diff --git a/docs/infrastructure/images/infra-sysmon.png b/docs/infrastructure/images/infra-sysmon.png index 5b82d8c9b4e19..dd653bb046f45 100644 Binary files a/docs/infrastructure/images/infra-sysmon.png and b/docs/infrastructure/images/infra-sysmon.png differ diff --git a/docs/infrastructure/images/infra-view-metrics.png b/docs/infrastructure/images/infra-view-metrics.png index 9ad862ec6515d..6001f18d283fe 100644 Binary files a/docs/infrastructure/images/infra-view-metrics.png and b/docs/infrastructure/images/infra-view-metrics.png differ diff --git a/docs/infrastructure/images/metrics-add-data.png b/docs/infrastructure/images/metrics-add-data.png index d9640e0d9f5da..f96c30f0e1848 100644 Binary files a/docs/infrastructure/images/metrics-add-data.png and b/docs/infrastructure/images/metrics-add-data.png differ diff --git a/docs/infrastructure/images/metrics-explorer-screen.png b/docs/infrastructure/images/metrics-explorer-screen.png index 7ccf8891678af..6d56491f7d485 100644 Binary files a/docs/infrastructure/images/metrics-explorer-screen.png and b/docs/infrastructure/images/metrics-explorer-screen.png differ diff --git a/docs/logs/images/log-rate-anomalies.png b/docs/logs/images/log-rate-anomalies.png index ac9ff7c9a5235..74ce8d682e1cc 100644 Binary files a/docs/logs/images/log-rate-anomalies.png and b/docs/logs/images/log-rate-anomalies.png differ diff --git a/docs/logs/images/log-rate-entries.png b/docs/logs/images/log-rate-entries.png index f8a3acc9883e0..efa693a2ac529 100644 Binary files a/docs/logs/images/log-rate-entries.png and b/docs/logs/images/log-rate-entries.png differ diff --git a/docs/logs/images/log-time-filter.png b/docs/logs/images/log-time-filter.png index 863e488e6c6c0..ffba6f972aeb7 100644 Binary files a/docs/logs/images/log-time-filter.png and b/docs/logs/images/log-time-filter.png differ diff --git a/docs/logs/images/logs-add-data.png b/docs/logs/images/logs-add-data.png index 2c4a65590aa1b..176c71466aa38 100644 Binary files a/docs/logs/images/logs-add-data.png and b/docs/logs/images/logs-add-data.png differ diff --git a/docs/logs/images/logs-console.png b/docs/logs/images/logs-console.png index 5feb3d9608974..8e94c31c6862a 100644 Binary files a/docs/logs/images/logs-console.png and b/docs/logs/images/logs-console.png differ diff --git a/docs/logs/using.asciidoc b/docs/logs/using.asciidoc index f191f7d746cf8..d84a9260521c7 100644 --- a/docs/logs/using.asciidoc +++ b/docs/logs/using.asciidoc @@ -8,7 +8,6 @@ You can also view related application traces or uptime information where availab [role="screenshot"] image::logs/images/logs-console.png[Logs Console in Kibana] -// ++ Update this [float] [[logs-search]] diff --git a/src/core/TESTING.md b/src/core/TESTING.md index aac54a4a14680..9abc2bb77d7d1 100644 --- a/src/core/TESTING.md +++ b/src/core/TESTING.md @@ -29,7 +29,6 @@ This document outlines best practices and patterns for testing Kibana Plugins. - [Testing dependencies usages](#testing-dependencies-usages) - [Testing components consuming the dependencies](#testing-components-consuming-the-dependencies) - [Testing optional plugin dependencies](#testing-optional-plugin-dependencies) - - [Plugin Contracts](#plugin-contracts) ## Strategy @@ -1082,7 +1081,3 @@ describe('Plugin', () => { }); }); ``` - -## Plugin Contracts - -_How to test your plugin's exposed API_ diff --git a/src/legacy/core_plugins/kibana/index.js b/src/legacy/core_plugins/kibana/index.js index 395e0da218307..36563ba8cbe45 100644 --- a/src/legacy/core_plugins/kibana/index.js +++ b/src/legacy/core_plugins/kibana/index.js @@ -25,7 +25,6 @@ import { migrations } from './migrations'; import { importApi } from './server/routes/api/import'; import { exportApi } from './server/routes/api/export'; import { managementApi } from './server/routes/api/management'; -import * as systemApi from './server/lib/system_api'; import mappings from './mappings.json'; import { getUiSettingDefaults } from './ui_setting_defaults'; import { registerCspCollector } from './server/lib/csp_usage_collector'; @@ -323,7 +322,6 @@ export default function(kibana) { exportApi(server); managementApi(server); registerCspCollector(usageCollection, server); - server.expose('systemApi', systemApi); server.injectUiAppVars('kibana', () => injectVars(server)); }, }); diff --git a/src/legacy/core_plugins/kibana/public/home/np_ready/components/__snapshots__/sample_data_view_data_button.test.js.snap b/src/legacy/core_plugins/kibana/public/home/np_ready/components/__snapshots__/sample_data_view_data_button.test.js.snap index e08d802406fff..661d1d33a5283 100644 --- a/src/legacy/core_plugins/kibana/public/home/np_ready/components/__snapshots__/sample_data_view_data_button.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/home/np_ready/components/__snapshots__/sample_data_view_data_button.test.js.snap @@ -14,6 +14,7 @@ exports[`should render popover when appLinks is not empty 1`] = ` } closePopover={[Function]} + data-test-subj="launchSampleDataSetecommerce" display="inlineBlock" hasArrow={true} id="sampleDataLinksecommerce" diff --git a/src/legacy/core_plugins/kibana/public/home/np_ready/components/sample_data_view_data_button.js b/src/legacy/core_plugins/kibana/public/home/np_ready/components/sample_data_view_data_button.js index e6f5c07c94f9f..cb43c18a8e78b 100644 --- a/src/legacy/core_plugins/kibana/public/home/np_ready/components/sample_data_view_data_button.js +++ b/src/legacy/core_plugins/kibana/public/home/np_ready/components/sample_data_view_data_button.js @@ -112,6 +112,7 @@ export class SampleDataViewDataButton extends React.Component { closePopover={this.closePopover} panelPaddingSize="none" anchorPosition="downCenter" + data-test-subj={`launchSampleDataSet${this.props.id}`} > diff --git a/src/legacy/core_plugins/kibana/server/lib/__tests__/system_api.js b/src/legacy/core_plugins/kibana/server/lib/__tests__/system_api.js deleted file mode 100644 index a63a93f3a70d5..0000000000000 --- a/src/legacy/core_plugins/kibana/server/lib/__tests__/system_api.js +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import expect from '@kbn/expect'; -import { isSystemApiRequest } from '../system_api'; - -describe('system_api', () => { - describe('#isSystemApiRequest', () => { - it('returns true for a system API HTTP request', () => { - const mockHapiRequest = { - headers: { - 'kbn-system-api': true, - }, - }; - expect(isSystemApiRequest(mockHapiRequest)).to.be(true); - }); - - it('returns false for a non-system API HTTP request', () => { - const mockHapiRequest = { - headers: {}, - }; - expect(isSystemApiRequest(mockHapiRequest)).to.be(false); - }); - }); -}); diff --git a/src/legacy/core_plugins/kibana/server/lib/system_api.js b/src/legacy/core_plugins/kibana/server/lib/system_api.js deleted file mode 100644 index 3e2ab667dd98b..0000000000000 --- a/src/legacy/core_plugins/kibana/server/lib/system_api.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -const SYSTEM_API_HEADER_NAME = 'kbn-system-api'; - -/** - * Checks on the *server-side*, if an HTTP request is a system API request - * - * @param request HAPI request object - * @return true if request is a system API request; false, otherwise - * @deprecated Use KibanaRequest#isSystemApi - */ -export function isSystemApiRequest(request) { - return !!request.headers[SYSTEM_API_HEADER_NAME]; -} diff --git a/src/legacy/ui/public/system_api/__tests__/system_api.js b/src/legacy/ui/public/system_api/__tests__/system_api.js index 822edaa08fdd6..816024f13f8b2 100644 --- a/src/legacy/ui/public/system_api/__tests__/system_api.js +++ b/src/legacy/ui/public/system_api/__tests__/system_api.js @@ -31,8 +31,8 @@ describe('system_api', () => { }; const newHeaders = addSystemApiHeader(headers); - expect(newHeaders).to.have.property('kbn-system-api'); - expect(newHeaders['kbn-system-api']).to.be(true); + expect(newHeaders).to.have.property('kbn-system-request'); + expect(newHeaders['kbn-system-request']).to.be(true); expect(newHeaders).to.have.property('kbn-version'); expect(newHeaders['kbn-version']).to.be('4.6.0'); @@ -40,7 +40,16 @@ describe('system_api', () => { }); describe('#isSystemApiRequest', () => { - it('returns true for a system API HTTP request', () => { + it('returns true for a system HTTP request', () => { + const mockRequest = { + headers: { + 'kbn-system-request': true, + }, + }; + expect(isSystemApiRequest(mockRequest)).to.be(true); + }); + + it('returns true for a legacy system API HTTP request', () => { const mockRequest = { headers: { 'kbn-system-api': true, diff --git a/src/plugins/kibana_legacy/public/utils/system_api.ts b/src/plugins/kibana_legacy/public/utils/system_api.ts index 397de4dcc2bb3..49d4a78584737 100644 --- a/src/plugins/kibana_legacy/public/utils/system_api.ts +++ b/src/plugins/kibana_legacy/public/utils/system_api.ts @@ -19,7 +19,8 @@ import { IRequestConfig } from 'angular'; -const SYSTEM_API_HEADER_NAME = 'kbn-system-api'; +const SYSTEM_REQUEST_HEADER_NAME = 'kbn-system-request'; +const LEGACY_SYSTEM_API_HEADER_NAME = 'kbn-system-api'; /** * Adds a custom header designating request as system API @@ -28,7 +29,7 @@ const SYSTEM_API_HEADER_NAME = 'kbn-system-api'; */ export function addSystemApiHeader(originalHeaders: Record) { const systemApiHeaders = { - [SYSTEM_API_HEADER_NAME]: true, + [SYSTEM_REQUEST_HEADER_NAME]: true, }; return { ...originalHeaders, @@ -44,5 +45,7 @@ export function addSystemApiHeader(originalHeaders: Record) { */ export function isSystemApiRequest(request: IRequestConfig) { const { headers } = request; - return headers && !!headers[SYSTEM_API_HEADER_NAME]; + return ( + headers && (!!headers[SYSTEM_REQUEST_HEADER_NAME] || !!headers[LEGACY_SYSTEM_API_HEADER_NAME]) + ); } diff --git a/test/functional/apps/home/_sample_data.ts b/test/functional/apps/home/_sample_data.ts index 8088b5a0f9da9..8bc528e045566 100644 --- a/test/functional/apps/home/_sample_data.ts +++ b/test/functional/apps/home/_sample_data.ts @@ -84,7 +84,7 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { }); it('should launch sample flights data set dashboard', async () => { - await PageObjects.home.launchSampleDataSet('flights'); + await PageObjects.home.launchSampleDashboard('flights'); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); const todayYearMonthDay = moment().format('MMM D, YYYY'); @@ -96,7 +96,7 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { }); it('should render visualizations', async () => { - await PageObjects.home.launchSampleDataSet('flights'); + await PageObjects.home.launchSampleDashboard('flights'); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); log.debug('Checking pie charts rendered'); @@ -115,7 +115,7 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { }); it('should launch sample logs data set dashboard', async () => { - await PageObjects.home.launchSampleDataSet('logs'); + await PageObjects.home.launchSampleDashboard('logs'); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); const todayYearMonthDay = moment().format('MMM D, YYYY'); @@ -127,7 +127,7 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { }); it('should launch sample ecommerce data set dashboard', async () => { - await PageObjects.home.launchSampleDataSet('ecommerce'); + await PageObjects.home.launchSampleDashboard('ecommerce'); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); const todayYearMonthDay = moment().format('MMM D, YYYY'); diff --git a/test/functional/page_objects/home_page.ts b/test/functional/page_objects/home_page.ts index a641fbda023c3..6225b4e3aca62 100644 --- a/test/functional/page_objects/home_page.ts +++ b/test/functional/page_objects/home_page.ts @@ -19,9 +19,12 @@ import { FtrProviderContext } from '../ftr_provider_context'; -export function HomePageProvider({ getService }: FtrProviderContext) { +export function HomePageProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const retry = getService('retry'); + const find = getService('find'); + const PageObjects = getPageObjects(['common']); + let isOss = true; class HomePage { async clickSynopsis(title: string) { @@ -63,6 +66,14 @@ export function HomePageProvider({ getService }: FtrProviderContext) { }); } + async launchSampleDashboard(id: string) { + await this.launchSampleDataSet(id); + isOss = await PageObjects.common.isOss(); + if (!isOss) { + await find.clickByLinkText('Dashboard'); + } + } + async launchSampleDataSet(id: string) { await this.addSampleDataSet(id); await testSubjects.click(`launchSampleDataSet${id}`); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap index a45357121354f..ed09b71f0c31c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap @@ -305,7 +305,7 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = ` exports[`ErrorGroupOverview -> List should render with data 1`] = ` .c0 { - font-family: "SFMono-Regular",Consolas,"Liberation Mono",Menlo,Courier,monospace; + font-family: "Roboto Mono",Consolas,Menlo,Courier,monospace; } .c1 { @@ -316,7 +316,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` } .c2 { - font-family: "SFMono-Regular",Consolas,"Liberation Mono",Menlo,Courier,monospace; + font-family: "Roboto Mono",Consolas,Menlo,Courier,monospace; font-size: 16px; max-width: 100%; white-space: nowrap; @@ -325,7 +325,7 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` } .c3 { - font-family: "SFMono-Regular",Consolas,"Liberation Mono",Menlo,Courier,monospace; + font-family: "Roboto Mono",Consolas,Menlo,Courier,monospace; } { - const tooltipState = getTooltipState(getState()); - - if (!tooltipState) { - return; - } - - const nextTooltipFeatures = tooltipState.features.filter(tooltipFeature => { - if (tooltipFeature.layerId !== layerId) { - // feature from another layer, keep it - return true; - } - - // Keep feature if it is still in layer - return layerFeatures.some(layerFeature => { - return layerFeature.properties[FEATURE_ID_PROPERTY_NAME] === tooltipFeature.id; + let featuresRemoved = false; + const openTooltips = getOpenTooltips(getState()) + .map(tooltipState => { + const nextFeatures = tooltipState.features.filter(tooltipFeature => { + if (tooltipFeature.layerId !== layerId) { + // feature from another layer, keep it + return true; + } + + // Keep feature if it is still in layer + return layerFeatures.some(layerFeature => { + return layerFeature.properties[FEATURE_ID_PROPERTY_NAME] === tooltipFeature.id; + }); + }); + + if (tooltipState.features.length !== nextFeatures.length) { + featuresRemoved = true; + } + + return { ...tooltipState, features: nextFeatures }; + }) + .filter(tooltipState => { + return tooltipState.features.length > 0; }); - }); - - if (tooltipState.features.length === nextTooltipFeatures.length) { - // no features got removed, nothing to update - return; - } - if (nextTooltipFeatures.length === 0) { - // all features removed from tooltip, close tooltip - dispatch(setTooltipState(null)); - } else { - dispatch(setTooltipState({ ...tooltipState, features: nextTooltipFeatures })); + if (featuresRemoved) { + dispatch({ + type: SET_OPEN_TOOLTIPS, + openTooltips, + }); } }; } @@ -412,10 +416,61 @@ export function mapExtentChanged(newMapConstants) { }; } -export function setTooltipState(tooltipState) { +export function closeOnClickTooltip(tooltipId) { + return (dispatch, getState) => { + dispatch({ + type: SET_OPEN_TOOLTIPS, + openTooltips: getOpenTooltips(getState()).filter(({ id }) => { + return tooltipId !== id; + }), + }); + }; +} + +export function openOnClickTooltip(tooltipState) { + return (dispatch, getState) => { + const openTooltips = getOpenTooltips(getState()).filter(({ features, location, isLocked }) => { + return ( + isLocked && + !_.isEqual(location, tooltipState.location) && + !_.isEqual(features, tooltipState.features) + ); + }); + + openTooltips.push({ + ...tooltipState, + isLocked: true, + id: uuid(), + }); + + dispatch({ + type: SET_OPEN_TOOLTIPS, + openTooltips, + }); + }; +} + +export function closeOnHoverTooltip() { + return (dispatch, getState) => { + if (getOpenTooltips(getState()).length) { + dispatch({ + type: SET_OPEN_TOOLTIPS, + openTooltips: [], + }); + } + }; +} + +export function openOnHoverTooltip(tooltipState) { return { - type: 'SET_TOOLTIP_STATE', - tooltipState: tooltipState, + type: SET_OPEN_TOOLTIPS, + openTooltips: [ + { + ...tooltipState, + isLocked: false, + id: uuid(), + }, + ], }; } @@ -826,9 +881,9 @@ export function setJoinsForLayer(layer, joins) { } export function updateDrawState(drawState) { - return async dispatch => { + return dispatch => { if (drawState !== null) { - await dispatch(setTooltipState(null)); //tooltips just get in the way + dispatch({ type: SET_OPEN_TOOLTIPS, openTooltips: [] }); // tooltips just get in the way } dispatch({ type: UPDATE_DRAW_STATE, diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/index.js b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/index.js index 0274f849daf3d..9148fbdfd2d1e 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/index.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/index.js @@ -16,7 +16,6 @@ import { setMapInitError, } from '../../../actions/map_actions'; import { - getTooltipState, getLayerList, getMapReady, getGoto, @@ -33,7 +32,6 @@ function mapStateToProps(state = {}) { layerList: getLayerList(state), goto: getGoto(state), inspectorAdapters: getInspectorAdapters(state), - tooltipState: getTooltipState(state), scrollZoom: getScrollZoom(state), disableInteractive: isInteractiveDisabled(state), disableTooltipControl: isTooltipControlDisabled(state), diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/__snapshots__/tooltip_control.test.js.snap b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/__snapshots__/tooltip_control.test.js.snap index 7e8feeec01bbd..cffa441d04ff5 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/__snapshots__/tooltip_control.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/__snapshots__/tooltip_control.test.js.snap @@ -1,117 +1,97 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`TooltipControl render tooltipState is not provided should not render tooltip popover when tooltipState is not provided 1`] = `""`; +exports[`TooltipControl render should not render tooltips when there are no open tooltips 1`] = `""`; -exports[`TooltipControl render tooltipState is provided should render tooltip popover with custom tooltip content when renderTooltipContent provided 1`] = ` - +exports[`TooltipControl render should render hover tooltip 1`] = ` + -
- Custom tooltip content -
-
+/> `; -exports[`TooltipControl render tooltipState is provided should render tooltip popover with features tooltip content 1`] = ` - +exports[`TooltipControl render should render locked tooltip 1`] = ` + - - - - + } +/> `; diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/__snapshots__/tooltip_popover.test.js.snap b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/__snapshots__/tooltip_popover.test.js.snap new file mode 100644 index 0000000000000..d95a418988ae7 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/__snapshots__/tooltip_popover.test.js.snap @@ -0,0 +1,115 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TooltipPopover render should render tooltip popover 1`] = ` + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="mapTooltip" + isOpen={true} + ownFocus={false} + panelPaddingSize="m" + style={ + Object { + "pointerEvents": "none", + "transform": "translate(NaNpx, 2987px)", + } + } +> + + + + +`; + +exports[`TooltipPopover render should render tooltip popover with custom tooltip content when renderTooltipContent provided 1`] = ` + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="mapTooltip" + isOpen={true} + ownFocus={false} + panelPaddingSize="m" + style={ + Object { + "pointerEvents": "none", + "transform": "translate(NaNpx, 2987px)", + } + } +> +
+ Custom tooltip content +
+
+`; diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/index.js b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/index.js index 6bc9511c6c580..d3cdbfeca3e57 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/index.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/index.js @@ -6,28 +6,41 @@ import { connect } from 'react-redux'; import { TooltipControl } from './tooltip_control'; -import { setTooltipState } from '../../../../actions/map_actions'; +import { + closeOnClickTooltip, + openOnClickTooltip, + closeOnHoverTooltip, + openOnHoverTooltip, +} from '../../../../actions/map_actions'; import { getLayerList, - getTooltipState, + getOpenTooltips, + getHasLockedTooltips, isDrawingFilter, } from '../../../../selectors/map_selectors'; function mapStateToProps(state = {}) { return { layerList: getLayerList(state), - tooltipState: getTooltipState(state), + hasLockedTooltips: getHasLockedTooltips(state), isDrawingFilter: isDrawingFilter(state), + openTooltips: getOpenTooltips(state), }; } function mapDispatchToProps(dispatch) { return { - setTooltipState(tooltipState) { - dispatch(setTooltipState(tooltipState)); + closeOnClickTooltip(tooltipId) { + dispatch(closeOnClickTooltip(tooltipId)); + }, + openOnClickTooltip(tooltipState) { + dispatch(openOnClickTooltip(tooltipState)); + }, + closeOnHoverTooltip() { + dispatch(closeOnHoverTooltip()); }, - clearTooltipState() { - dispatch(setTooltipState(null)); + openOnHoverTooltip(tooltipState) { + dispatch(openOnHoverTooltip(tooltipState)); }, }; } diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js index cfb92a8677455..329d2b7fd2985 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.js @@ -6,16 +6,8 @@ import _ from 'lodash'; import React from 'react'; -import { FEATURE_ID_PROPERTY_NAME, LAT_INDEX, LON_INDEX } from '../../../../../common/constants'; -import { FeaturesTooltip } from '../../features_tooltip/features_tooltip'; -import { EuiPopover, EuiText } from '@elastic/eui'; - -export const TOOLTIP_TYPE = { - HOVER: 'HOVER', - LOCKED: 'LOCKED', -}; - -const noop = () => {}; +import { FEATURE_ID_PROPERTY_NAME, LON_INDEX } from '../../../../../common/constants'; +import { TooltipPopover } from './tooltip_popover'; function justifyAnchorLocation(mbLngLat, targetFeature) { let popupAnchorLocation = [mbLngLat.lng, mbLngLat.lat]; // default popup location to mouse location @@ -35,80 +27,23 @@ function justifyAnchorLocation(mbLngLat, targetFeature) { } export class TooltipControl extends React.Component { - state = { - x: undefined, - y: undefined, - }; - - constructor(props) { - super(props); - this._popoverRef = React.createRef(); - } - - static getDerivedStateFromProps(nextProps, prevState) { - if (nextProps.tooltipState) { - const nextPoint = nextProps.mbMap.project(nextProps.tooltipState.location); - if (nextPoint.x !== prevState.x || nextPoint.y !== prevState.y) { - return { - x: nextPoint.x, - y: nextPoint.y, - }; - } - } - - return null; - } - componentDidMount() { this.props.mbMap.on('mouseout', this._onMouseout); this.props.mbMap.on('mousemove', this._updateHoverTooltipState); - this.props.mbMap.on('move', this._updatePopoverPosition); this.props.mbMap.on('click', this._lockTooltip); } - componentDidUpdate() { - if (this.props.tooltipState && this._popoverRef.current) { - this._popoverRef.current.positionPopoverFluid(); - } - } - componentWillUnmount() { this.props.mbMap.off('mouseout', this._onMouseout); this.props.mbMap.off('mousemove', this._updateHoverTooltipState); - this.props.mbMap.off('move', this._updatePopoverPosition); this.props.mbMap.off('click', this._lockTooltip); } _onMouseout = () => { this._updateHoverTooltipState.cancel(); - if (this.props.tooltipState && this.props.tooltipState.type !== TOOLTIP_TYPE.LOCKED) { - this.props.clearTooltipState(); - } - }; - - _updatePopoverPosition = () => { - if (!this.props.tooltipState) { - return; + if (!this.props.hasLockedTooltips) { + this.props.closeOnHoverTooltip(); } - - const lat = this.props.tooltipState.location[LAT_INDEX]; - const lon = this.props.tooltipState.location[LON_INDEX]; - const bounds = this.props.mbMap.getBounds(); - if ( - lat > bounds.getNorth() || - lat < bounds.getSouth() || - lon < bounds.getWest() || - lon > bounds.getEast() - ) { - this.props.clearTooltipState(); - return; - } - - const nextPoint = this.props.mbMap.project(this.props.tooltipState.location); - this.setState({ - x: nextPoint.x, - y: nextPoint.y, - }); }; _getLayerByMbLayerId(mbLayerId) { @@ -148,7 +83,7 @@ export class TooltipControl extends React.Component { _lockTooltip = e => { if (this.props.isDrawingFilter) { - //ignore click events when in draw mode + // ignore click events when in draw mode return; } @@ -156,7 +91,7 @@ export class TooltipControl extends React.Component { const mbFeatures = this._getFeaturesUnderPointer(e.point); if (!mbFeatures.length) { - this.props.clearTooltipState(); + // No features at click location so there is no tooltip to open return; } @@ -164,42 +99,36 @@ export class TooltipControl extends React.Component { const popupAnchorLocation = justifyAnchorLocation(e.lngLat, targetMbFeataure); const features = this._getIdsForFeatures(mbFeatures); - this.props.setTooltipState({ - type: TOOLTIP_TYPE.LOCKED, + this.props.openOnClickTooltip({ features: features, location: popupAnchorLocation, }); }; _updateHoverTooltipState = _.debounce(e => { - if (this.props.isDrawingFilter) { - //ignore hover events when in draw mode - return; - } - - if (this.props.tooltipState && this.props.tooltipState.type === TOOLTIP_TYPE.LOCKED) { - //ignore hover events when tooltip is locked + if (this.props.isDrawingFilter || this.props.hasLockedTooltips) { + // ignore hover events when in draw mode or when there are locked tooltips return; } const mbFeatures = this._getFeaturesUnderPointer(e.point); if (!mbFeatures.length) { - this.props.clearTooltipState(); + this.props.closeOnHoverTooltip(); return; } const targetMbFeature = mbFeatures[0]; - if (this.props.tooltipState) { - const firstFeature = this.props.tooltipState.features[0]; + if (this.props.openTooltips[0]) { + const firstFeature = this.props.openTooltips[0].features[0]; if (targetMbFeature.properties[FEATURE_ID_PROPERTY_NAME] === firstFeature.id) { + // ignore hover events when hover tooltip is all ready opened for feature return; } } const popupAnchorLocation = justifyAnchorLocation(e.lngLat, targetMbFeature); const features = this._getIdsForFeatures(mbFeatures); - this.props.setTooltipState({ - type: TOOLTIP_TYPE.HOVER, + this.props.openOnHoverTooltip({ features: features, location: popupAnchorLocation, }); @@ -240,114 +169,32 @@ export class TooltipControl extends React.Component { return this.props.mbMap.queryRenderedFeatures(mbBbox, { layers: mbLayerIds }); } - // Must load original geometry instead of using geometry from mapbox feature. - // Mapbox feature geometry is from vector tile and is not the same as the original geometry. - _loadFeatureGeometry = ({ layerId, featureId }) => { - const tooltipLayer = this._findLayerById(layerId); - if (!tooltipLayer) { - return null; - } - - const targetFeature = tooltipLayer.getFeatureById(featureId); - if (!targetFeature) { - return null; - } - - return targetFeature.geometry; - }; - - _loadFeatureProperties = async ({ layerId, featureId }) => { - const tooltipLayer = this._findLayerById(layerId); - if (!tooltipLayer) { - return []; - } - - const targetFeature = tooltipLayer.getFeatureById(featureId); - if (!targetFeature) { - return []; - } - return await tooltipLayer.getPropertiesForTooltip(targetFeature.properties); - }; - - _loadPreIndexedShape = async ({ layerId, featureId }) => { - const tooltipLayer = this._findLayerById(layerId); - if (!tooltipLayer) { - return null; - } - - const targetFeature = tooltipLayer.getFeatureById(featureId); - if (!targetFeature) { - return null; - } - - return await tooltipLayer.getSource().getPreIndexedShape(targetFeature.properties); - }; - - _findLayerById = layerId => { - return this.props.layerList.find(layer => { - return layer.getId() === layerId; - }); - }; - - _getLayerName = async layerId => { - const layer = this._findLayerById(layerId); - if (!layer) { + render() { + if (this.props.openTooltips.length === 0) { return null; } - return layer.getDisplayName(); - }; - - _renderTooltipContent = () => { - const publicProps = { - addFilters: this.props.addFilters, - closeTooltip: this.props.clearTooltipState, - features: this.props.tooltipState.features, - isLocked: this.props.tooltipState.type === TOOLTIP_TYPE.LOCKED, - loadFeatureProperties: this._loadFeatureProperties, - loadFeatureGeometry: this._loadFeatureGeometry, - getLayerName: this._getLayerName, - }; - - if (this.props.renderTooltipContent) { - return this.props.renderTooltipContent(publicProps); - } - - return ( - - { + const closeTooltip = isLocked + ? () => { + this.props.closeOnClickTooltip(id); + } + : this.props.closeOnHoverTooltip; + return ( + - - ); - }; - - render() { - if (!this.props.tooltipState) { - return null; - } - - const tooltipAnchor = ( -
- ); - return ( - - {this._renderTooltipContent()} - - ); + ); + }); } } diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.test.js b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.test.js index b9dc668cfb016..620d7cb9ff756 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.test.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_control.test.js @@ -4,21 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -jest.mock('../../features_tooltip/features_tooltip', () => ({ - FeaturesTooltip: () => { - return
mockFeaturesTooltip
; +jest.mock('./tooltip_popover', () => ({ + TooltipPopover: () => { + return
mockTooltipPopover
; }, })); import sinon from 'sinon'; import React from 'react'; import { mount, shallow } from 'enzyme'; -import { TooltipControl, TOOLTIP_TYPE } from './tooltip_control'; +import { TooltipControl } from './tooltip_control'; // mutable map state let featuresAtLocation; -let mapCenter; -let mockMbMapBounds; const layerId = 'tfi3f'; const mbLayerId = 'tfi3f_circle'; @@ -32,48 +30,16 @@ const mockLayer = { canShowTooltip: () => { return true; }, - getFeatureById: () => { - return { - geometry: { - type: 'Point', - coordinates: [102.0, 0.5], - }, - }; - }, }; const mockMbMapHandlers = {}; const mockMBMap = { - project: lonLatArray => { - const lonDistanceFromCenter = Math.abs(lonLatArray[0] - mapCenter[0]); - const latDistanceFromCenter = Math.abs(lonLatArray[1] - mapCenter[1]); - return { - x: lonDistanceFromCenter * 100, - y: latDistanceFromCenter * 100, - }; - }, on: (eventName, callback) => { mockMbMapHandlers[eventName] = callback; }, off: eventName => { delete mockMbMapHandlers[eventName]; }, - getBounds: () => { - return { - getNorth: () => { - return mockMbMapBounds.north; - }, - getSouth: () => { - return mockMbMapBounds.south; - }, - getWest: () => { - return mockMbMapBounds.west; - }, - getEast: () => { - return mockMbMapBounds.east; - }, - }; - }, getLayer: () => {}, queryRenderedFeatures: () => { return featuresAtLocation; @@ -82,16 +48,21 @@ const mockMBMap = { const defaultProps = { mbMap: mockMBMap, - clearTooltipState: () => {}, - setTooltipState: () => {}, + closeOnClickTooltip: () => {}, + openOnClickTooltip: () => {}, + closeOnHoverTooltip: () => {}, + openOnHoverTooltip: () => {}, layerList: [mockLayer], isDrawingFilter: false, addFilters: () => {}, geoFields: [{}], + openTooltips: [], + hasLockedTooltips: false, }; const hoverTooltipState = { - type: TOOLTIP_TYPE.HOVER, + id: '1', + isLocked: false, location: [-120, 30], features: [ { @@ -103,7 +74,8 @@ const hoverTooltipState = { }; const lockedTooltipState = { - type: TOOLTIP_TYPE.LOCKED, + id: '2', + isLocked: true, location: [-120, 30], features: [ { @@ -117,82 +89,79 @@ const lockedTooltipState = { describe('TooltipControl', () => { beforeEach(() => { featuresAtLocation = []; - mapCenter = [0, 0]; - mockMbMapBounds = { - west: -180, - east: 180, - north: 90, - south: -90, - }; }); describe('render', () => { - describe('tooltipState is not provided', () => { - test('should not render tooltip popover when tooltipState is not provided', () => { - const component = shallow(); + test('should not render tooltips when there are no open tooltips', () => { + const component = shallow(); - expect(component).toMatchSnapshot(); - }); + expect(component).toMatchSnapshot(); }); - describe('tooltipState is provided', () => { - test('should render tooltip popover with features tooltip content', () => { - const component = shallow( - - ); + test('should render hover tooltip', () => { + const component = shallow( + + ); - expect(component).toMatchSnapshot(); - }); + expect(component).toMatchSnapshot(); + }); - test('should render tooltip popover with custom tooltip content when renderTooltipContent provided', () => { - const component = shallow( - { - return
Custom tooltip content
; - }} - /> - ); - - expect(component).toMatchSnapshot(); - }); + test('should render locked tooltip', () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('should un-register all map callbacks on unmount', () => { + const component = mount(); + + expect(Object.keys(mockMbMapHandlers).length).toBe(3); + + component.unmount(); + expect(Object.keys(mockMbMapHandlers).length).toBe(0); }); }); describe('on mouse out', () => { - const clearTooltipStateStub = sinon.stub(); + const closeOnHoverTooltipStub = sinon.stub(); beforeEach(() => { - clearTooltipStateStub.reset(); + closeOnHoverTooltipStub.reset(); }); test('should clear hover tooltip state', () => { mount( ); mockMbMapHandlers.mouseout(); - sinon.assert.calledOnce(clearTooltipStateStub); + sinon.assert.calledOnce(closeOnHoverTooltipStub); }); test('should not clear locked tooltip state', () => { mount( ); mockMbMapHandlers.mouseout(); - sinon.assert.notCalled(clearTooltipStateStub); + sinon.assert.notCalled(closeOnHoverTooltipStub); }); }); @@ -201,44 +170,44 @@ describe('TooltipControl', () => { point: { x: 0, y: 0 }, lngLat: { lng: 0, lat: 0 }, }; - const setTooltipStateStub = sinon.stub(); - const clearTooltipStateStub = sinon.stub(); + const openOnClickTooltipStub = sinon.stub(); + const closeOnClickTooltipStub = sinon.stub(); beforeEach(() => { - setTooltipStateStub.reset(); - clearTooltipStateStub.reset(); + openOnClickTooltipStub.reset(); + closeOnClickTooltipStub.reset(); }); test('should ignore clicks when map is in drawing mode', () => { mount( ); mockMbMapHandlers.click(mockMapMouseEvent); - sinon.assert.notCalled(clearTooltipStateStub); - sinon.assert.notCalled(setTooltipStateStub); + sinon.assert.notCalled(closeOnClickTooltipStub); + sinon.assert.notCalled(openOnClickTooltipStub); }); - test('should clear tooltip state when there are no features at clicked location', () => { + test('should not open tooltip when there are no features at clicked location', () => { featuresAtLocation = []; mount( ); mockMbMapHandlers.click(mockMapMouseEvent); - sinon.assert.calledOnce(clearTooltipStateStub); - sinon.assert.notCalled(setTooltipStateStub); + sinon.assert.notCalled(closeOnClickTooltipStub); + sinon.assert.notCalled(openOnClickTooltipStub); }); test('should set tooltip state when there are features at clicked location and remove duplicate features', () => { @@ -258,93 +227,18 @@ describe('TooltipControl', () => { mount( ); mockMbMapHandlers.click(mockMapMouseEvent); - sinon.assert.notCalled(clearTooltipStateStub); - sinon.assert.calledWith(setTooltipStateStub, { + sinon.assert.notCalled(closeOnClickTooltipStub); + sinon.assert.calledWith(openOnClickTooltipStub, { features: [{ id: 1, layerId: 'tfi3f' }], location: [100, 30], - type: 'LOCKED', }); }); }); - - describe('on map move', () => { - const clearTooltipStateStub = sinon.stub(); - - beforeEach(() => { - clearTooltipStateStub.reset(); - }); - - test('should safely handle map move when there is no tooltip location', () => { - const component = mount( - - ); - - mockMbMapHandlers.move(); - component.update(); - - sinon.assert.notCalled(clearTooltipStateStub); - }); - - test('should update popover location', () => { - const component = mount( - - ); - - // ensure x and y set from original tooltipState.location - expect(component.state('x')).toBe(12000); - expect(component.state('y')).toBe(3000); - - mapCenter = [25, -15]; - mockMbMapHandlers.move(); - component.update(); - - // ensure x and y updated from new map center with same tooltipState.location - expect(component.state('x')).toBe(14500); - expect(component.state('y')).toBe(4500); - - sinon.assert.notCalled(clearTooltipStateStub); - }); - - test('should clear tooltip state if tooltip location is outside map bounds', () => { - const component = mount( - - ); - - // move map bounds outside of hoverTooltipState.location, which is [-120, 30] - mockMbMapBounds = { - west: -180, - east: -170, - north: 90, - south: 80, - }; - mockMbMapHandlers.move(); - component.update(); - - sinon.assert.calledOnce(clearTooltipStateStub); - }); - }); - - test('should un-register all map callbacks on unmount', () => { - const component = mount(); - - expect(Object.keys(mockMbMapHandlers).length).toBe(4); - - component.unmount(); - expect(Object.keys(mockMbMapHandlers).length).toBe(0); - }); }); diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_popover.js b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_popover.js new file mode 100644 index 0000000000000..867c779bc4dba --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_popover.js @@ -0,0 +1,169 @@ +/* + * 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 { LAT_INDEX, LON_INDEX } from '../../../../../common/constants'; +import { FeaturesTooltip } from '../../features_tooltip/features_tooltip'; +import { EuiPopover, EuiText } from '@elastic/eui'; + +const noop = () => {}; + +export class TooltipPopover extends Component { + state = { + x: undefined, + y: undefined, + isVisible: true, + }; + + constructor(props) { + super(props); + this._popoverRef = React.createRef(); + } + + componentDidMount() { + this._updatePopoverPosition(); + this.props.mbMap.on('move', this._updatePopoverPosition); + } + + componentDidUpdate() { + if (this._popoverRef.current) { + this._popoverRef.current.positionPopoverFluid(); + } + } + + componentWillUnmount() { + this.props.mbMap.off('move', this._updatePopoverPosition); + } + + _updatePopoverPosition = () => { + const nextPoint = this.props.mbMap.project(this.props.location); + const lat = this.props.location[LAT_INDEX]; + const lon = this.props.location[LON_INDEX]; + const bounds = this.props.mbMap.getBounds(); + this.setState({ + x: nextPoint.x, + y: nextPoint.y, + isVisible: + lat < bounds.getNorth() && + lat > bounds.getSouth() && + lon > bounds.getWest() && + lon < bounds.getEast(), + }); + }; + + // Must load original geometry instead of using geometry from mapbox feature. + // Mapbox feature geometry is from vector tile and is not the same as the original geometry. + _loadFeatureGeometry = ({ layerId, featureId }) => { + const tooltipLayer = this._findLayerById(layerId); + if (!tooltipLayer) { + return null; + } + + const targetFeature = tooltipLayer.getFeatureById(featureId); + if (!targetFeature) { + return null; + } + + return targetFeature.geometry; + }; + + _loadFeatureProperties = async ({ layerId, featureId }) => { + const tooltipLayer = this._findLayerById(layerId); + if (!tooltipLayer) { + return []; + } + + const targetFeature = tooltipLayer.getFeatureById(featureId); + if (!targetFeature) { + return []; + } + return await tooltipLayer.getPropertiesForTooltip(targetFeature.properties); + }; + + _loadPreIndexedShape = async ({ layerId, featureId }) => { + const tooltipLayer = this._findLayerById(layerId); + if (!tooltipLayer) { + return null; + } + + const targetFeature = tooltipLayer.getFeatureById(featureId); + if (!targetFeature) { + return null; + } + + return await tooltipLayer.getSource().getPreIndexedShape(targetFeature.properties); + }; + + _findLayerById = layerId => { + return this.props.layerList.find(layer => { + return layer.getId() === layerId; + }); + }; + + _getLayerName = async layerId => { + const layer = this._findLayerById(layerId); + if (!layer) { + return null; + } + + return layer.getDisplayName(); + }; + + _renderTooltipContent = () => { + const publicProps = { + addFilters: this.props.addFilters, + closeTooltip: this.props.closeTooltip, + features: this.props.features, + isLocked: this.props.isLocked, + loadFeatureProperties: this._loadFeatureProperties, + loadFeatureGeometry: this._loadFeatureGeometry, + getLayerName: this._getLayerName, + }; + + if (this.props.renderTooltipContent) { + return this.props.renderTooltipContent(publicProps); + } + + return ( + + + + ); + }; + + render() { + if (!this.state.isVisible) { + return null; + } + + const tooltipAnchor =
; + // Although tooltip anchors are not visible, they take up horizontal space. + // This horizontal spacing needs to be accounted for in the translate function, + // otherwise the anchors get increasingly pushed to the right away from the actual location. + const offset = this.props.index * 26; + return ( + + {this._renderTooltipContent()} + + ); + } +} diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_popover.test.js b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_popover.test.js new file mode 100644 index 0000000000000..bcef03c205b2b --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/tooltip_control/tooltip_popover.test.js @@ -0,0 +1,142 @@ +/* + * 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. + */ + +jest.mock('../../features_tooltip/features_tooltip', () => ({ + FeaturesTooltip: () => { + return
mockFeaturesTooltip
; + }, +})); + +import sinon from 'sinon'; +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import { TooltipPopover } from './tooltip_popover'; + +// mutable map state +let mapCenter; +let mockMbMapBounds; + +const layerId = 'tfi3f'; + +const mockMbMapHandlers = {}; +const mockMBMap = { + project: lonLatArray => { + const lonDistanceFromCenter = Math.abs(lonLatArray[0] - mapCenter[0]); + const latDistanceFromCenter = Math.abs(lonLatArray[1] - mapCenter[1]); + return { + x: lonDistanceFromCenter * 100, + y: latDistanceFromCenter * 100, + }; + }, + on: (eventName, callback) => { + mockMbMapHandlers[eventName] = callback; + }, + off: eventName => { + delete mockMbMapHandlers[eventName]; + }, + getBounds: () => { + return { + getNorth: () => { + return mockMbMapBounds.north; + }, + getSouth: () => { + return mockMbMapBounds.south; + }, + getWest: () => { + return mockMbMapBounds.west; + }, + getEast: () => { + return mockMbMapBounds.east; + }, + }; + }, +}; + +const defaultProps = { + mbMap: mockMBMap, + closeTooltip: () => {}, + layerList: [], + isDrawingFilter: false, + addFilters: () => {}, + geoFields: [{}], + location: [-120, 30], + features: [ + { + id: 1, + layerId: layerId, + geometry: {}, + }, + ], + isLocked: false, +}; + +describe('TooltipPopover', () => { + beforeEach(() => { + mapCenter = [0, 0]; + mockMbMapBounds = { + west: -180, + east: 180, + north: 90, + south: -90, + }; + }); + + describe('render', () => { + test('should render tooltip popover', () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); + }); + + test('should render tooltip popover with custom tooltip content when renderTooltipContent provided', () => { + const component = shallow( + { + return
Custom tooltip content
; + }} + /> + ); + + expect(component).toMatchSnapshot(); + }); + + test('should un-register all map callbacks on unmount', () => { + const component = mount(); + + expect(Object.keys(mockMbMapHandlers).length).toBe(1); + + component.unmount(); + expect(Object.keys(mockMbMapHandlers).length).toBe(0); + }); + }); + + describe('on map move', () => { + const closeTooltipStub = sinon.stub(); + + beforeEach(() => { + closeTooltipStub.reset(); + }); + + test('should update popover location', () => { + const component = mount(); + + // ensure x and y set from original tooltipState.location + expect(component.state('x')).toBe(12000); + expect(component.state('y')).toBe(3000); + + mapCenter = [25, -15]; + mockMbMapHandlers.move(); + component.update(); + + // ensure x and y updated from new map center with same tooltipState.location + expect(component.state('x')).toBe(14500); + expect(component.state('y')).toBe(4500); + + sinon.assert.notCalled(closeTooltipStub); + }); + }); +}); diff --git a/x-pack/legacy/plugins/maps/public/reducers/map.js b/x-pack/legacy/plugins/maps/public/reducers/map.js index 234584d08a311..7e81fb03dd85b 100644 --- a/x-pack/legacy/plugins/maps/public/reducers/map.js +++ b/x-pack/legacy/plugins/maps/public/reducers/map.js @@ -37,7 +37,7 @@ import { ROLLBACK_TO_TRACKED_LAYER_STATE, REMOVE_TRACKED_LAYER_STATE, UPDATE_SOURCE_DATA_REQUEST, - SET_TOOLTIP_STATE, + SET_OPEN_TOOLTIPS, SET_SCROLL_ZOOM, SET_MAP_INIT_ERROR, UPDATE_DRAW_STATE, @@ -97,7 +97,7 @@ const INITIAL_STATE = { ready: false, mapInitError: null, goto: null, - tooltipState: null, + openTooltips: [], mapState: { zoom: null, // setting this value does not adjust map zoom, read only value used to store current map zoom for persisting between sessions center: null, // setting this value does not adjust map view, read only value used to store current map center for persisting between sessions @@ -138,10 +138,10 @@ export function map(state = INITIAL_STATE, action) { return trackCurrentLayerState(state, action.layerId); case ROLLBACK_TO_TRACKED_LAYER_STATE: return rollbackTrackedLayerState(state, action.layerId); - case SET_TOOLTIP_STATE: + case SET_OPEN_TOOLTIPS: return { ...state, - tooltipState: action.tooltipState, + openTooltips: action.openTooltips, }; case SET_MOUSE_COORDINATES: return { diff --git a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js index 4b3d1355e4264..d1048a759beca 100644 --- a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js +++ b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js @@ -42,8 +42,14 @@ function createSourceInstance(sourceDescriptor, inspectorAdapters) { return new Source(sourceDescriptor, inspectorAdapters); } -export const getTooltipState = ({ map }) => { - return map.tooltipState; +export const getOpenTooltips = ({ map }) => { + return map && map.openTooltips ? map.openTooltips : []; +}; + +export const getHasLockedTooltips = state => { + return getOpenTooltips(state).some(({ isLocked }) => { + return isLocked; + }); }; export const getMapReady = ({ map }) => map && map.ready; diff --git a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/jobs.js b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/jobs.js index cc9593d946bd1..1ac391c7f84ae 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/jobs.js +++ b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/jobs.js @@ -21,7 +21,7 @@ export const jobs = { jobsWithTimerange(dateFormatTz) { return http({ - url: `${basePath()}/jobs/jobs_with_timerange`, + url: `${basePath()}/jobs/jobs_with_time_range`, method: 'POST', data: { dateFormatTz, diff --git a/x-pack/legacy/plugins/ml/server/models/calendar/calendar_manager.ts b/x-pack/legacy/plugins/ml/server/models/calendar/calendar_manager.ts index 2487943b5efc0..61f21c316be23 100644 --- a/x-pack/legacy/plugins/ml/server/models/calendar/calendar_manager.ts +++ b/x-pack/legacy/plugins/ml/server/models/calendar/calendar_manager.ts @@ -6,6 +6,7 @@ import { difference } from 'lodash'; import Boom from 'boom'; +import { IScopedClusterClient } from 'src/core/server'; import { EventManager, CalendarEvent } from './event_manager'; interface BasicCalendar { @@ -23,13 +24,12 @@ export interface FormCalendar extends BasicCalendar { } export class CalendarManager { - private _client: any; + private _client: IScopedClusterClient['callAsCurrentUser']; private _eventManager: any; - constructor(isLegacy: boolean, client: any) { - const actualClient = isLegacy === true ? client : client.ml!.mlClient.callAsCurrentUser; - this._client = actualClient; - this._eventManager = new EventManager(actualClient); + constructor(client: any) { + this._client = client; + this._eventManager = new EventManager(client); } async getCalendar(calendarId: string) { diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/groups.js b/x-pack/legacy/plugins/ml/server/models/job_service/groups.js index 58237b2a8a730..91f82f04a9a0c 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/groups.js +++ b/x-pack/legacy/plugins/ml/server/models/job_service/groups.js @@ -7,7 +7,7 @@ import { CalendarManager } from '../calendar'; export function groupsProvider(callWithRequest) { - const calMngr = new CalendarManager(true, callWithRequest); + const calMngr = new CalendarManager(callWithRequest); async function getAllGroups() { const groups = {}; diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/index.js b/x-pack/legacy/plugins/ml/server/models/job_service/index.js index 5c0eff3112a53..6f409e70e68b8 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/index.js +++ b/x-pack/legacy/plugins/ml/server/models/job_service/index.js @@ -14,14 +14,14 @@ import { topCategoriesProvider, } from './new_job'; -export function jobServiceProvider(callWithRequest, request) { +export function jobServiceProvider(callAsCurrentUser) { return { - ...datafeedsProvider(callWithRequest), - ...jobsProvider(callWithRequest), - ...groupsProvider(callWithRequest), - ...newJobCapsProvider(callWithRequest, request), - ...newJobChartsProvider(callWithRequest, request), - ...categorizationExamplesProvider(callWithRequest, request), - ...topCategoriesProvider(callWithRequest, request), + ...datafeedsProvider(callAsCurrentUser), + ...jobsProvider(callAsCurrentUser), + ...groupsProvider(callAsCurrentUser), + ...newJobCapsProvider(callAsCurrentUser), + ...newJobChartsProvider(callAsCurrentUser), + ...categorizationExamplesProvider(callAsCurrentUser), + ...topCategoriesProvider(callAsCurrentUser), }; } diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/jobs.js b/x-pack/legacy/plugins/ml/server/models/job_service/jobs.js index e60593c9f0ed5..b4b476c1f926e 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/jobs.js +++ b/x-pack/legacy/plugins/ml/server/models/job_service/jobs.js @@ -22,7 +22,7 @@ export function jobsProvider(callWithRequest) { const { forceDeleteDatafeed, getDatafeedIdsByJobId } = datafeedsProvider(callWithRequest); const { getAuditMessagesSummary } = jobAuditMessagesProvider(callWithRequest); const { getLatestBucketTimestampByJob } = resultsServiceProvider(callWithRequest); - const calMngr = new CalendarManager(true, callWithRequest); + const calMngr = new CalendarManager(callWithRequest); async function forceDeleteJob(jobId) { return callWithRequest('ml.deleteJob', { jobId, force: true }); diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/field_service.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/field_service.ts index 3cfb552189062..5827201a63661 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/field_service.ts +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/field_service.ts @@ -5,7 +5,7 @@ */ import { cloneDeep } from 'lodash'; -import { Request } from 'src/legacy/server/kbn_server'; +import { SavedObjectsClientContract } from 'kibana/server'; import { Field, Aggregation, @@ -40,22 +40,27 @@ export function fieldServiceProvider( indexPattern: string, isRollup: boolean, callWithRequest: any, - request: Request + savedObjectsClient: SavedObjectsClientContract ) { - return new FieldsService(indexPattern, isRollup, callWithRequest, request); + return new FieldsService(indexPattern, isRollup, callWithRequest, savedObjectsClient); } class FieldsService { private _indexPattern: string; private _isRollup: boolean; private _callWithRequest: any; - private _request: Request; + private _savedObjectsClient: SavedObjectsClientContract; - constructor(indexPattern: string, isRollup: boolean, callWithRequest: any, request: Request) { + constructor( + indexPattern: string, + isRollup: boolean, + callWithRequest: any, + savedObjectsClient: any + ) { this._indexPattern = indexPattern; this._isRollup = isRollup; this._callWithRequest = callWithRequest; - this._request = request; + this._savedObjectsClient = savedObjectsClient; } private async loadFieldCaps(): Promise { @@ -104,7 +109,7 @@ class FieldsService { const rollupService = await rollupServiceProvider( this._indexPattern, this._callWithRequest, - this._request + this._savedObjectsClient ); const rollupConfigs: RollupJob[] | null = await rollupService.getRollupJobs(); diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts index 2c8f8a8f82fb8..f1af7614b4232 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts @@ -18,7 +18,7 @@ import cloudwatchJobCaps from './__mocks__/results/cloudwatch_rollup_job_caps.js describe('job_service - job_caps', () => { let callWithRequestNonRollupMock: jest.Mock; let callWithRequestRollupMock: jest.Mock; - let requestMock: any; + let savedObjectsClientMock: any; beforeEach(() => { callWithRequestNonRollupMock = jest.fn((action: string) => { @@ -37,14 +37,10 @@ describe('job_service - job_caps', () => { } }); - requestMock = { - getSavedObjectsClient: jest.fn(() => { - return { - async find() { - return Promise.resolve(kibanaSavedObjects); - }, - }; - }), + savedObjectsClientMock = { + async find() { + return Promise.resolve(kibanaSavedObjects); + }, }; }); @@ -52,8 +48,8 @@ describe('job_service - job_caps', () => { it('can get job caps for index pattern', async done => { const indexPattern = 'farequote-*'; const isRollup = false; - const { newJobCaps } = newJobCapsProvider(callWithRequestNonRollupMock, requestMock); - const response = await newJobCaps(indexPattern, isRollup); + const { newJobCaps } = newJobCapsProvider(callWithRequestNonRollupMock); + const response = await newJobCaps(indexPattern, isRollup, savedObjectsClientMock); expect(response).toEqual(farequoteJobCaps); done(); }); @@ -61,8 +57,8 @@ describe('job_service - job_caps', () => { it('can get rollup job caps for non rollup index pattern', async done => { const indexPattern = 'farequote-*'; const isRollup = true; - const { newJobCaps } = newJobCapsProvider(callWithRequestNonRollupMock, requestMock); - const response = await newJobCaps(indexPattern, isRollup); + const { newJobCaps } = newJobCapsProvider(callWithRequestNonRollupMock); + const response = await newJobCaps(indexPattern, isRollup, savedObjectsClientMock); expect(response).toEqual(farequoteJobCapsEmpty); done(); }); @@ -72,8 +68,8 @@ describe('job_service - job_caps', () => { it('can get rollup job caps for rollup index pattern', async done => { const indexPattern = 'cloud_roll_index'; const isRollup = true; - const { newJobCaps } = newJobCapsProvider(callWithRequestRollupMock, requestMock); - const response = await newJobCaps(indexPattern, isRollup); + const { newJobCaps } = newJobCapsProvider(callWithRequestRollupMock); + const response = await newJobCaps(indexPattern, isRollup, savedObjectsClientMock); expect(response).toEqual(cloudwatchJobCaps); done(); }); @@ -81,8 +77,8 @@ describe('job_service - job_caps', () => { it('can get non rollup job caps for rollup index pattern', async done => { const indexPattern = 'cloud_roll_index'; const isRollup = false; - const { newJobCaps } = newJobCapsProvider(callWithRequestRollupMock, requestMock); - const response = await newJobCaps(indexPattern, isRollup); + const { newJobCaps } = newJobCapsProvider(callWithRequestRollupMock); + const response = await newJobCaps(indexPattern, isRollup, savedObjectsClientMock); expect(response).not.toEqual(cloudwatchJobCaps); done(); }); diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts index cbb249be09aa0..3a9d979ccb22c 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Request } from 'src/legacy/server/kbn_server'; +import { SavedObjectsClientContract } from 'kibana/server'; import { Aggregation, Field, NewJobCaps } from '../../../../common/types/fields'; import { fieldServiceProvider } from './field_service'; @@ -12,12 +12,18 @@ interface NewJobCapsResponse { [indexPattern: string]: NewJobCaps; } -export function newJobCapsProvider(callWithRequest: any, request: Request) { +export function newJobCapsProvider(callWithRequest: any) { async function newJobCaps( indexPattern: string, - isRollup: boolean = false + isRollup: boolean = false, + savedObjectsClient: SavedObjectsClientContract ): Promise { - const fieldService = fieldServiceProvider(indexPattern, isRollup, callWithRequest, request); + const fieldService = fieldServiceProvider( + indexPattern, + isRollup, + callWithRequest, + savedObjectsClient + ); const { aggs, fields } = await fieldService.getData(); convertForStringify(aggs, fields); diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/rollup.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/rollup.ts index 5f8d8ae5c1f25..11b0802192e1f 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/rollup.ts +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/rollup.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Request } from 'src/legacy/server/kbn_server'; import { SavedObject } from 'src/core/server'; +import { SavedObjectsClientContract } from 'kibana/server'; import { FieldId } from '../../../../common/types/fields'; import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; @@ -21,9 +21,9 @@ export interface RollupJob { export async function rollupServiceProvider( indexPattern: string, callWithRequest: any, - request: Request + savedObjectsClient: SavedObjectsClientContract ) { - const rollupIndexPatternObject = await loadRollupIndexPattern(indexPattern, request); + const rollupIndexPatternObject = await loadRollupIndexPattern(indexPattern, savedObjectsClient); let jobIndexPatterns: string[] = [indexPattern]; async function getRollupJobs(): Promise { @@ -57,9 +57,8 @@ export async function rollupServiceProvider( async function loadRollupIndexPattern( indexPattern: string, - request: Request + savedObjectsClient: SavedObjectsClientContract ): Promise { - const savedObjectsClient = request.getSavedObjectsClient(); const resp = await savedObjectsClient.find({ type: 'index-pattern', fields: ['title', 'type', 'typeMeta'], diff --git a/x-pack/legacy/plugins/ml/server/new_platform/anomaly_detectors_schema.ts b/x-pack/legacy/plugins/ml/server/new_platform/anomaly_detectors_schema.ts index 392d3bfd84768..d728fbf312d76 100644 --- a/x-pack/legacy/plugins/ml/server/new_platform/anomaly_detectors_schema.ts +++ b/x-pack/legacy/plugins/ml/server/new_platform/anomaly_detectors_schema.ts @@ -6,6 +6,18 @@ import { schema } from '@kbn/config-schema'; +const customRulesSchema = schema.maybe( + schema.arrayOf( + schema.maybe( + schema.object({ + actions: schema.arrayOf(schema.string()), + conditions: schema.arrayOf(schema.any()), + scope: schema.maybe(schema.any()), + }) + ) + ) +); + const detectorSchema = schema.object({ identifier: schema.maybe(schema.string()), function: schema.string(), @@ -14,6 +26,7 @@ const detectorSchema = schema.object({ over_field_name: schema.maybe(schema.string()), partition_field_name: schema.maybe(schema.string()), detector_description: schema.maybe(schema.string()), + custom_rules: customRulesSchema, }); const customUrlSchema = { @@ -34,15 +47,8 @@ export const anomalyDetectionUpdateJobSchema = { schema.maybe( schema.object({ detector_index: schema.number(), - custom_rules: schema.arrayOf( - schema.maybe( - schema.object({ - actions: schema.arrayOf(schema.string()), - conditions: schema.arrayOf(schema.any()), - scope: schema.maybe(schema.any()), - }) - ) - ), + description: schema.maybe(schema.string()), + custom_rules: customRulesSchema, }) ) ) diff --git a/x-pack/legacy/plugins/ml/server/new_platform/job_service_schema.ts b/x-pack/legacy/plugins/ml/server/new_platform/job_service_schema.ts new file mode 100644 index 0000000000000..b37fcba737802 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/new_platform/job_service_schema.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +const analyzerSchema = { + tokenizer: schema.string(), + filter: schema.maybe( + schema.arrayOf( + schema.object({ + type: schema.string(), + stopwords: schema.arrayOf(schema.maybe(schema.string())), + }) + ) + ), +}; + +export const categorizationFieldExamplesSchema = { + indexPatternTitle: schema.string(), + query: schema.any(), + size: schema.number(), + field: schema.string(), + timeField: schema.maybe(schema.string()), + start: schema.number(), + end: schema.number(), + analyzer: schema.object(analyzerSchema), +}; + +export const chartSchema = { + indexPatternTitle: schema.string(), + timeField: schema.maybe(schema.string()), + start: schema.maybe(schema.number()), + end: schema.maybe(schema.number()), + intervalMs: schema.number(), + query: schema.any(), + aggFieldNamePairs: schema.arrayOf(schema.any()), + splitFieldName: schema.maybe(schema.nullable(schema.string())), + splitFieldValue: schema.maybe(schema.nullable(schema.string())), +}; + +export const datafeedIdsSchema = { datafeedIds: schema.arrayOf(schema.maybe(schema.string())) }; + +export const forceStartDatafeedSchema = { + datafeedIds: schema.arrayOf(schema.maybe(schema.string())), + start: schema.maybe(schema.number()), + end: schema.maybe(schema.number()), +}; + +export const jobIdsSchema = { + jobIds: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.maybe(schema.string()))]) + ), +}; + +export const jobsWithTimerangeSchema = { dateFormatTz: schema.maybe(schema.string()) }; + +export const lookBackProgressSchema = { + jobId: schema.string(), + start: schema.maybe(schema.number()), + end: schema.maybe(schema.number()), +}; + +export const topCategoriesSchema = { jobId: schema.string(), count: schema.number() }; + +export const updateGroupsSchema = { + jobs: schema.maybe( + schema.arrayOf( + schema.object({ + job_id: schema.maybe(schema.string()), + groups: schema.arrayOf(schema.maybe(schema.string())), + }) + ) + ), +}; diff --git a/x-pack/legacy/plugins/ml/server/routes/apidoc.json b/x-pack/legacy/plugins/ml/server/routes/apidoc.json index 919592f8ed62a..3fac715fef85a 100644 --- a/x-pack/legacy/plugins/ml/server/routes/apidoc.json +++ b/x-pack/legacy/plugins/ml/server/routes/apidoc.json @@ -50,6 +50,25 @@ "Annotations", "GetAnnotations", "IndexAnnotations", - "DeleteAnnotation" + "DeleteAnnotation", + "JobService", + "ForceStartDatafeeds", + "StopDatafeeds", + "DeleteJobs", + "CloseJobs", + "JobsSummary", + "JobsWithTimerange", + "CreateFullJobsList", + "GetAllGroups", + "UpdateGroups", + "DeletingJobTasks", + "JobsExist", + "NewJobCaps", + "NewJobLineChart", + "NewJobPopulationChart", + "GetAllJobAndGroupIds", + "GetLookBackProgress", + "ValidateCategoryExamples", + "TopCategories" ] } diff --git a/x-pack/legacy/plugins/ml/server/routes/calendars.ts b/x-pack/legacy/plugins/ml/server/routes/calendars.ts index 19d614a4e6a22..8e4e1c4c14751 100644 --- a/x-pack/legacy/plugins/ml/server/routes/calendars.ts +++ b/x-pack/legacy/plugins/ml/server/routes/calendars.ts @@ -13,32 +13,32 @@ import { calendarSchema } from '../new_platform/calendars_schema'; import { CalendarManager, Calendar, FormCalendar } from '../models/calendar'; function getAllCalendars(context: RequestHandlerContext) { - const cal = new CalendarManager(false, context); + const cal = new CalendarManager(context.ml!.mlClient.callAsCurrentUser); return cal.getAllCalendars(); } function getCalendar(context: RequestHandlerContext, calendarId: string) { - const cal = new CalendarManager(false, context); + const cal = new CalendarManager(context.ml!.mlClient.callAsCurrentUser); return cal.getCalendar(calendarId); } function newCalendar(context: RequestHandlerContext, calendar: FormCalendar) { - const cal = new CalendarManager(false, context); + const cal = new CalendarManager(context.ml!.mlClient.callAsCurrentUser); return cal.newCalendar(calendar); } function updateCalendar(context: RequestHandlerContext, calendarId: string, calendar: Calendar) { - const cal = new CalendarManager(false, context); + const cal = new CalendarManager(context.ml!.mlClient.callAsCurrentUser); return cal.updateCalendar(calendarId, calendar); } function deleteCalendar(context: RequestHandlerContext, calendarId: string) { - const cal = new CalendarManager(false, context); + const cal = new CalendarManager(context.ml!.mlClient.callAsCurrentUser); return cal.deleteCalendar(calendarId); } function getCalendarsByIds(context: RequestHandlerContext, calendarIds: string) { - const cal = new CalendarManager(false, context); + const cal = new CalendarManager(context.ml!.mlClient.callAsCurrentUser); return cal.getCalendarsByIds(calendarIds); } diff --git a/x-pack/legacy/plugins/ml/server/routes/job_service.js b/x-pack/legacy/plugins/ml/server/routes/job_service.js deleted file mode 100644 index a83b4fa403f65..0000000000000 --- a/x-pack/legacy/plugins/ml/server/routes/job_service.js +++ /dev/null @@ -1,319 +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 { callWithRequestFactory } from '../client/call_with_request_factory'; -import { wrapError } from '../client/errors'; -import { jobServiceProvider } from '../models/job_service'; - -export function jobServiceRoutes({ commonRouteConfig, elasticsearchPlugin, route }) { - route({ - method: 'POST', - path: '/api/ml/jobs/force_start_datafeeds', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { forceStartDatafeeds } = jobServiceProvider(callWithRequest); - const { datafeedIds, start, end } = request.payload; - return forceStartDatafeeds(datafeedIds, start, end).catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'POST', - path: '/api/ml/jobs/stop_datafeeds', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { stopDatafeeds } = jobServiceProvider(callWithRequest); - const { datafeedIds } = request.payload; - return stopDatafeeds(datafeedIds).catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'POST', - path: '/api/ml/jobs/delete_jobs', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { deleteJobs } = jobServiceProvider(callWithRequest); - const { jobIds } = request.payload; - return deleteJobs(jobIds).catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'POST', - path: '/api/ml/jobs/close_jobs', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { closeJobs } = jobServiceProvider(callWithRequest); - const { jobIds } = request.payload; - return closeJobs(jobIds).catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'POST', - path: '/api/ml/jobs/jobs_summary', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { jobsSummary } = jobServiceProvider(callWithRequest); - const { jobIds } = request.payload; - return jobsSummary(jobIds).catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'POST', - path: '/api/ml/jobs/jobs_with_timerange', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { jobsWithTimerange } = jobServiceProvider(callWithRequest); - const { dateFormatTz } = request.payload; - return jobsWithTimerange(dateFormatTz).catch(resp => { - wrapError(resp); - }); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'POST', - path: '/api/ml/jobs/jobs', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { createFullJobsList } = jobServiceProvider(callWithRequest); - const { jobIds } = request.payload; - return createFullJobsList(jobIds).catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'GET', - path: '/api/ml/jobs/groups', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { getAllGroups } = jobServiceProvider(callWithRequest); - return getAllGroups().catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'POST', - path: '/api/ml/jobs/update_groups', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { updateGroups } = jobServiceProvider(callWithRequest); - const { jobs } = request.payload; - return updateGroups(jobs).catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'GET', - path: '/api/ml/jobs/deleting_jobs_tasks', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { deletingJobTasks } = jobServiceProvider(callWithRequest); - return deletingJobTasks().catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'POST', - path: '/api/ml/jobs/jobs_exist', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { jobsExist } = jobServiceProvider(callWithRequest); - const { jobIds } = request.payload; - return jobsExist(jobIds).catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'GET', - path: '/api/ml/jobs/new_job_caps/{indexPattern}', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { indexPattern } = request.params; - const isRollup = request.query.rollup === 'true'; - const { newJobCaps } = jobServiceProvider(callWithRequest, request); - return newJobCaps(indexPattern, isRollup).catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'POST', - path: '/api/ml/jobs/new_job_line_chart', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { - indexPatternTitle, - timeField, - start, - end, - intervalMs, - query, - aggFieldNamePairs, - splitFieldName, - splitFieldValue, - } = request.payload; - const { newJobLineChart } = jobServiceProvider(callWithRequest, request); - return newJobLineChart( - indexPatternTitle, - timeField, - start, - end, - intervalMs, - query, - aggFieldNamePairs, - splitFieldName, - splitFieldValue - ).catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'POST', - path: '/api/ml/jobs/new_job_population_chart', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { - indexPatternTitle, - timeField, - start, - end, - intervalMs, - query, - aggFieldNamePairs, - splitFieldName, - } = request.payload; - const { newJobPopulationChart } = jobServiceProvider(callWithRequest, request); - return newJobPopulationChart( - indexPatternTitle, - timeField, - start, - end, - intervalMs, - query, - aggFieldNamePairs, - splitFieldName - ).catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'GET', - path: '/api/ml/jobs/all_jobs_and_group_ids', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { getAllJobAndGroupIds } = jobServiceProvider(callWithRequest); - return getAllJobAndGroupIds().catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'POST', - path: '/api/ml/jobs/look_back_progress', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { getLookBackProgress } = jobServiceProvider(callWithRequest); - const { jobId, start, end } = request.payload; - return getLookBackProgress(jobId, start, end).catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'POST', - path: '/api/ml/jobs/categorization_field_examples', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { validateCategoryExamples } = jobServiceProvider(callWithRequest); - const { - indexPatternTitle, - timeField, - query, - size, - field, - start, - end, - analyzer, - } = request.payload; - return validateCategoryExamples( - indexPatternTitle, - query, - size, - field, - timeField, - start, - end, - analyzer - ).catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); - - route({ - method: 'POST', - path: '/api/ml/jobs/top_categories', - handler(request) { - const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); - const { topCategories } = jobServiceProvider(callWithRequest); - const { jobId, count } = request.payload; - return topCategories(jobId, count).catch(resp => wrapError(resp)); - }, - config: { - ...commonRouteConfig, - }, - }); -} diff --git a/x-pack/legacy/plugins/ml/server/routes/job_service.ts b/x-pack/legacy/plugins/ml/server/routes/job_service.ts new file mode 100644 index 0000000000000..3af651c92353b --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/routes/job_service.ts @@ -0,0 +1,610 @@ +/* + * 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 { licensePreRoutingFactory } from '../new_platform/licence_check_pre_routing_factory'; +import { wrapError } from '../client/error_wrapper'; +import { RouteInitialization } from '../new_platform/plugin'; +import { + categorizationFieldExamplesSchema, + chartSchema, + datafeedIdsSchema, + forceStartDatafeedSchema, + jobIdsSchema, + jobsWithTimerangeSchema, + lookBackProgressSchema, + topCategoriesSchema, + updateGroupsSchema, +} from '../new_platform/job_service_schema'; +// @ts-ignore no declaration module +import { jobServiceProvider } from '../models/job_service'; + +/** + * Routes for job service + */ +export function jobServiceRoutes({ xpackMainPlugin, router }: RouteInitialization) { + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/force_start_datafeeds + * @apiName ForceStartDatafeeds + * @apiDescription Starts one or more datafeeds + */ + router.post( + { + path: '/api/ml/jobs/force_start_datafeeds', + validate: { + body: schema.object(forceStartDatafeedSchema), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { forceStartDatafeeds } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { datafeedIds, start, end } = request.body; + const resp = await forceStartDatafeeds(datafeedIds, start, end); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/stop_datafeeds + * @apiName StopDatafeeds + * @apiDescription Stops one or more datafeeds + */ + router.post( + { + path: '/api/ml/jobs/stop_datafeeds', + validate: { + body: schema.object(datafeedIdsSchema), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { stopDatafeeds } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { datafeedIds } = request.body; + const resp = await stopDatafeeds(datafeedIds); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/delete_jobs + * @apiName DeleteJobs + * @apiDescription Deletes an existing anomaly detection job + */ + router.post( + { + path: '/api/ml/jobs/delete_jobs', + validate: { + body: schema.object(jobIdsSchema), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { deleteJobs } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { jobIds } = request.body; + const resp = await deleteJobs(jobIds); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/close_jobs + * @apiName CloseJobs + * @apiDescription Closes one or more anomaly detection jobs + */ + router.post( + { + path: '/api/ml/jobs/close_jobs', + validate: { + body: schema.object(jobIdsSchema), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { closeJobs } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { jobIds } = request.body; + const resp = await closeJobs(jobIds); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/jobs_summary + * @apiName JobsSummary + * @apiDescription Creates a summary jobs list. Jobs include job stats, datafeed stats, and calendars. + */ + router.post( + { + path: '/api/ml/jobs/jobs_summary', + validate: { + body: schema.object(jobIdsSchema), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { jobsSummary } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { jobIds } = request.body; + const resp = await jobsSummary(jobIds); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/jobs_with_time_range + * @apiName JobsWithTimerange + * @apiDescription Creates a list of jobs with data about the job's timerange + */ + router.post( + { + path: '/api/ml/jobs/jobs_with_time_range', + validate: { + body: schema.object(jobsWithTimerangeSchema), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { jobsWithTimerange } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { dateFormatTz } = request.body; + const resp = await jobsWithTimerange(dateFormatTz); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/jobs + * @apiName CreateFullJobsList + * @apiDescription Creates a list of jobs + */ + router.post( + { + path: '/api/ml/jobs/jobs', + validate: { + body: schema.object(jobIdsSchema), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { createFullJobsList } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { jobIds } = request.body; + const resp = await createFullJobsList(jobIds); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {get} /api/ml/jobs/groups + * @apiName GetAllGroups + * @apiDescription Returns array of group objects with job ids listed for each group + */ + router.get( + { + path: '/api/ml/jobs/groups', + validate: false, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { getAllGroups } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const resp = await getAllGroups(); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/update_groups + * @apiName UpdateGroups + * @apiDescription Updates 'groups' property of an anomaly detection job + */ + router.post( + { + path: '/api/ml/jobs/update_groups', + validate: { + body: schema.object(updateGroupsSchema), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { updateGroups } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { jobs } = request.body; + const resp = await updateGroups(jobs); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {get} /api/ml/jobs/deleting_jobs_tasks + * @apiName DeletingJobTasks + * @apiDescription Gets the ids of deleting anomaly detection jobs + */ + router.get( + { + path: '/api/ml/jobs/deleting_jobs_tasks', + validate: false, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { deletingJobTasks } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const resp = await deletingJobTasks(); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/jobs_exist + * @apiName JobsExist + * @apiDescription Checks if each of the jobs in the specified list of IDs exist + */ + router.post( + { + path: '/api/ml/jobs/jobs_exist', + validate: { + body: schema.object(jobIdsSchema), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { jobsExist } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { jobIds } = request.body; + const resp = await jobsExist(jobIds); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {get} /api/ml/jobs/new_job_caps/:indexPattern + * @apiName NewJobCaps + * @apiDescription Retrieve the capabilities of fields for indices + */ + router.get( + { + path: '/api/ml/jobs/new_job_caps/{indexPattern}', + validate: { + params: schema.object({ indexPattern: schema.string() }), + query: schema.maybe(schema.object({ rollup: schema.maybe(schema.string()) })), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { indexPattern } = request.params; + const isRollup = request.query.rollup === 'true'; + const savedObjectsClient = context.core.savedObjects.client; + const { newJobCaps } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const resp = await newJobCaps(indexPattern, isRollup, savedObjectsClient); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/new_job_line_chart + * @apiName NewJobLineChart + * @apiDescription Returns line chart data for anomaly detection job + */ + router.post( + { + path: '/api/ml/jobs/new_job_line_chart', + validate: { + body: schema.object(chartSchema), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + splitFieldValue, + } = request.body; + + const { newJobLineChart } = jobServiceProvider( + context.ml!.mlClient.callAsCurrentUser, + request + ); + const resp = await newJobLineChart( + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + splitFieldValue + ); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/new_job_population_chart + * @apiName NewJobPopulationChart + * @apiDescription Returns population job chart data + */ + router.post( + { + path: '/api/ml/jobs/new_job_population_chart', + validate: { + body: schema.object(chartSchema), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + } = request.body; + + const { newJobPopulationChart } = jobServiceProvider( + context.ml!.mlClient.callAsCurrentUser + ); + const resp = await newJobPopulationChart( + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName + ); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {get} /api/ml/jobs/all_jobs_and_group_ids + * @apiName GetAllJobAndGroupIds + * @apiDescription Returns a list of all job IDs and all group IDs + */ + router.get( + { + path: '/api/ml/jobs/all_jobs_and_group_ids', + validate: false, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { getAllJobAndGroupIds } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const resp = await getAllJobAndGroupIds(); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/look_back_progress + * @apiName GetLookBackProgress + * @apiDescription Returns current progress of anomaly detection job + */ + router.post( + { + path: '/api/ml/jobs/look_back_progress', + validate: { + body: schema.object(lookBackProgressSchema), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { getLookBackProgress } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { jobId, start, end } = request.body; + const resp = await getLookBackProgress(jobId, start, end); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/categorization_field_examples + * @apiName ValidateCategoryExamples + * @apiDescription Validates category examples + */ + router.post( + { + path: '/api/ml/jobs/categorization_field_examples', + validate: { + body: schema.object(categorizationFieldExamplesSchema), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { validateCategoryExamples } = jobServiceProvider( + context.ml!.mlClient.callAsCurrentUser + ); + const { + indexPatternTitle, + timeField, + query, + size, + field, + start, + end, + analyzer, + } = request.body; + + const resp = await validateCategoryExamples( + indexPatternTitle, + query, + size, + field, + timeField, + start, + end, + analyzer + ); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/top_categories + * @apiName TopCategories + * @apiDescription Returns list of top categories + */ + router.post( + { + path: '/api/ml/jobs/top_categories', + validate: { + body: schema.object(topCategoriesSchema), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const { topCategories } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { jobId, count } = request.body; + const resp = await topCategories(jobId, count); + + return response.ok({ + body: resp, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); +} diff --git a/x-pack/legacy/plugins/security/index.js b/x-pack/legacy/plugins/security/index.js index fd89c40f010b7..18b815fb429cb 100644 --- a/x-pack/legacy/plugins/security/index.js +++ b/x-pack/legacy/plugins/security/index.js @@ -115,9 +115,6 @@ export const security = kibana => const xpackInfo = server.plugins.xpack_main.info; securityPlugin.__legacyCompat.registerLegacyAPI({ auditLogger: new AuditLogger(server, 'security', config, xpackInfo), - isSystemAPIRequest: server.plugins.kibana.systemApi.isSystemApiRequest.bind( - server.plugins.kibana.systemApi - ), }); // Legacy xPack Info endpoint returns whatever we return in a callback for `registerLicenseCheckResultsGenerator` diff --git a/x-pack/legacy/plugins/siem/index.ts b/x-pack/legacy/plugins/siem/index.ts index 0a3e447ac64a1..c786dad61c09d 100644 --- a/x-pack/legacy/plugins/siem/index.ts +++ b/x-pack/legacy/plugins/siem/index.ts @@ -5,12 +5,10 @@ */ import { i18n } from '@kbn/i18n'; -import { get } from 'lodash/fp'; import { resolve } from 'path'; import { Server } from 'hapi'; import { Root } from 'joi'; -import { PluginInitializerContext } from '../../../../src/core/server'; import { plugin } from './server'; import { savedObjectMappings } from './server/saved_objects'; @@ -32,7 +30,6 @@ import { SIGNALS_INDEX_KEY, } from './common/constants'; import { defaultIndexPattern } from './default_index_pattern'; -import { initServerWithKibana } from './server/kibana.index'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -151,27 +148,20 @@ export const siem = (kibana: any) => { mappings: savedObjectMappings, }, init(server: Server) { - const { config, newPlatform, plugins, route } = server; - const { coreContext, env, setup } = newPlatform; - const initializerContext = { ...coreContext, env } as PluginInitializerContext; - const serverFacade = { - config, - usingEphemeralEncryptionKey: - get('usingEphemeralEncryptionKey', newPlatform.setup.plugins.encryptedSavedObjects) ?? - false, - plugins: { - alerting: plugins.alerting, - actions: newPlatform.start.plugins.actions, - elasticsearch: plugins.elasticsearch, - spaces: plugins.spaces, - savedObjects: server.savedObjects.SavedObjectsClient, - }, - route: route.bind(server), + const { coreContext, env, setup, start } = server.newPlatform; + const initializerContext = { ...coreContext, env }; + const __legacy = { + config: server.config, + alerting: server.plugins.alerting, + route: server.route.bind(server), }; - // @ts-ignore-next-line: setup.plugins is too loosely typed - plugin(initializerContext).setup(setup.core, setup.plugins); - initServerWithKibana(initializerContext, serverFacade); + // @ts-ignore-next-line: NewPlatform shim is too loosely typed + const pluginInstance = plugin(initializerContext); + // @ts-ignore-next-line: NewPlatform shim is too loosely typed + pluginInstance.setup(setup.core, setup.plugins, __legacy); + // @ts-ignore-next-line: NewPlatform shim is too loosely typed + pluginInstance.start(start.core, start.plugins); }, config(Joi: Root) { // See x-pack/plugins/siem/server/config.ts if you're adding another diff --git a/x-pack/legacy/plugins/siem/public/components/header_page_new/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/header_page_new/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..1b792503cf1c6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/header_page_new/__snapshots__/index.test.tsx.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HeaderPage it renders 1`] = ` +
+ + + +

+ Test title + + +

+
+ + +
+ +

+ Test supplement +

+
+
+
+`; diff --git a/x-pack/legacy/plugins/siem/public/components/header_page_new/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/header_page_new/index.test.tsx new file mode 100644 index 0000000000000..83a70fd90d82b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/header_page_new/index.test.tsx @@ -0,0 +1,224 @@ +/* + * 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 euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import { shallow } from 'enzyme'; +import React from 'react'; + +import { TestProviders } from '../../mock'; +import { HeaderPage } from './index'; +import { useMountAppended } from '../../utils/use_mount_appended'; + +describe('HeaderPage', () => { + const mount = useMountAppended(); + + test('it renders', () => { + const wrapper = shallow( + +

{'Test supplement'}

+
+ ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('it renders the title', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="header-page-title"]') + .first() + .exists() + ).toBe(true); + }); + + test('it renders the back link when provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('.siemHeaderPage__linkBack') + .first() + .exists() + ).toBe(true); + }); + + test('it DOES NOT render the back link when not provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('.siemHeaderPage__linkBack') + .first() + .exists() + ).toBe(false); + }); + + test('it renders the first subtitle when provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="header-page-subtitle"]') + .first() + .exists() + ).toBe(true); + }); + + test('it DOES NOT render the first subtitle when not provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="header-section-subtitle"]') + .first() + .exists() + ).toBe(false); + }); + + test('it renders the second subtitle when provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="header-page-subtitle-2"]') + .first() + .exists() + ).toBe(true); + }); + + test('it DOES NOT render the second subtitle when not provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="header-section-subtitle-2"]') + .first() + .exists() + ).toBe(false); + }); + + test('it renders supplements when children provided', () => { + const wrapper = mount( + + +

{'Test supplement'}

+
+
+ ); + + expect( + wrapper + .find('[data-test-subj="header-page-supplements"]') + .first() + .exists() + ).toBe(true); + }); + + test('it DOES NOT render supplements when children not provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="header-page-supplements"]') + .first() + .exists() + ).toBe(false); + }); + + test('it applies border styles when border is true', () => { + const wrapper = mount( + + + + ); + const siemHeaderPage = wrapper.find('.siemHeaderPage').first(); + + expect(siemHeaderPage).toHaveStyleRule('border-bottom', euiDarkVars.euiBorderThin); + expect(siemHeaderPage).toHaveStyleRule('padding-bottom', euiDarkVars.paddingSizes.l); + }); + + test('it DOES NOT apply border styles when border is false', () => { + const wrapper = mount( + + + + ); + const siemHeaderPage = wrapper.find('.siemHeaderPage').first(); + + expect(siemHeaderPage).not.toHaveStyleRule('border-bottom', euiDarkVars.euiBorderThin); + expect(siemHeaderPage).not.toHaveStyleRule('padding-bottom', euiDarkVars.paddingSizes.l); + }); + + test('it renders as a draggable when arguments provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="header-page-draggable"]') + .first() + .exists() + ).toBe(true); + }); + + test('it DOES NOT render as a draggable when arguments not provided', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="header-page-draggable"]') + .first() + .exists() + ).toBe(false); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/header_page_new/index.tsx b/x-pack/legacy/plugins/siem/public/components/header_page_new/index.tsx new file mode 100644 index 0000000000000..7e486c78fb9b9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/header_page_new/index.tsx @@ -0,0 +1,220 @@ +/* + * 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 { + EuiBadge, + EuiBetaBadge, + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiProgress, + EuiTitle, +} from '@elastic/eui'; +import React from 'react'; +import styled, { css } from 'styled-components'; + +import { DefaultDraggable } from '../draggables'; +import { LinkIcon, LinkIconProps } from '../link_icon'; +import { Subtitle, SubtitleProps } from '../subtitle'; +import * as i18n from './translations'; + +interface HeaderProps { + border?: boolean; + isLoading?: boolean; +} + +const Header = styled.header.attrs({ + className: 'siemHeaderPage', +})` + ${({ border, theme }) => css` + margin-bottom: ${theme.eui.euiSizeL}; + + ${border && + css` + border-bottom: ${theme.eui.euiBorderThin}; + padding-bottom: ${theme.eui.paddingSizes.l}; + .euiProgress { + top: ${theme.eui.paddingSizes.l}; + } + `} + `} +`; +Header.displayName = 'Header'; + +const FlexItem = styled(EuiFlexItem)` + display: block; +`; +FlexItem.displayName = 'FlexItem'; + +const LinkBack = styled.div.attrs({ + className: 'siemHeaderPage__linkBack', +})` + ${({ theme }) => css` + font-size: ${theme.eui.euiFontSizeXS}; + line-height: ${theme.eui.euiLineHeight}; + margin-bottom: ${theme.eui.euiSizeS}; + `} +`; +LinkBack.displayName = 'LinkBack'; + +const Badge = styled(EuiBadge)` + letter-spacing: 0; +`; +Badge.displayName = 'Badge'; + +const StyledEuiBetaBadge = styled(EuiBetaBadge)` + vertical-align: middle; +`; + +StyledEuiBetaBadge.displayName = 'StyledEuiBetaBadge'; + +const StyledEuiButtonIcon = styled(EuiButtonIcon)` + ${({ theme }) => css` + margin-left: ${theme.eui.euiSize}; + `} +`; + +StyledEuiButtonIcon.displayName = 'StyledEuiButtonIcon'; + +interface BackOptions { + href: LinkIconProps['href']; + text: LinkIconProps['children']; +} + +interface BadgeOptions { + beta?: boolean; + text: string; + tooltip?: string; +} + +interface DraggableArguments { + field: string; + value: string; +} +interface IconAction { + 'aria-label': string; + iconType: string; + onChange: (a: string) => void; + onClick: (b: boolean) => void; + onSubmit: () => void; +} + +export interface HeaderPageProps extends HeaderProps { + backOptions?: BackOptions; + badgeOptions?: BadgeOptions; + children?: React.ReactNode; + draggableArguments?: DraggableArguments; + isEditTitle?: boolean; + iconAction?: IconAction; + subtitle2?: SubtitleProps['items']; + subtitle?: SubtitleProps['items']; + title: string | React.ReactNode; +} + +const HeaderPageComponent: React.FC = ({ + backOptions, + badgeOptions, + border, + children, + draggableArguments, + isEditTitle, + iconAction, + isLoading, + subtitle, + subtitle2, + title, + ...rest +}) => ( +
+ + + {backOptions && ( + + + {backOptions.text} + + + )} + + {isEditTitle && iconAction ? ( + + + iconAction.onChange(e.target.value)} + value={`${title}`} + /> + + + + + {i18n.SUBMIT} + + + + iconAction.onClick(false)}> + {i18n.CANCEL} + + + + + + ) : ( + +

+ {!draggableArguments ? ( + title + ) : ( + + )} + {badgeOptions && ( + <> + {' '} + {badgeOptions.beta ? ( + + ) : ( + {badgeOptions.text} + )} + + )} + {iconAction && ( + iconAction.onClick(true)} + /> + )} +

+
+ )} + + {subtitle && } + {subtitle2 && } + {border && isLoading && } +
+ + {children && {children}} +
+
+); + +export const HeaderPage = React.memo(HeaderPageComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/header_page_new/translations.ts b/x-pack/legacy/plugins/siem/public/components/header_page_new/translations.ts new file mode 100644 index 0000000000000..57b2cda0b0b01 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/header_page_new/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const SUBMIT = i18n.translate('xpack.siem.case.casePage.title.submit', { + defaultMessage: 'Submit', +}); + +export const CANCEL = i18n.translate('xpack.siem.case.casePage.title.cancel', { + defaultMessage: 'Cancel', +}); diff --git a/x-pack/legacy/plugins/siem/public/components/link_to/index.ts b/x-pack/legacy/plugins/siem/public/components/link_to/index.ts index ad6147e5aad76..c93b415e017bb 100644 --- a/x-pack/legacy/plugins/siem/public/components/link_to/index.ts +++ b/x-pack/legacy/plugins/siem/public/components/link_to/index.ts @@ -13,3 +13,10 @@ export { getOverviewUrl, RedirectToOverviewPage } from './redirect_to_overview'; export { getHostDetailsUrl, getHostsUrl } from './redirect_to_hosts'; export { getNetworkUrl, getIPDetailsUrl, RedirectToNetworkPage } from './redirect_to_network'; export { getTimelinesUrl, RedirectToTimelinesPage } from './redirect_to_timelines'; +export { + getCaseDetailsUrl, + getCaseUrl, + getCreateCaseUrl, + RedirectToCasePage, + RedirectToCreatePage, +} from './redirect_to_case'; diff --git a/x-pack/legacy/plugins/siem/public/components/link_to/link_to.tsx b/x-pack/legacy/plugins/siem/public/components/link_to/link_to.tsx index dc8c696301611..c08b429dc4625 100644 --- a/x-pack/legacy/plugins/siem/public/components/link_to/link_to.tsx +++ b/x-pack/legacy/plugins/siem/public/components/link_to/link_to.tsx @@ -20,6 +20,7 @@ import { RedirectToHostsPage, RedirectToHostDetailsPage } from './redirect_to_ho import { RedirectToNetworkPage } from './redirect_to_network'; import { RedirectToOverviewPage } from './redirect_to_overview'; import { RedirectToTimelinesPage } from './redirect_to_timelines'; +import { RedirectToCasePage, RedirectToCreatePage } from './redirect_to_case'; import { DetectionEngineTab } from '../../pages/detection_engine/types'; interface LinkToPageProps { @@ -32,6 +33,20 @@ export const LinkToPage = React.memo(({ match }) => ( component={RedirectToOverviewPage} path={`${match.url}/:pageName(${SiemPageName.overview})`} /> + + + ; + +export const RedirectToCasePage = ({ + match: { + params: { detailName }, + }, +}: CaseComponentProps) => ( + +); + +export const RedirectToCreatePage = () => ; + +const baseCaseUrl = `#/link-to/${SiemPageName.case}`; + +export const getCaseUrl = () => baseCaseUrl; +export const getCaseDetailsUrl = (detailName: string) => `${baseCaseUrl}/${detailName}`; +export const getCreateCaseUrl = () => `${baseCaseUrl}/create`; diff --git a/x-pack/legacy/plugins/siem/public/components/links/index.tsx b/x-pack/legacy/plugins/siem/public/components/links/index.tsx index e122b3e235a9e..4f74f9ff2f5d6 100644 --- a/x-pack/legacy/plugins/siem/public/components/links/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/links/index.tsx @@ -8,7 +8,12 @@ import { EuiLink } from '@elastic/eui'; import React from 'react'; import { encodeIpv6 } from '../../lib/helpers'; -import { getHostDetailsUrl, getIPDetailsUrl } from '../link_to'; +import { + getCaseDetailsUrl, + getHostDetailsUrl, + getIPDetailsUrl, + getCreateCaseUrl, +} from '../link_to'; import { FlowTarget, FlowTargetSourceDest } from '../../graphql/types'; // Internal Links @@ -35,6 +40,23 @@ const IPDetailsLinkComponent: React.FC<{ export const IPDetailsLink = React.memo(IPDetailsLinkComponent); +const CaseDetailsLinkComponent: React.FC<{ children?: React.ReactNode; detailName: string }> = ({ + children, + detailName, +}) => ( + + {children ? children : detailName} + +); +export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent); +CaseDetailsLink.displayName = 'CaseDetailsLink'; + +export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => ( + {children} +)); + +CreateCaseLink.displayName = 'CreateCaseLink'; + // External Links export const GoogleLink = React.memo<{ children?: React.ReactNode; link: string }>( ({ children, link }) => ( diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts b/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts index e8d5032fd7548..e25fb4374bb14 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts +++ b/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts @@ -11,6 +11,7 @@ import { APP_NAME } from '../../../../common/constants'; import { StartServices } from '../../../plugin'; import { getBreadcrumbs as getHostDetailsBreadcrumbs } from '../../../pages/hosts/details/utils'; import { getBreadcrumbs as getIPDetailsBreadcrumbs } from '../../../pages/network/ip_details'; +import { getBreadcrumbs as getCaseDetailsBreadcrumbs } from '../../../pages/case/utils'; import { getBreadcrumbs as getDetectionRulesBreadcrumbs } from '../../../pages/detection_engine/rules/utils'; import { SiemPageName } from '../../../pages/home/types'; import { RouteSpyState, HostRouteSpyState, NetworkRouteSpyState } from '../../../utils/route/types'; @@ -43,6 +44,9 @@ const isNetworkRoutes = (spyState: RouteSpyState): spyState is NetworkRouteSpySt const isHostsRoutes = (spyState: RouteSpyState): spyState is HostRouteSpyState => spyState != null && spyState.pageName === SiemPageName.hosts; +const isCaseRoutes = (spyState: RouteSpyState): spyState is RouteSpyState => + spyState != null && spyState.pageName === SiemPageName.case; + const isDetectionsRoutes = (spyState: RouteSpyState) => spyState != null && spyState.pageName === SiemPageName.detections; @@ -102,6 +106,9 @@ export const getBreadcrumbsForRoute = ( ), ]; } + if (isCaseRoutes(spyState) && object.navTabs) { + return [...siemRootBreadcrumb, ...getCaseDetailsBreadcrumbs(spyState)]; + } if ( spyState != null && object.navTabs && diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx index ac7a4a0ee52b7..8eb08bd3d62f0 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx @@ -66,6 +66,13 @@ describe('SIEM Navigation', () => { { detailName: undefined, navTabs: { + case: { + disabled: true, + href: '#/link-to/case', + id: 'case', + name: 'Case', + urlKey: 'case', + }, detections: { disabled: false, href: '#/link-to/detections', @@ -152,6 +159,13 @@ describe('SIEM Navigation', () => { detailName: undefined, filters: [], navTabs: { + case: { + disabled: true, + href: '#/link-to/case', + id: 'case', + name: 'Case', + urlKey: 'case', + }, detections: { disabled: false, href: '#/link-to/detections', diff --git a/x-pack/legacy/plugins/siem/public/components/notes/add_note/new_note.tsx b/x-pack/legacy/plugins/siem/public/components/notes/add_note/new_note.tsx index 5a3439d53dd89..15e58f3efd21e 100644 --- a/x-pack/legacy/plugins/siem/public/components/notes/add_note/new_note.tsx +++ b/x-pack/legacy/plugins/siem/public/components/notes/add_note/new_note.tsx @@ -32,8 +32,6 @@ const TextArea = styled(EuiTextArea)<{ height: number }>` TextArea.displayName = 'TextArea'; -TextArea.displayName = 'TextArea'; - /** An input for entering a new note */ export const NewNote = React.memo<{ noteInputHeight: number; diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/constants.ts b/x-pack/legacy/plugins/siem/public/components/url_state/constants.ts index 22e8f99658f8d..b6ef3c8ccd4e9 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/constants.ts +++ b/x-pack/legacy/plugins/siem/public/components/url_state/constants.ts @@ -6,6 +6,8 @@ export enum CONSTANTS { appQuery = 'query', + caseDetails = 'case.details', + casePage = 'case.page', detectionsPage = 'detections.page', filters = 'filters', hostsDetails = 'hosts.details', @@ -14,10 +16,10 @@ export enum CONSTANTS { networkPage = 'network.page', overviewPage = 'overview.page', savedQuery = 'savedQuery', + timeline = 'timeline', timelinePage = 'timeline.page', timerange = 'timerange', - timeline = 'timeline', unknown = 'unknown', } -export type UrlStateType = 'detections' | 'host' | 'network' | 'overview' | 'timeline'; +export type UrlStateType = 'case' | 'detections' | 'host' | 'network' | 'overview' | 'timeline'; diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/helpers.ts b/x-pack/legacy/plugins/siem/public/components/url_state/helpers.ts index 7be775ef0c0e4..05329621aa97a 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/components/url_state/helpers.ts @@ -98,6 +98,8 @@ export const getUrlType = (pageName: string): UrlStateType => { return 'detections'; } else if (pageName === SiemPageName.timelines) { return 'timeline'; + } else if (pageName === SiemPageName.case) { + return 'case'; } return 'overview'; }; @@ -131,6 +133,11 @@ export const getCurrentLocation = ( return CONSTANTS.detectionsPage; } else if (pageName === SiemPageName.timelines) { return CONSTANTS.timelinePage; + } else if (pageName === SiemPageName.case) { + if (detailName != null) { + return CONSTANTS.caseDetails; + } + return CONSTANTS.casePage; } return CONSTANTS.unknown; }; diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts index fea1bc016fd49..97979e514aeaf 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts @@ -60,9 +60,12 @@ export const URL_STATE_KEYS: Record = { CONSTANTS.timeline, ], timeline: [CONSTANTS.timeline, CONSTANTS.timerange], + case: [], }; export type LocationTypes = + | CONSTANTS.caseDetails + | CONSTANTS.casePage | CONSTANTS.detectionsPage | CONSTANTS.hostsDetails | CONSTANTS.hostsPage diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts new file mode 100644 index 0000000000000..830e00c70975e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaServices } from '../../lib/kibana'; +import { AllCases, FetchCasesProps, Case, NewCase, SortFieldCase } from './types'; +import { Direction } from '../../graphql/types'; +import { throwIfNotOk } from '../../hooks/api/api'; +import { CASES_URL } from './constants'; + +export const getCase = async (caseId: string, includeComments: boolean) => { + const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { + method: 'GET', + asResponse: true, + query: { + includeComments, + }, + }); + await throwIfNotOk(response.response); + return response.body!; +}; + +export const getCases = async ({ + filterOptions = { + search: '', + tags: [], + }, + queryParams = { + page: 1, + perPage: 20, + sortField: SortFieldCase.createdAt, + sortOrder: Direction.desc, + }, +}: FetchCasesProps): Promise => { + const tags = [...(filterOptions.tags?.map(t => `case-workflow.attributes.tags: ${t}`) ?? [])]; + const query = { + ...queryParams, + filter: tags.join(' AND '), + search: filterOptions.search, + }; + const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { + method: 'GET', + query, + asResponse: true, + }); + await throwIfNotOk(response.response); + return response.body!; +}; + +export const createCase = async (newCase: NewCase): Promise => { + const response = await KibanaServices.get().http.fetch(`${CASES_URL}`, { + method: 'POST', + asResponse: true, + body: JSON.stringify(newCase), + }); + await throwIfNotOk(response.response); + return response.body!; +}; + +export const updateCaseProperty = async ( + caseId: string, + updatedCase: Partial +): Promise> => { + const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { + method: 'PATCH', + asResponse: true, + body: JSON.stringify(updatedCase), + }); + await throwIfNotOk(response.response); + return response.body!; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/constants.ts b/x-pack/legacy/plugins/siem/public/containers/case/constants.ts new file mode 100644 index 0000000000000..c8d668527ae32 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/constants.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const CASES_URL = `/api/cases`; +export const DEFAULT_TABLE_ACTIVE_PAGE = 1; +export const DEFAULT_TABLE_LIMIT = 5; +export const FETCH_FAILURE = 'FETCH_FAILURE'; +export const FETCH_INIT = 'FETCH_INIT'; +export const FETCH_SUCCESS = 'FETCH_SUCCESS'; +export const POST_NEW_CASE = 'POST_NEW_CASE'; +export const UPDATE_CASE_PROPERTY = 'UPDATE_CASE_PROPERTY'; +export const UPDATE_FILTER_OPTIONS = 'UPDATE_FILTER_OPTIONS'; +export const UPDATE_QUERY_PARAMS = 'UPDATE_QUERY_PARAMS'; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/translations.ts b/x-pack/legacy/plugins/siem/public/containers/case/translations.ts new file mode 100644 index 0000000000000..0c8b896e2b426 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/translations.ts @@ -0,0 +1,18 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const ERROR_TITLE = i18n.translate('xpack.siem.containers.case.errorTitle', { + defaultMessage: 'Error fetching data', +}); + +export const TAG_FETCH_FAILURE = i18n.translate( + 'xpack.siem.containers.case.tagFetchFailDescription', + { + defaultMessage: 'Failed to fetch Tags', + } +); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts new file mode 100644 index 0000000000000..0f80b2327a30c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -0,0 +1,61 @@ +/* + * 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 { Direction } from '../../graphql/types'; +interface FormData { + isNew?: boolean; +} + +export interface NewCase extends FormData { + description: string; + tags: string[]; + title: string; +} + +export interface Case { + case_id: string; + created_at: string; + created_by: ElasticUser; + description: string; + state: string; + tags: string[]; + title: string; + updated_at: string; +} + +export interface QueryParams { + page: number; + perPage: number; + sortField: SortFieldCase; + sortOrder: Direction; +} + +export interface FilterOptions { + search: string; + tags: string[]; +} + +export interface AllCases { + cases: Case[]; + page: number; + per_page: number; + total: number; +} +export enum SortFieldCase { + createdAt = 'created_at', + state = 'state', + updatedAt = 'updated_at', +} + +export interface ElasticUser { + readonly username: string; + readonly full_name?: string; +} + +export interface FetchCasesProps { + queryParams?: QueryParams; + filterOptions?: FilterOptions; +} diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx new file mode 100644 index 0000000000000..8cc961c68fdf0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -0,0 +1,99 @@ +/* + * 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 { useEffect, useReducer } from 'react'; + +import { Case } from './types'; +import { FETCH_INIT, FETCH_FAILURE, FETCH_SUCCESS } from './constants'; +import { getTypedPayload } from './utils'; +import { errorToToaster } from '../../components/ml/api/error_to_toaster'; +import * as i18n from './translations'; +import { useStateToaster } from '../../components/toasters'; +import { getCase } from './api'; + +interface CaseState { + data: Case; + isLoading: boolean; + isError: boolean; +} +interface Action { + type: string; + payload?: Case; +} + +const dataFetchReducer = (state: CaseState, action: Action): CaseState => { + switch (action.type) { + case FETCH_INIT: + return { + ...state, + isLoading: true, + isError: false, + }; + case FETCH_SUCCESS: + return { + ...state, + isLoading: false, + isError: false, + data: getTypedPayload(action.payload), + }; + case FETCH_FAILURE: + return { + ...state, + isLoading: false, + isError: true, + }; + default: + throw new Error(); + } +}; +const initialData: Case = { + case_id: '', + created_at: '', + created_by: { + username: '', + }, + description: '', + state: '', + tags: [], + title: '', + updated_at: '', +}; + +export const useGetCase = (caseId: string): [CaseState] => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: true, + isError: false, + data: initialData, + }); + const [, dispatchToaster] = useStateToaster(); + + const callFetch = () => { + let didCancel = false; + const fetchData = async () => { + dispatch({ type: FETCH_INIT }); + try { + const response = await getCase(caseId, false); + if (!didCancel) { + dispatch({ type: FETCH_SUCCESS, payload: response }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: FETCH_FAILURE }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + }; + }; + + useEffect(() => { + callFetch(); + }, [caseId]); + return [state]; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx new file mode 100644 index 0000000000000..db9c07747ba04 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx @@ -0,0 +1,154 @@ +/* + * 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 { Dispatch, SetStateAction, useEffect, useReducer, useState } from 'react'; +import { isEqual } from 'lodash/fp'; +import { + DEFAULT_TABLE_ACTIVE_PAGE, + DEFAULT_TABLE_LIMIT, + FETCH_FAILURE, + FETCH_INIT, + FETCH_SUCCESS, + UPDATE_QUERY_PARAMS, + UPDATE_FILTER_OPTIONS, +} from './constants'; +import { AllCases, SortFieldCase, FilterOptions, QueryParams } from './types'; +import { getTypedPayload } from './utils'; +import { Direction } from '../../graphql/types'; +import { errorToToaster } from '../../components/ml/api/error_to_toaster'; +import { useStateToaster } from '../../components/toasters'; +import * as i18n from './translations'; +import { getCases } from './api'; + +export interface UseGetCasesState { + data: AllCases; + isLoading: boolean; + isError: boolean; + queryParams: QueryParams; + filterOptions: FilterOptions; +} + +export interface QueryArgs { + page?: number; + perPage?: number; + sortField?: SortFieldCase; + sortOrder?: Direction; +} + +export interface Action { + type: string; + payload?: AllCases | QueryArgs | FilterOptions; +} +const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesState => { + switch (action.type) { + case FETCH_INIT: + return { + ...state, + isLoading: true, + isError: false, + }; + case FETCH_SUCCESS: + return { + ...state, + isLoading: false, + isError: false, + data: getTypedPayload(action.payload), + }; + case FETCH_FAILURE: + return { + ...state, + isLoading: false, + isError: true, + }; + case UPDATE_QUERY_PARAMS: + return { + ...state, + queryParams: { + ...state.queryParams, + ...action.payload, + }, + }; + case UPDATE_FILTER_OPTIONS: + return { + ...state, + filterOptions: getTypedPayload(action.payload), + }; + default: + throw new Error(); + } +}; + +const initialData: AllCases = { + page: 0, + per_page: 0, + total: 0, + cases: [], +}; +export const useGetCases = (): [ + UseGetCasesState, + Dispatch>, + Dispatch> +] => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: false, + isError: false, + data: initialData, + filterOptions: { + search: '', + tags: [], + }, + queryParams: { + page: DEFAULT_TABLE_ACTIVE_PAGE, + perPage: DEFAULT_TABLE_LIMIT, + sortField: SortFieldCase.createdAt, + sortOrder: Direction.desc, + }, + }); + const [queryParams, setQueryParams] = useState(state.queryParams as QueryArgs); + const [filterQuery, setFilters] = useState(state.filterOptions as FilterOptions); + const [, dispatchToaster] = useStateToaster(); + + useEffect(() => { + if (!isEqual(queryParams, state.queryParams)) { + dispatch({ type: UPDATE_QUERY_PARAMS, payload: queryParams }); + } + }, [queryParams, state.queryParams]); + + useEffect(() => { + if (!isEqual(filterQuery, state.filterOptions)) { + dispatch({ type: UPDATE_FILTER_OPTIONS, payload: filterQuery }); + } + }, [filterQuery, state.filterOptions]); + + useEffect(() => { + let didCancel = false; + const fetchData = async () => { + dispatch({ type: FETCH_INIT }); + try { + const response = await getCases({ + filterOptions: state.filterOptions, + queryParams: state.queryParams, + }); + if (!didCancel) { + dispatch({ + type: FETCH_SUCCESS, + payload: response, + }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: FETCH_FAILURE }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + }; + }, [state.queryParams, state.filterOptions]); + return [state, setQueryParams, setFilters]; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx new file mode 100644 index 0000000000000..f796ae550c9ec --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_tags.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useReducer } from 'react'; +import chrome from 'ui/chrome'; +import { useStateToaster } from '../../components/toasters'; +import { errorToToaster } from '../../components/ml/api/error_to_toaster'; +import * as i18n from './translations'; +import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants'; +import { throwIfNotOk } from '../../hooks/api/api'; + +interface TagsState { + data: string[]; + isLoading: boolean; + isError: boolean; +} +interface Action { + type: string; + payload?: string[]; +} + +const dataFetchReducer = (state: TagsState, action: Action): TagsState => { + switch (action.type) { + case FETCH_INIT: + return { + ...state, + isLoading: true, + isError: false, + }; + case FETCH_SUCCESS: + const getTypedPayload = (a: Action['payload']) => a as string[]; + return { + ...state, + isLoading: false, + isError: false, + data: getTypedPayload(action.payload), + }; + case FETCH_FAILURE: + return { + ...state, + isLoading: false, + isError: true, + }; + default: + throw new Error(); + } +}; +const initialData: string[] = []; + +export const useGetTags = (): [TagsState] => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: false, + isError: false, + data: initialData, + }); + const [, dispatchToaster] = useStateToaster(); + + useEffect(() => { + let didCancel = false; + const fetchData = async () => { + dispatch({ type: FETCH_INIT }); + try { + const response = await fetch(`${chrome.getBasePath()}/api/cases/tags`, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-system-api': 'true', + }, + }); + if (!didCancel) { + await throwIfNotOk(response); + const responseJson = await response.json(); + dispatch({ type: FETCH_SUCCESS, payload: responseJson }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: FETCH_FAILURE }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + }; + }, []); + return [state]; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx new file mode 100644 index 0000000000000..5cf99701977d2 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Dispatch, SetStateAction, useEffect, useReducer, useState } from 'react'; +import { useStateToaster } from '../../components/toasters'; +import { errorToToaster } from '../../components/ml/api/error_to_toaster'; +import * as i18n from './translations'; +import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS, POST_NEW_CASE } from './constants'; +import { Case, NewCase } from './types'; +import { createCase } from './api'; +import { getTypedPayload } from './utils'; + +interface NewCaseState { + data: NewCase; + newCase?: Case; + isLoading: boolean; + isError: boolean; +} +interface Action { + type: string; + payload?: NewCase | Case; +} + +const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => { + switch (action.type) { + case FETCH_INIT: + return { + ...state, + isLoading: true, + isError: false, + }; + case POST_NEW_CASE: + return { + ...state, + isLoading: false, + isError: false, + data: getTypedPayload(action.payload), + }; + case FETCH_SUCCESS: + return { + ...state, + isLoading: false, + isError: false, + newCase: getTypedPayload(action.payload), + }; + case FETCH_FAILURE: + return { + ...state, + isLoading: false, + isError: true, + }; + default: + throw new Error(); + } +}; +const initialData: NewCase = { + description: '', + isNew: false, + tags: [], + title: '', +}; + +export const usePostCase = (): [NewCaseState, Dispatch>] => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: false, + isError: false, + data: initialData, + }); + const [formData, setFormData] = useState(initialData); + const [, dispatchToaster] = useStateToaster(); + + useEffect(() => { + dispatch({ type: POST_NEW_CASE, payload: formData }); + }, [formData]); + + useEffect(() => { + const postCase = async () => { + dispatch({ type: FETCH_INIT }); + try { + const dataWithoutIsNew = state.data; + delete dataWithoutIsNew.isNew; + const response = await createCase(dataWithoutIsNew); + dispatch({ type: FETCH_SUCCESS, payload: response }); + } catch (error) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: FETCH_FAILURE }); + } + }; + if (state.data.isNew) { + postCase(); + } + }, [state.data.isNew]); + return [state, setFormData]; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx new file mode 100644 index 0000000000000..68592c17e58dc --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -0,0 +1,112 @@ +/* + * 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 { useEffect, useReducer } from 'react'; +import { useStateToaster } from '../../components/toasters'; +import { errorToToaster } from '../../components/ml/api/error_to_toaster'; +import * as i18n from './translations'; +import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS, UPDATE_CASE_PROPERTY } from './constants'; +import { Case } from './types'; +import { updateCaseProperty } from './api'; +import { getTypedPayload } from './utils'; + +type UpdateKey = keyof Case; + +interface NewCaseState { + data: Case; + isLoading: boolean; + isError: boolean; + updateKey?: UpdateKey | null; +} + +interface UpdateByKey { + updateKey: UpdateKey; + updateValue: Case[UpdateKey]; +} + +interface Action { + type: string; + payload?: Partial | UpdateByKey; +} + +const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => { + switch (action.type) { + case FETCH_INIT: + return { + ...state, + isLoading: true, + isError: false, + updateKey: null, + }; + case UPDATE_CASE_PROPERTY: + const { updateKey, updateValue } = getTypedPayload(action.payload); + return { + ...state, + isLoading: false, + isError: false, + data: { + ...state.data, + [updateKey]: updateValue, + }, + updateKey, + }; + case FETCH_SUCCESS: + return { + ...state, + isLoading: false, + isError: false, + data: { + ...state.data, + ...getTypedPayload(action.payload), + }, + }; + case FETCH_FAILURE: + return { + ...state, + isLoading: false, + isError: true, + }; + default: + throw new Error(); + } +}; + +export const useUpdateCase = ( + caseId: string, + initialData: Case +): [{ data: Case }, (updates: UpdateByKey) => void] => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: false, + isError: false, + data: initialData, + }); + const [, dispatchToaster] = useStateToaster(); + + const dispatchUpdateCaseProperty = ({ updateKey, updateValue }: UpdateByKey) => { + dispatch({ + type: UPDATE_CASE_PROPERTY, + payload: { updateKey, updateValue }, + }); + }; + + useEffect(() => { + const updateData = async (updateKey: keyof Case) => { + dispatch({ type: FETCH_INIT }); + try { + const response = await updateCaseProperty(caseId, { [updateKey]: state.data[updateKey] }); + dispatch({ type: FETCH_SUCCESS, payload: response }); + } catch (error) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: FETCH_FAILURE }); + } + }; + if (state.updateKey) { + updateData(state.updateKey); + } + }, [state.updateKey]); + + return [{ data: state.data }, dispatchUpdateCaseProperty]; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts new file mode 100644 index 0000000000000..8e6eaca1a8f0c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const getTypedPayload = (a: unknown): T => a as T; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/case.tsx b/x-pack/legacy/plugins/siem/public/pages/case/case.tsx new file mode 100644 index 0000000000000..1206ec950deed --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/case.tsx @@ -0,0 +1,39 @@ +/* + * 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 { EuiButton, EuiFlexGroup } from '@elastic/eui'; +import { HeaderPage } from '../../components/header_page'; +import { WrapperPage } from '../../components/wrapper_page'; +import { AllCases } from './components/all_cases'; +import { SpyRoute } from '../../utils/route/spy_routes'; +import * as i18n from './translations'; +import { getCreateCaseUrl } from '../../components/link_to'; + +const badgeOptions = { + beta: true, + text: i18n.PAGE_BADGE_LABEL, + tooltip: i18n.PAGE_BADGE_TOOLTIP, +}; + +export const CasesPage = React.memo(() => ( + <> + + + + + {i18n.CREATE_TITLE} + + + + + + + +)); + +CasesPage.displayName = 'CasesPage'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/case_details.tsx b/x-pack/legacy/plugins/siem/public/pages/case/case_details.tsx new file mode 100644 index 0000000000000..890df91c8560e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/case_details.tsx @@ -0,0 +1,26 @@ +/* + * 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 { useParams } from 'react-router-dom'; + +import { CaseView } from './components/case_view'; +import { SpyRoute } from '../../utils/route/spy_routes'; + +export const CaseDetailsPage = React.memo(() => { + const { detailName: caseId } = useParams(); + if (!caseId) { + return null; + } + return ( + <> + + + + ); +}); + +CaseDetailsPage.displayName = 'CaseDetailsPage'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx new file mode 100644 index 0000000000000..92cd16fd2000e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -0,0 +1,81 @@ +/* + * 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 { EuiBadge, EuiTableFieldDataColumnType, EuiTableComputedColumnType } from '@elastic/eui'; +import { getEmptyTagValue } from '../../../../components/empty_value'; +import { Case } from '../../../../containers/case/types'; +import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; +import { CaseDetailsLink } from '../../../../components/links'; +import { TruncatableText } from '../../../../components/truncatable_text'; +import * as i18n from './translations'; + +export type CasesColumns = EuiTableFieldDataColumnType | EuiTableComputedColumnType; + +const renderStringField = (field: string) => (field != null ? field : getEmptyTagValue()); + +export const getCasesColumns = (): CasesColumns[] => [ + { + name: i18n.CASE_TITLE, + render: (theCase: Case) => { + if (theCase.case_id != null && theCase.title != null) { + return {theCase.title}; + } + return getEmptyTagValue(); + }, + }, + { + field: 'tags', + name: i18n.TAGS, + render: (tags: Case['tags']) => { + if (tags != null && tags.length > 0) { + return ( + + {tags.map((tag: string, i: number) => ( + + {tag} + + ))} + + ); + } + return getEmptyTagValue(); + }, + truncateText: true, + }, + { + field: 'created_at', + name: i18n.CREATED_AT, + sortable: true, + render: (createdAt: Case['created_at']) => { + if (createdAt != null) { + return ; + } + return getEmptyTagValue(); + }, + }, + { + field: 'created_by.username', + name: i18n.REPORTER, + render: (createdBy: Case['created_by']['username']) => renderStringField(createdBy), + }, + { + field: 'updated_at', + name: i18n.LAST_UPDATED, + sortable: true, + render: (updatedAt: Case['updated_at']) => { + if (updatedAt != null) { + return ; + } + return getEmptyTagValue(); + }, + }, + { + field: 'state', + name: i18n.STATE, + sortable: true, + render: (state: Case['state']) => renderStringField(state), + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx new file mode 100644 index 0000000000000..b1dd39c95e191 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -0,0 +1,147 @@ +/* + * 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, { useCallback, useMemo } from 'react'; +import { + EuiBasicTable, + EuiButton, + EuiEmptyPrompt, + EuiLoadingContent, + EuiTableSortingType, +} from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import * as i18n from './translations'; + +import { getCasesColumns } from './columns'; +import { SortFieldCase, Case, FilterOptions } from '../../../../containers/case/types'; + +import { Direction } from '../../../../graphql/types'; +import { useGetCases } from '../../../../containers/case/use_get_cases'; +import { EuiBasicTableOnChange } from '../../../detection_engine/rules/types'; +import { Panel } from '../../../../components/panel'; +import { HeaderSection } from '../../../../components/header_section'; +import { CasesTableFilters } from './table_filters'; + +import { + UtilityBar, + UtilityBarGroup, + UtilityBarSection, + UtilityBarText, +} from '../../../../components/detection_engine/utility_bar'; +import { getCreateCaseUrl } from '../../../../components/link_to'; + +export const AllCases = React.memo(() => { + const [ + { data, isLoading, queryParams, filterOptions }, + setQueryParams, + setFilters, + ] = useGetCases(); + + const tableOnChangeCallback = useCallback( + ({ page, sort }: EuiBasicTableOnChange) => { + let newQueryParams = queryParams; + if (sort) { + let newSort; + switch (sort.field) { + case 'state': + newSort = SortFieldCase.state; + break; + case 'created_at': + newSort = SortFieldCase.createdAt; + break; + case 'updated_at': + newSort = SortFieldCase.updatedAt; + break; + default: + newSort = SortFieldCase.createdAt; + } + newQueryParams = { + ...newQueryParams, + sortField: newSort, + sortOrder: sort.direction as Direction, + }; + } + if (page) { + newQueryParams = { + ...newQueryParams, + page: page.index + 1, + perPage: page.size, + }; + } + setQueryParams(newQueryParams); + }, + [setQueryParams, queryParams] + ); + + const onFilterChangedCallback = useCallback( + (newFilterOptions: Partial) => { + setFilters({ ...filterOptions, ...newFilterOptions }); + }, + [filterOptions, setFilters] + ); + + const memoizedGetCasesColumns = useMemo(() => getCasesColumns(), []); + const memoizedPagination = useMemo( + () => ({ + pageIndex: queryParams.page - 1, + pageSize: queryParams.perPage, + totalItemCount: data.total, + pageSizeOptions: [5, 10, 20, 50, 100, 200, 300], + }), + [data, queryParams] + ); + + const sorting: EuiTableSortingType = { + sort: { field: queryParams.sortField, direction: queryParams.sortOrder }, + }; + + return ( + + + + + {isLoading && isEmpty(data.cases) && ( + + )} + {!isLoading && !isEmpty(data.cases) && ( + <> + + + + {i18n.SHOWING_CASES(data.total ?? 0)} + + + + {i18n.NO_CASES}} + titleSize="xs" + body={i18n.NO_CASES_BODY} + actions={ + + {i18n.ADD_NEW_CASE} + + } + /> + } + onChange={tableOnChangeCallback} + pagination={memoizedPagination} + sorting={sorting} + /> + + )} + + ); +}); + +AllCases.displayName = 'AllCases'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx new file mode 100644 index 0000000000000..e593623788046 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/table_filters.tsx @@ -0,0 +1,90 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { isEqual } from 'lodash/fp'; +import { EuiFieldSearch, EuiFilterGroup, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import * as i18n from './translations'; + +import { FilterOptions } from '../../../../containers/case/types'; +import { useGetTags } from '../../../../containers/case/use_get_tags'; +import { TagsFilterPopover } from '../../../../pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover'; + +interface Initial { + search: string; + tags: string[]; +} +interface CasesTableFiltersProps { + onFilterChanged: (filterOptions: Partial) => void; + initial: Initial; +} + +/** + * Collection of filters for filtering data within the CasesTable. Contains search bar, + * and tag selection + * + * @param onFilterChanged change listener to be notified on filter changes + */ + +const CasesTableFiltersComponent = ({ + onFilterChanged, + initial = { search: '', tags: [] }, +}: CasesTableFiltersProps) => { + const [search, setSearch] = useState(initial.search); + const [selectedTags, setSelectedTags] = useState(initial.tags); + const [{ isLoading, data }] = useGetTags(); + + const handleSelectedTags = useCallback( + newTags => { + if (!isEqual(newTags, selectedTags)) { + setSelectedTags(newTags); + onFilterChanged({ search, tags: newTags }); + } + }, + [search, selectedTags] + ); + const handleOnSearch = useCallback( + newSearch => { + const trimSearch = newSearch.trim(); + if (!isEqual(trimSearch, search)) { + setSearch(trimSearch); + onFilterChanged({ tags: selectedTags, search: trimSearch }); + } + }, + [search, selectedTags] + ); + + return ( + + + + + + + + + + + + ); +}; + +CasesTableFiltersComponent.displayName = 'CasesTableFiltersComponent'; + +export const CasesTableFilters = React.memo(CasesTableFiltersComponent); + +CasesTableFilters.displayName = 'CasesTableFilters'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts new file mode 100644 index 0000000000000..ab8e22ebcf1be --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts @@ -0,0 +1,48 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export * from '../../translations'; + +export const ALL_CASES = i18n.translate('xpack.siem.case.caseTable.title', { + defaultMessage: 'All Cases', +}); +export const NO_CASES = i18n.translate('xpack.siem.case.caseTable.noCases.title', { + defaultMessage: 'No Cases', +}); +export const NO_CASES_BODY = i18n.translate('xpack.siem.case.caseTable.noCases.body', { + defaultMessage: 'Create a new case to see it displayed in the case workflow table.', +}); +export const ADD_NEW_CASE = i18n.translate('xpack.siem.case.caseTable.addNewCase', { + defaultMessage: 'Add New Case', +}); + +export const SHOWING_CASES = (totalRules: number) => + i18n.translate('xpack.siem.case.caseTable.showingCasesTitle', { + values: { totalRules }, + defaultMessage: 'Showing {totalRules} {totalRules, plural, =1 {case} other {cases}}', + }); + +export const UNIT = (totalCount: number) => + i18n.translate('xpack.siem.case.caseTable.unit', { + values: { totalCount }, + defaultMessage: `{totalCount, plural, =1 {case} other {cases}}`, + }); + +export const SEARCH_CASES = i18n.translate( + 'xpack.siem.detectionEngine.case.caseTable.searchAriaLabel', + { + defaultMessage: 'Search cases', + } +); + +export const SEARCH_PLACEHOLDER = i18n.translate( + 'xpack.siem.detectionEngine.case.caseTable.searchPlaceholder', + { + defaultMessage: 'e.g. case name', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx new file mode 100644 index 0000000000000..4f43a6edeeac6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -0,0 +1,312 @@ +/* + * 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, { useCallback, useEffect, useState } from 'react'; +import { + EuiBadge, + EuiButton, + EuiButtonEmpty, + EuiButtonToggle, + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, +} from '@elastic/eui'; + +import styled, { css } from 'styled-components'; +import * as i18n from './translations'; +import { DescriptionMarkdown } from '../description_md_editor'; +import { Case } from '../../../../containers/case/types'; +import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; +import { getCaseUrl } from '../../../../components/link_to'; +import { HeaderPage } from '../../../../components/header_page_new'; +import { Markdown } from '../../../../components/markdown'; +import { PropertyActions } from '../property_actions'; +import { TagList } from '../tag_list'; +import { useGetCase } from '../../../../containers/case/use_get_case'; +import { UserActionTree } from '../user_action_tree'; +import { UserList } from '../user_list'; +import { useUpdateCase } from '../../../../containers/case/use_update_case'; +import { WrapperPage } from '../../../../components/wrapper_page'; + +interface Props { + caseId: string; +} + +const MyDescriptionList = styled(EuiDescriptionList)` + ${({ theme }) => css` + & { + padding-right: ${theme.eui.euiSizeL}; + border-right: ${theme.eui.euiBorderThin}; + } + `} +`; + +const MyWrapper = styled(WrapperPage)` + padding-bottom: 0; +`; +const BackgroundWrapper = styled.div` + ${({ theme }) => css` + background-color: ${theme.eui.euiColorEmptyShade}; + border-top: ${theme.eui.euiBorderThin}; + height: 100%; + `} +`; + +interface CasesProps { + caseId: string; + initialData: Case; + isLoading: boolean; +} + +export const Cases = React.memo(({ caseId, initialData, isLoading }) => { + const [{ data }, dispatchUpdateCaseProperty] = useUpdateCase(caseId, initialData); + const [isEditDescription, setIsEditDescription] = useState(false); + const [isEditTitle, setIsEditTitle] = useState(false); + const [isEditTags, setIsEditTags] = useState(false); + const [isCaseOpen, setIsCaseOpen] = useState(data.state === 'open'); + const [description, setDescription] = useState(data.description); + const [title, setTitle] = useState(data.title); + const [tags, setTags] = useState(data.tags); + + const onUpdateField = useCallback( + async (updateKey: keyof Case, updateValue: string | string[]) => { + switch (updateKey) { + case 'title': + if (updateValue.length > 0) { + dispatchUpdateCaseProperty({ + updateKey: 'title', + updateValue, + }); + setIsEditTitle(false); + } + break; + case 'description': + if (updateValue.length > 0) { + dispatchUpdateCaseProperty({ + updateKey: 'description', + updateValue, + }); + setIsEditDescription(false); + } + break; + case 'tags': + setTags(updateValue as string[]); + if (updateValue.length > 0) { + dispatchUpdateCaseProperty({ + updateKey: 'tags', + updateValue, + }); + setIsEditTags(false); + } + break; + default: + return null; + } + }, + [dispatchUpdateCaseProperty, title] + ); + + const onSetIsCaseOpen = useCallback(() => setIsCaseOpen(!isCaseOpen), [ + isCaseOpen, + setIsCaseOpen, + ]); + + useEffect(() => { + const caseState = isCaseOpen ? 'open' : 'closed'; + if (data.state !== caseState) { + dispatchUpdateCaseProperty({ + updateKey: 'state', + updateValue: caseState, + }); + } + }, [isCaseOpen]); + + // TO DO refactor each of these const's into their own components + const propertyActions = [ + { + iconType: 'documentEdit', + label: 'Edit description', + onClick: () => setIsEditDescription(true), + }, + { + iconType: 'securitySignalResolved', + label: 'Close case', + onClick: () => null, + }, + { + iconType: 'trash', + label: 'Delete case', + onClick: () => null, + }, + { + iconType: 'importAction', + label: 'Push as ServiceNow incident', + onClick: () => null, + }, + { + iconType: 'popout', + label: 'View ServiceNow incident', + onClick: () => null, + }, + { + iconType: 'importAction', + label: 'Update ServiceNow incident', + onClick: () => null, + }, + ]; + const userActions = [ + { + avatarName: data.created_by.username, + title: ( + + +

+ {`${data.created_by.username}`} + {` ${i18n.ADDED_DESCRIPTION} `}{' '} + + {/* STEPH FIX come back and add label `on` */} +

+
+ + + +
+ ), + children: isEditDescription ? ( + <> + setDescription(updatedDescription)} + /> + + + + onUpdateField('description', description)} + > + {i18n.SUBMIT} + + + + setIsEditDescription(false)}> + {i18n.CANCEL} + + + + + ) : ( + + ), + }, + ]; + return ( + <> + + setTitle(newTitle), + onSubmit: () => onUpdateField('title', title), + onClick: isEdit => setIsEditTitle(isEdit), + }} + isEditTitle={isEditTitle} + title={title} + > + + + + + + {i18n.STATUS} + + {data.state} + + + + {i18n.CASE_OPENED} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + onUpdateField('tags', newTags), + onClick: isEdit => setIsEditTags(isEdit), + }} + isEditTags={isEditTags} + /> + + + + + + ); +}); + +export const CaseView = React.memo(({ caseId }: Props) => { + const [{ data, isLoading, isError }] = useGetCase(caseId); + if (isError) { + return null; + } + if (isLoading) { + return ( + + + + + + ); + } + + return ; +}); + +CaseView.displayName = 'CaseView'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts new file mode 100644 index 0000000000000..f45c52533d2e7 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.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 { i18n } from '@kbn/i18n'; + +export * from '../../translations'; + +export const SHOWING_CASES = (actionDate: string, actionName: string, userName: string) => + i18n.translate('xpack.siem.case.caseView.actionHeadline', { + values: { + actionDate, + actionName, + userName, + }, + defaultMessage: '{userName} {actionName} on {actionDate}', + }); + +export const ADDED_DESCRIPTION = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.addDescription', + { + defaultMessage: 'added description', + } +); + +export const EDITED_DESCRIPTION = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.editDescription', + { + defaultMessage: 'edited description', + } +); + +export const ADDED_COMMENT = i18n.translate('xpack.siem.case.caseView.actionLabel.addComment', { + defaultMessage: 'added comment', +}); + +export const STATUS = i18n.translate('xpack.siem.case.caseView.statusLabel', { + defaultMessage: 'Status', +}); + +export const CASE_OPENED = i18n.translate('xpack.siem.case.caseView.caseOpened', { + defaultMessage: 'Case opened', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/form_options.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/create/form_options.ts new file mode 100644 index 0000000000000..7bc43e23a72c5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/form_options.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const stateOptions = [ + { + value: 'open', + inputDisplay: 'Open', + }, + { + value: 'closed', + inputDisplay: 'Closed', + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx new file mode 100644 index 0000000000000..9fd1525003b0b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useCallback } from 'react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiLoadingSpinner, + EuiPanel, +} from '@elastic/eui'; +import styled from 'styled-components'; +import { Redirect } from 'react-router-dom'; +import { Field, Form, getUseField, useForm } from '../../../shared_imports'; +import { NewCase } from '../../../../containers/case/types'; +import { usePostCase } from '../../../../containers/case/use_post_case'; +import { schema } from './schema'; +import * as i18n from '../../translations'; +import { SiemPageName } from '../../../home/types'; +import { DescriptionMarkdown } from '../description_md_editor'; + +export const CommonUseField = getUseField({ component: Field }); + +const TagContainer = styled.div` + margin-top: 16px; +`; +const MySpinner = styled(EuiLoadingSpinner)` + position: absolute; + top: 50%; + left: 50%; +`; + +export const Create = React.memo(() => { + const [{ data, isLoading, newCase }, setFormData] = usePostCase(); + const { form } = useForm({ + defaultValue: data, + options: { stripEmptyFields: false }, + schema, + }); + + const onSubmit = useCallback(async () => { + const { isValid, data: newData } = await form.submit(); + if (isValid) { + setFormData({ ...newData, isNew: true } as NewCase); + } + }, [form]); + + if (newCase && newCase.case_id) { + return ; + } + return ( + + {isLoading && } +
+ + setFormData({ ...data, description })} + /> + + + + + <> + + + + + {i18n.SUBMIT} + + + + +
+ ); +}); + +Create.displayName = 'Create'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/optional_field_label/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/optional_field_label/index.tsx new file mode 100644 index 0000000000000..b86198e09ceac --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/optional_field_label/index.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiText } from '@elastic/eui'; +import React from 'react'; + +import * as i18n from '../../../translations'; + +export const OptionalFieldLabel = ( + + {i18n.OPTIONAL} + +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx new file mode 100644 index 0000000000000..1b5df72a6671c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../shared_imports'; +import { OptionalFieldLabel } from './optional_field_label'; +import * as i18n from '../../translations'; + +const { emptyField } = fieldValidators; + +export const schema: FormSchema = { + title: { + type: FIELD_TYPES.TEXT, + label: i18n.CASE_TITLE, + validations: [ + { + validator: emptyField(i18n.TITLE_REQUIRED), + }, + ], + }, + description: { + type: FIELD_TYPES.TEXTAREA, + validations: [ + { + validator: emptyField(i18n.DESCRIPTION_REQUIRED), + }, + ], + }, + tags: { + type: FIELD_TYPES.COMBO_BOX, + label: i18n.TAGS, + helpText: i18n.TAGS_HELP, + labelAppend: OptionalFieldLabel, + }, +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/description_md_editor/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/description_md_editor/index.tsx new file mode 100644 index 0000000000000..44062a5a1d589 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/description_md_editor/index.tsx @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexItem, EuiPanel, EuiTabbedContent, EuiTextArea } from '@elastic/eui'; +import React, { useState } from 'react'; +import styled from 'styled-components'; + +import { Markdown } from '../../../../components/markdown'; +import * as i18n from '../../translations'; +import { MarkdownHint } from '../../../../components/markdown/markdown_hint'; +import { CommonUseField } from '../create'; + +const TextArea = styled(EuiTextArea)<{ height: number }>` + min-height: ${({ height }) => `${height}px`}; + width: 100%; +`; + +TextArea.displayName = 'TextArea'; + +const DescriptionContainer = styled.div` + margin-top: 15px; + margin-bottom: 15px; +`; + +const DescriptionMarkdownTabs = styled(EuiTabbedContent)` + width: 100%; +`; + +DescriptionMarkdownTabs.displayName = 'DescriptionMarkdownTabs'; + +const MarkdownContainer = styled(EuiPanel)<{ height: number }>` + height: ${({ height }) => height}px; + overflow: auto; +`; + +MarkdownContainer.displayName = 'MarkdownContainer'; + +/** An input for entering a new case description */ +export const DescriptionMarkdown = React.memo<{ + descriptionInputHeight: number; + initialDescription: string; + isLoading: boolean; + formHook?: boolean; + onChange: (description: string) => void; +}>(({ initialDescription, isLoading, descriptionInputHeight, onChange, formHook = false }) => { + const [description, setDescription] = useState(initialDescription); + const tabs = [ + { + id: 'description', + name: i18n.DESCRIPTION, + content: formHook ? ( + { + setDescription(e as string); + onChange(e as string); + }} + componentProps={{ + idAria: 'caseDescription', + 'data-test-subj': 'caseDescription', + isDisabled: isLoading, + spellcheck: false, + }} + /> + ) : ( +