From 3793ae538148a1cdd650db9ecca2a141404875ee Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Fri, 31 Jul 2020 09:57:07 -0400 Subject: [PATCH 01/29] Check for security first (#73821) Co-authored-by: Elastic Machine --- .../__test__/get_collection_status.test.js | 52 ++++++++++++++++--- .../setup/collection/get_collection_status.js | 7 +++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/monitoring/server/lib/setup/collection/__test__/get_collection_status.test.js b/x-pack/plugins/monitoring/server/lib/setup/collection/__test__/get_collection_status.test.js index e56627369475b..083ebfb27fd51 100644 --- a/x-pack/plugins/monitoring/server/lib/setup/collection/__test__/get_collection_status.test.js +++ b/x-pack/plugins/monitoring/server/lib/setup/collection/__test__/get_collection_status.test.js @@ -10,7 +10,12 @@ import { getCollectionStatus } from '..'; import { getIndexPatterns } from '../../../cluster/get_index_patterns'; const liveClusterUuid = 'a12'; -const mockReq = (searchResult = {}, securityEnabled = true, userHasPermissions = true) => { +const mockReq = ( + searchResult = {}, + securityEnabled = true, + userHasPermissions = true, + securityErrorMessage = null +) => { return { server: { newPlatform: { @@ -37,12 +42,14 @@ const mockReq = (searchResult = {}, securityEnabled = true, userHasPermissions = }, }, plugins: { - xpack_main: { + monitoring: { info: { - isAvailable: () => true, - feature: () => ({ - isEnabled: () => securityEnabled, - }), + getSecurityFeature: () => { + return { + isAvailable: securityEnabled, + isEnabled: securityEnabled, + }; + }, }, }, elasticsearch: { @@ -61,6 +68,11 @@ const mockReq = (searchResult = {}, securityEnabled = true, userHasPermissions = params && params.path === '/_security/user/_has_privileges' ) { + if (securityErrorMessage !== null) { + return Promise.reject({ + message: securityErrorMessage, + }); + } return Promise.resolve({ has_all_requested: userHasPermissions }); } if (type === 'transport.request' && params && params.path === '/_nodes') { @@ -245,6 +257,34 @@ describe('getCollectionStatus', () => { expect(result.kibana.detected.doesExist).to.be(true); }); + it('should work properly with an unknown security message', async () => { + const req = mockReq({ hits: { total: { value: 1 } } }, true, true, 'foobar'); + const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + expect(result._meta.hasPermissions).to.be(false); + }); + + it('should work properly with a known security message', async () => { + const req = mockReq( + { hits: { total: { value: 1 } } }, + true, + true, + 'no handler found for uri [/_security/user/_has_privileges] and method [POST]' + ); + const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + expect(result.kibana.detected.doesExist).to.be(true); + }); + + it('should work properly with another known security message', async () => { + const req = mockReq( + { hits: { total: { value: 1 } } }, + true, + true, + 'Invalid index name [_security]' + ); + const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); + expect(result.kibana.detected.doesExist).to.be(true); + }); + it('should not work if the user does not have the necessary permissions', async () => { const req = mockReq({ hits: { total: { value: 1 } } }, true, false); const result = await getCollectionStatus(req, getIndexPatterns(req.server), liveClusterUuid); diff --git a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.js b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.js index 607503673276b..81cdfd6ecd172 100644 --- a/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.js +++ b/x-pack/plugins/monitoring/server/lib/setup/collection/get_collection_status.js @@ -233,6 +233,10 @@ function isBeatFromAPM(bucket) { } async function hasNecessaryPermissions(req) { + const securityFeature = req.server.plugins.monitoring.info.getSecurityFeature(); + if (!securityFeature.isAvailable || !securityFeature.isEnabled) { + return true; + } try { const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('data'); const response = await callWithRequest(req, 'transport.request', { @@ -250,6 +254,9 @@ async function hasNecessaryPermissions(req) { ) { return true; } + if (err.message.includes('Invalid index name [_security]')) { + return true; + } return false; } } From 9a1a6d35bc556a6debdd270f8cbf9ff720065b89 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Fri, 31 Jul 2020 10:04:58 -0400 Subject: [PATCH 02/29] Use defaultsDeep to match what monitoring is doing (#73325) Co-authored-by: Elastic Machine --- src/legacy/server/status/routes/api/register_stats.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/legacy/server/status/routes/api/register_stats.js b/src/legacy/server/status/routes/api/register_stats.js index 0221c7e0ea085..2cd780d21f681 100644 --- a/src/legacy/server/status/routes/api/register_stats.js +++ b/src/legacy/server/status/routes/api/register_stats.js @@ -19,6 +19,7 @@ import Joi from 'joi'; import boom from 'boom'; +import { defaultsDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; import { wrapAuthConfig } from '../../wrap_auth_config'; import { getKibanaInfoForStats } from '../../lib'; @@ -120,10 +121,9 @@ export function registerStatsApi(usageCollection, server, config, kbnServer) { }, }; } else { - accum = { - ...accum, - [usageKey]: usage[usageKey], - }; + // I don't think we need to it this for the above conditions, but do it for most as it will + // match the behavior done in monitoring/bulk_uploader + defaultsDeep(accum, { [usageKey]: usage[usageKey] }); } return accum; From 4454b30db2854296aa9903ac6152d453f29a82d8 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 31 Jul 2020 16:33:40 +0200 Subject: [PATCH 03/29] reset validation counter (#73459) --- .../vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/plugins/vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts b/src/plugins/vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts index a9b542af68c9d..dd748ea2d3815 100644 --- a/src/plugins/vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts +++ b/src/plugins/vis_type_timeseries/server/saved_objects/tsvb_telemetry.ts @@ -42,5 +42,7 @@ export const tsvbTelemetrySavedObjectType: SavedObjectsType = { migrations: { '7.7.0': flow(resetCount), '7.8.0': flow(resetCount), + '7.9.0': flow(resetCount), + '7.10.0': flow(resetCount), }, }; From d51e277c3e41ed3aabe6a114682f74a534207757 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Fri, 31 Jul 2020 08:34:27 -0600 Subject: [PATCH 04/29] [Security Solution][Detections] Fixes risk score mapping bug and updates copy on empty rules message (#73901) ## Summary Fixes issue where Rules with a `Risk Score Mapping` could not be created. Fixes copy for the Rules Table empty view that says all rules are disabled by default (no longer true for the `Elastic Endpoint Security Rule`)

### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) --- .../components/rules/pre_packaged_rules/translations.ts | 2 +- .../detections/components/rules/risk_score_mapping/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts index 49da7dbf6d514..9b0cec99b1b38 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts @@ -17,7 +17,7 @@ export const PRE_BUILT_MSG = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.prePackagedRules.emptyPromptMessage', { defaultMessage: - 'Elastic Security comes with prebuilt detection rules that run in the background and create alerts when their conditions are met. By default, all prebuilt rules are disabled and you select which rules you want to activate.', + 'Elastic Security comes with prebuilt detection rules that run in the background and create alerts when their conditions are met. By default, all prebuilt rules except the Elastic Endpoint Security rule are disabled. You can select additional rules you want to activate.', } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/risk_score_mapping/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/risk_score_mapping/index.tsx index 35816e82540d1..0f16cb99862a5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/risk_score_mapping/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/risk_score_mapping/index.tsx @@ -70,7 +70,7 @@ export const RiskScoreField = ({ { field: newField?.name ?? '', operator: 'equals', - value: undefined, + value: '', riskScore: undefined, }, ], From 747e9e47363581539d8a767ede9006c67f32479b Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 31 Jul 2020 16:35:47 +0200 Subject: [PATCH 05/29] Stabilize graph test (#73918) --- x-pack/test/functional/apps/graph/graph.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/x-pack/test/functional/apps/graph/graph.ts b/x-pack/test/functional/apps/graph/graph.ts index c2500dca78444..68e5045c1f36c 100644 --- a/x-pack/test/functional/apps/graph/graph.ts +++ b/x-pack/test/functional/apps/graph/graph.ts @@ -129,17 +129,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should show venn when clicking a line', async function () { await buildGraph(); - const { edges } = await PageObjects.graph.getGraphObjects(); await PageObjects.graph.isolateEdge('test', '/test/wp-admin/'); await PageObjects.graph.stopLayout(); await PageObjects.common.sleep(1000); - const testTestWpAdminBlogEdge = edges.find( - ({ sourceNode, targetNode }) => - targetNode.label === '/test/wp-admin/' && sourceNode.label === 'test' - )!; - await testTestWpAdminBlogEdge.element.click(); + await browser.execute(() => { + const event = document.createEvent('SVGEvents'); + event.initEvent('click', true, true); + return document.getElementsByClassName('gphEdge')[0].dispatchEvent(event); + }); await PageObjects.common.sleep(1000); await PageObjects.graph.startLayout(); From df3e209262fbca24bdee9ed2353e077965a329b6 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Fri, 31 Jul 2020 10:39:01 -0400 Subject: [PATCH 06/29] [Uptime] Unskip alerting functional tests (#72963) * Unskip monitor status alert test. * Trying to resolve flakiness. * Remove commented code. * Simplify test expect. * Revert conditional block change. * Remove line in question. Co-authored-by: Elastic Machine --- .../functional/services/uptime/navigation.ts | 2 +- .../apps/uptime/alert_flyout.ts | 17 ++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/x-pack/test/functional/services/uptime/navigation.ts b/x-pack/test/functional/services/uptime/navigation.ts index ab511abf130a5..710923c886cbe 100644 --- a/x-pack/test/functional/services/uptime/navigation.ts +++ b/x-pack/test/functional/services/uptime/navigation.ts @@ -17,7 +17,7 @@ export function UptimeNavigationProvider({ getService, getPageObjects }: FtrProv if (await testSubjects.exists('uptimeSettingsToOverviewLink', { timeout: 0 })) { await testSubjects.click('uptimeSettingsToOverviewLink'); await testSubjects.existOrFail('uptimeOverviewPage', { timeout: 2000 }); - } else if (!(await testSubjects.exists('uptimeOverviewPage', { timeout: 0 }))) { + } else { await PageObjects.common.navigateToApp('uptime'); await PageObjects.header.waitUntilLoadingHasFinished(); await testSubjects.existOrFail('uptimeOverviewPage', { timeout: 2000 }); diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts index 6cb74aff95be2..a6de87d6f7b1a 100644 --- a/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts @@ -8,8 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { - // FLAKY: https://github.com/elastic/kibana/issues/65948 - describe.skip('uptime alerts', () => { + describe('uptime alerts', () => { const pageObjects = getPageObjects(['common', 'uptime']); const supertest = getService('supertest'); const retry = getService('retry'); @@ -105,7 +104,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { alertTypeId, consumer, id, - params: { numTimes, timerange, locations, filters }, + params: { numTimes, timerangeUnit, timerangeCount, filters }, schedule: { interval }, tags, } = alert; @@ -119,14 +118,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(interval).to.eql('11m'); expect(tags).to.eql(['uptime', 'another']); expect(numTimes).to.be(3); - expect(timerange.from).to.be('now-1h'); - expect(timerange.to).to.be('now'); - expect(locations).to.eql(['mpls']); - expect(filters).to.eql( - '{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"monitor.id":"0001-up"}}],' + - '"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"match":{"observer.geo.name":"mpls"}}],' + - '"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"match":{"url.port":5678}}],' + - '"minimum_should_match":1}},{"bool":{"should":[{"match":{"monitor.type":"http"}}],"minimum_should_match":1}}]}}]}}]}}' + expect(timerangeUnit).to.be('h'); + expect(timerangeCount).to.be(1); + expect(JSON.stringify(filters)).to.eql( + `{"url.port":["5678"],"observer.geo.name":["mpls"],"monitor.type":["http"],"tags":[]}` ); } finally { await supertest.delete(`/api/alerts/alert/${id}`).set('kbn-xsrf', 'true').expect(204); From ff3877d61db17aa8343357f29f50a409e256e16a Mon Sep 17 00:00:00 2001 From: Poff Poffenberger Date: Fri, 31 Jul 2020 09:43:56 -0500 Subject: [PATCH 07/29] Hide Canvas toolbar close button when tray is closed (#73845) Co-authored-by: Elastic Machine --- x-pack/plugins/canvas/public/components/toolbar/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/canvas/public/components/toolbar/index.js b/x-pack/plugins/canvas/public/components/toolbar/index.js index 16860063f8a45..a95371f5f032a 100644 --- a/x-pack/plugins/canvas/public/components/toolbar/index.js +++ b/x-pack/plugins/canvas/public/components/toolbar/index.js @@ -44,6 +44,6 @@ export const Toolbar = compose( props.router.navigateTo('loadWorkpad', { id: props.workpadId, page: pageNumber }); }, }), - withState('tray', 'setTray', (props) => props.tray), + withState('tray', 'setTray', null), withState('showWorkpadManager', 'setShowWorkpadManager', false) )(Component); From 9c5bbe4e5f18df9e5f6212d24e7150b5f2cdcb84 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Fri, 31 Jul 2020 10:44:45 -0400 Subject: [PATCH 08/29] [ML] DF Analytics creation wizard: ensure user can switch back to form from JSON editor (#73752) * wip: add reducer action to switch to form * rename getFormStateFromJobConfig * wip: types fix * show destIndex input when switching back from editor * ensure validation up to date when switching to form * cannot switch back to form if advanced config * update types * localization fix --- .../details_step/details_step_form.tsx | 14 ++-- .../pages/analytics_creation/page.tsx | 71 +++++++++++-------- .../use_create_analytics_form/actions.ts | 5 +- .../use_create_analytics_form/reducer.ts | 57 +++++++++++++-- .../use_create_analytics_form/state.test.ts | 14 ++-- .../hooks/use_create_analytics_form/state.ts | 16 ++++- .../use_create_analytics_form.ts | 9 ++- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 9 files changed, 131 insertions(+), 57 deletions(-) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx index 0ac237bb33e76..1d6a603caa817 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx @@ -44,7 +44,7 @@ export const DetailsStepForm: FC = ({ const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; const { setFormState } = actions; - const { form, cloneJob, isJobCreated } = state; + const { form, cloneJob, hasSwitchedToEditor, isJobCreated } = state; const { createIndexPattern, description, @@ -61,7 +61,9 @@ export const DetailsStepForm: FC = ({ resultsField, } = form; - const [destIndexSameAsId, setDestIndexSameAsId] = useState(cloneJob === undefined); + const [destIndexSameAsId, setDestIndexSameAsId] = useState( + cloneJob === undefined && hasSwitchedToEditor === false + ); const forceInput = useRef(null); @@ -90,7 +92,11 @@ export const DetailsStepForm: FC = ({ useEffect(() => { if (destinationIndexNameValid === true) { debouncedIndexCheck(); - } else if (destinationIndex.trim() === '' && destinationIndexNameExists === true) { + } else if ( + typeof destinationIndex === 'string' && + destinationIndex.trim() === '' && + destinationIndexNameExists === true + ) { setFormState({ destinationIndexNameExists: false }); } @@ -102,7 +108,7 @@ export const DetailsStepForm: FC = ({ useEffect(() => { if (destIndexSameAsId === true && !jobIdEmpty && jobIdValid) { setFormState({ destinationIndex: jobId }); - } else if (destIndexSameAsId === false) { + } else if (destIndexSameAsId === false && hasSwitchedToEditor === false) { setFormState({ destinationIndex: '' }); } }, [destIndexSameAsId, jobId]); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/page.tsx index 04dd25896d443..2f0e2ed3428c0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/page.tsx @@ -6,7 +6,6 @@ import React, { FC, useEffect, useState } from 'react'; import { - EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -16,7 +15,7 @@ import { EuiSpacer, EuiSteps, EuiStepStatus, - EuiText, + EuiSwitch, EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -48,9 +47,15 @@ export const Page: FC = ({ jobId }) => { const { currentIndexPattern } = mlContext; const createAnalyticsForm = useCreateAnalyticsForm(); - const { isAdvancedEditorEnabled } = createAnalyticsForm.state; - const { jobType } = createAnalyticsForm.state.form; - const { initiateWizard, setJobClone, switchToAdvancedEditor } = createAnalyticsForm.actions; + const { state } = createAnalyticsForm; + const { isAdvancedEditorEnabled, disableSwitchToForm } = state; + const { jobType } = state.form; + const { + initiateWizard, + setJobClone, + switchToAdvancedEditor, + switchToForm, + } = createAnalyticsForm.actions; useEffect(() => { initiateWizard(); @@ -170,34 +175,40 @@ export const Page: FC = ({ jobId }) => { - {isAdvancedEditorEnabled === false && ( - - + + - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.create.switchToJsonEditorSwitch', - { - defaultMessage: 'Switch to json editor', - } - )} - - - - - )} + checked={isAdvancedEditorEnabled} + onChange={(e) => { + if (e.target.checked === true) { + switchToAdvancedEditor(); + } else { + switchToForm(); + } + }} + data-test-subj="mlAnalyticsCreateJobWizardAdvancedEditorSwitch" + /> + + {isAdvancedEditorEnabled === true && ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/actions.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/actions.ts index 4bfee9f308313..5f3045696f170 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/actions.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/actions.ts @@ -25,6 +25,7 @@ export enum ACTION { SET_JOB_CONFIG, SET_JOB_IDS, SWITCH_TO_ADVANCED_EDITOR, + SWITCH_TO_FORM, SET_ESTIMATED_MODEL_MEMORY_LIMIT, SET_JOB_CLONE, } @@ -38,7 +39,8 @@ export type Action = | ACTION.OPEN_MODAL | ACTION.RESET_ADVANCED_EDITOR_MESSAGES | ACTION.RESET_FORM - | ACTION.SWITCH_TO_ADVANCED_EDITOR; + | ACTION.SWITCH_TO_ADVANCED_EDITOR + | ACTION.SWITCH_TO_FORM; } // Actions with custom payloads: | { type: ACTION.ADD_REQUEST_MESSAGE; requestMessage: FormMessage } @@ -71,6 +73,7 @@ export interface ActionDispatchers { setJobConfig: (payload: State['jobConfig']) => void; startAnalyticsJob: () => void; switchToAdvancedEditor: () => void; + switchToForm: () => void; setEstimatedModelMemoryLimit: (value: State['estimatedModelMemoryLimit']) => void; setJobClone: (cloneJob: DeepReadonly) => Promise; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index acdaf15cdf4b7..8d8421a116b91 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -8,13 +8,17 @@ import { i18n } from '@kbn/i18n'; import { memoize } from 'lodash'; // @ts-ignore import numeral from '@elastic/numeral'; -import { isEmpty } from 'lodash'; import { isValidIndexName } from '../../../../../../../common/util/es_utils'; import { collapseLiteralStrings } from '../../../../../../../../../../src/plugins/es_ui_shared/public'; import { Action, ACTION } from './actions'; -import { getInitialState, getJobConfigFromFormState, State } from './state'; +import { + getInitialState, + getFormStateFromJobConfig, + getJobConfigFromFormState, + State, +} from './state'; import { isJobIdValid, validateModelMemoryLimitUnits, @@ -41,6 +45,7 @@ import { TRAINING_PERCENT_MAX, } from '../../../../common/analytics'; import { indexPatterns } from '../../../../../../../../../../src/plugins/data/public'; +import { isAdvancedConfig } from '../../components/action_clone/clone_button'; const mmlAllowedUnitsStr = `${ALLOWED_DATA_UNITS.slice(0, ALLOWED_DATA_UNITS.length - 1).join( ', ' @@ -458,13 +463,16 @@ export function reducer(state: State, action: Action): State { case ACTION.SET_ADVANCED_EDITOR_RAW_STRING: let resultJobConfig; + let disableSwitchToForm = false; try { resultJobConfig = JSON.parse(collapseLiteralStrings(action.advancedEditorRawString)); + disableSwitchToForm = isAdvancedConfig(resultJobConfig); } catch (e) { return { ...state, advancedEditorRawString: action.advancedEditorRawString, isAdvancedEditorValidJson: false, + disableSwitchToForm: true, advancedEditorMessages: [], }; } @@ -473,6 +481,7 @@ export function reducer(state: State, action: Action): State { ...validateAdvancedEditor({ ...state, jobConfig: resultJobConfig }), advancedEditorRawString: action.advancedEditorRawString, isAdvancedEditorValidJson: true, + disableSwitchToForm, }; case ACTION.SET_FORM_STATE: @@ -538,17 +547,53 @@ export function reducer(state: State, action: Action): State { case ACTION.SWITCH_TO_ADVANCED_EDITOR: let { jobConfig } = state; - const isJobConfigEmpty = isEmpty(state.jobConfig); - if (isJobConfigEmpty) { - jobConfig = getJobConfigFromFormState(state.form); - } + jobConfig = getJobConfigFromFormState(state.form); + const shouldDisableSwitchToForm = isAdvancedConfig(jobConfig); + return validateAdvancedEditor({ ...state, advancedEditorRawString: JSON.stringify(jobConfig, null, 2), isAdvancedEditorEnabled: true, + disableSwitchToForm: shouldDisableSwitchToForm, + hasSwitchedToEditor: true, jobConfig, }); + case ACTION.SWITCH_TO_FORM: + const { jobConfig: config, jobIds } = state; + const { jobId } = state.form; + // @ts-ignore + const formState = getFormStateFromJobConfig(config, false); + + if (typeof jobId === 'string' && jobId.trim() !== '') { + formState.jobId = jobId; + } + + formState.jobIdExists = jobIds.some((id) => formState.jobId === id); + formState.jobIdEmpty = jobId === ''; + formState.jobIdValid = isJobIdValid(jobId); + formState.jobIdInvalidMaxLength = !!maxLengthValidator(JOB_ID_MAX_LENGTH)(jobId); + + formState.destinationIndexNameEmpty = formState.destinationIndex === ''; + formState.destinationIndexNameValid = isValidIndexName(formState.destinationIndex || ''); + formState.destinationIndexPatternTitleExists = + state.indexPatternsMap[formState.destinationIndex || ''] !== undefined; + + if (formState.numTopFeatureImportanceValues !== undefined) { + formState.numTopFeatureImportanceValuesValid = validateNumTopFeatureImportanceValues( + formState.numTopFeatureImportanceValues + ); + } + + return validateForm({ + ...state, + // @ts-ignore + form: formState, + isAdvancedEditorEnabled: false, + advancedEditorRawString: JSON.stringify(config, null, 2), + jobConfig: config, + }); + case ACTION.SET_ESTIMATED_MODEL_MEMORY_LIMIT: return { ...state, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts index d397dfc315da4..499318ebddc19 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.test.ts @@ -4,11 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - getCloneFormStateFromJobConfig, - getInitialState, - getJobConfigFromFormState, -} from './state'; +import { getFormStateFromJobConfig, getInitialState, getJobConfigFromFormState } from './state'; const regJobConfig = { id: 'reg-test-01', @@ -96,8 +92,8 @@ describe('useCreateAnalyticsForm', () => { ]); }); - test('state: getCloneFormStateFromJobConfig() regression', () => { - const clonedState = getCloneFormStateFromJobConfig(regJobConfig); + test('state: getFormStateFromJobConfig() regression', () => { + const clonedState = getFormStateFromJobConfig(regJobConfig); expect(clonedState?.sourceIndex).toBe('reg-test-index'); expect(clonedState?.includes).toStrictEqual([]); @@ -112,8 +108,8 @@ describe('useCreateAnalyticsForm', () => { expect(clonedState?.jobId).toBe(undefined); }); - test('state: getCloneFormStateFromJobConfig() outlier detection', () => { - const clonedState = getCloneFormStateFromJobConfig(outlierJobConfig); + test('state: getFormStateFromJobConfig() outlier detection', () => { + const clonedState = getFormStateFromJobConfig(outlierJobConfig); expect(clonedState?.sourceIndex).toBe('outlier-test-index'); expect(clonedState?.includes).toStrictEqual(['field', 'other_field']); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 725fc8751408e..69599f43ef297 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -12,6 +12,7 @@ import { DataFrameAnalyticsId, DataFrameAnalyticsConfig, ANALYSIS_CONFIG_TYPE, + defaultSearchQuery, } from '../../../../common/analytics'; import { CloneDataFrameAnalyticsConfig } from '../../components/action_clone'; @@ -44,6 +45,7 @@ export interface FormMessage { export interface State { advancedEditorMessages: FormMessage[]; advancedEditorRawString: string; + disableSwitchToForm: boolean; form: { computeFeatureInfluence: string; createIndexPattern: boolean; @@ -97,6 +99,7 @@ export interface State { indexPatternsMap: SourceIndexMap; isAdvancedEditorEnabled: boolean; isAdvancedEditorValidJson: boolean; + hasSwitchedToEditor: boolean; isJobCreated: boolean; isJobStarted: boolean; isValid: boolean; @@ -110,6 +113,7 @@ export interface State { export const getInitialState = (): State => ({ advancedEditorMessages: [], advancedEditorRawString: '', + disableSwitchToForm: false, form: { computeFeatureInfluence: 'true', createIndexPattern: true, @@ -131,7 +135,7 @@ export const getInitialState = (): State => ({ jobIdInvalidMaxLength: false, jobIdValid: false, jobType: undefined, - jobConfigQuery: { match_all: {} }, + jobConfigQuery: defaultSearchQuery, jobConfigQueryString: undefined, lambda: undefined, loadingFieldOptions: false, @@ -167,6 +171,7 @@ export const getInitialState = (): State => ({ indexPatternsMap: {}, isAdvancedEditorEnabled: false, isAdvancedEditorValidJson: true, + hasSwitchedToEditor: false, isJobCreated: false, isJobStarted: false, isValid: false, @@ -283,8 +288,9 @@ function toCamelCase(property: string): string { * Extracts form state for a job clone from the analytics job configuration. * For cloning we keep job id and destination index empty. */ -export function getCloneFormStateFromJobConfig( - analyticsJobConfig: Readonly +export function getFormStateFromJobConfig( + analyticsJobConfig: Readonly, + isClone: boolean = true ): Partial { const jobType = Object.keys(analyticsJobConfig.analysis)[0] as ANALYSIS_CONFIG_TYPE; @@ -300,6 +306,10 @@ export function getCloneFormStateFromJobConfig( includes: analyticsJobConfig.analyzed_fields.includes, }; + if (isClone === false) { + resultState.destinationIndex = analyticsJobConfig?.dest.index ?? ''; + } + const analysisConfig = analyticsJobConfig.analysis[jobType]; for (const key in analysisConfig) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts index 035610684d556..9612b9213d120 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts @@ -28,7 +28,7 @@ import { FormMessage, State, SourceIndexMap, - getCloneFormStateFromJobConfig, + getFormStateFromJobConfig, } from './state'; import { ANALYTICS_STEPS } from '../../../analytics_creation/page'; @@ -283,6 +283,10 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { dispatch({ type: ACTION.SWITCH_TO_ADVANCED_EDITOR }); }; + const switchToForm = () => { + dispatch({ type: ACTION.SWITCH_TO_FORM }); + }; + const setEstimatedModelMemoryLimit = (value: State['estimatedModelMemoryLimit']) => { dispatch({ type: ACTION.SET_ESTIMATED_MODEL_MEMORY_LIMIT, value }); }; @@ -294,7 +298,7 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { setJobConfig(config); switchToAdvancedEditor(); } else { - setFormState(getCloneFormStateFromJobConfig(config)); + setFormState(getFormStateFromJobConfig(config)); setEstimatedModelMemoryLimit(config.model_memory_limit); } @@ -311,6 +315,7 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { setJobConfig, startAnalyticsJob, switchToAdvancedEditor, + switchToForm, setEstimatedModelMemoryLimit, setJobClone, }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c81aade2b063e..25d37334d0b82 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11084,7 +11084,6 @@ "xpack.ml.dataframe.analytics.create.detailsDetails.editButtonText": "編集", "xpack.ml.dataframe.analytics.create.duplicateIndexPatternErrorMessage": "Kibanaインデックスパターンの作成中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.duplicateIndexPatternErrorMessageError": "インデックスパターン{indexPatternName}はすでに作成されています。", - "xpack.ml.dataframe.analytics.create.enableJsonEditorHelpText": "JSONエディターからこのフォームには戻れません。", "xpack.ml.dataframe.analytics.create.errorCreatingDataFrameAnalyticsJob": "データフレーム分析ジョブの作成中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.errorGettingDataFrameAnalyticsList": "既存のデータフレーム分析ジョブIDの取得中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.errorGettingIndexPatternTitles": "既存のインデックスパターンのタイトルの取得中にエラーが発生しました。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index aba5adf72c2f8..b3886ffb1ecb1 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11086,7 +11086,6 @@ "xpack.ml.dataframe.analytics.create.detailsDetails.editButtonText": "编辑", "xpack.ml.dataframe.analytics.create.duplicateIndexPatternErrorMessage": "创建 Kibana 索引模式时发生错误:", "xpack.ml.dataframe.analytics.create.duplicateIndexPatternErrorMessageError": "索引模式 {indexPatternName} 已存在。", - "xpack.ml.dataframe.analytics.create.enableJsonEditorHelpText": "您不能从 json 编辑器切回到此表单。", "xpack.ml.dataframe.analytics.create.errorCreatingDataFrameAnalyticsJob": "创建数据帧分析作业时发生错误:", "xpack.ml.dataframe.analytics.create.errorGettingDataFrameAnalyticsList": "获取现有数据帧分析作业 ID 时发生错误:", "xpack.ml.dataframe.analytics.create.errorGettingIndexPatternTitles": "获取现有索引模式标题时发生错误:", From 147d4c7ad0afe83143faa22136aa56a821ef57e6 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Fri, 31 Jul 2020 08:52:27 -0600 Subject: [PATCH 09/29] [SIEM] Fixes a bug where invalid regular expressions within the index patterns can cause UI toaster errors (#73754) ## Summary https://github.com/elastic/kibana/issues/49753 When you have no data you get a toaster error when we don't want a toaster error. Before with the toaster error: ![error](https://user-images.githubusercontent.com/1151048/88860918-0e2a5900-d1ba-11ea-95e7-5ed7324fc831.png) After: You don't get an error toaster because I catch any regular expression errors and do not report them up to the UI. ### Checklist - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios --- .../server/utils/beat_schema/index.test.ts | 7 +++++++ .../server/utils/beat_schema/index.ts | 14 ++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/server/utils/beat_schema/index.test.ts b/x-pack/plugins/security_solution/server/utils/beat_schema/index.test.ts index 56ceca2b70e9c..5f002aa7fad7b 100644 --- a/x-pack/plugins/security_solution/server/utils/beat_schema/index.test.ts +++ b/x-pack/plugins/security_solution/server/utils/beat_schema/index.test.ts @@ -401,10 +401,17 @@ describe('Schema Beat', () => { const result = getIndexAlias([leadingWildcardIndex], leadingWildcardIndex); expect(result).toBe(leadingWildcardIndex); }); + test('getIndexAlias no match returns "unknown" string', () => { const index = 'auditbeat-*'; const result = getIndexAlias([index], 'hello'); expect(result).toBe('unknown'); }); + + test('empty index should not cause an error to return although it will cause an invalid regular expression to occur', () => { + const index = ''; + const result = getIndexAlias([index], 'hello'); + expect(result).toBe('unknown'); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/utils/beat_schema/index.ts b/x-pack/plugins/security_solution/server/utils/beat_schema/index.ts index ff7331cf39bc7..6ec15d328714d 100644 --- a/x-pack/plugins/security_solution/server/utils/beat_schema/index.ts +++ b/x-pack/plugins/security_solution/server/utils/beat_schema/index.ts @@ -77,10 +77,16 @@ const convertFieldsToAssociativeArray = ( : {}; export const getIndexAlias = (defaultIndex: string[], indexName: string): string => { - const found = defaultIndex.find((index) => `\\${indexName}`.match(`\\${index}`) != null); - if (found != null) { - return found; - } else { + try { + const found = defaultIndex.find((index) => `\\${indexName}`.match(`\\${index}`) != null); + if (found != null) { + return found; + } else { + return 'unknown'; + } + } catch (error) { + // if we encounter an error because the index contains invalid regular expressions then we should return an unknown + // rather than blow up with a toaster error upstream return 'unknown'; } }; From 61194b65aaa2f04bc0600171eeecc2bc56985640 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Fri, 31 Jul 2020 11:04:41 -0400 Subject: [PATCH 10/29] [Uptime] Unskip certs func tests (#73086) * Temporarily unload all other functional tests. * Unskip certificates test. * Uncomment skipped test files. * Add explicit fields to check generator call to prevent grouping issues that can lead to test flakiness. * added wait for loading * update missing func Co-authored-by: Elastic Machine Co-authored-by: Shahzad --- x-pack/test/functional/apps/uptime/certificates.ts | 12 ++++++++++-- x-pack/test/functional/apps/uptime/index.ts | 1 + .../test/functional/services/uptime/certificates.ts | 5 ++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/x-pack/test/functional/apps/uptime/certificates.ts b/x-pack/test/functional/apps/uptime/certificates.ts index ccf35a1e63e37..7e9a2cd85935e 100644 --- a/x-pack/test/functional/apps/uptime/certificates.ts +++ b/x-pack/test/functional/apps/uptime/certificates.ts @@ -14,8 +14,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const es = getService('es'); - // Failing: See https://github.com/elastic/kibana/issues/70493 - describe.skip('certificates', function () { + describe('certificates', function () { before(async () => { await makeCheck({ es, tls: true }); await uptime.goToRoot(true); @@ -58,6 +57,15 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const certId = getSha256(); const { monitorId } = await makeCheck({ es, + monitorId: 'cert-test-check-id', + fields: { + monitor: { + name: 'Cert Test Check', + }, + url: { + full: 'https://site-to-check.com/', + }, + }, tls: { sha256: certId, }, diff --git a/x-pack/test/functional/apps/uptime/index.ts b/x-pack/test/functional/apps/uptime/index.ts index 028ab3ff8803a..6b2b61cba2b64 100644 --- a/x-pack/test/functional/apps/uptime/index.ts +++ b/x-pack/test/functional/apps/uptime/index.ts @@ -55,6 +55,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => { loadTestFile(require.resolve('./settings')); loadTestFile(require.resolve('./certificates')); }); + describe('with real-world data', () => { before(async () => { await esArchiver.unload(ARCHIVE); diff --git a/x-pack/test/functional/services/uptime/certificates.ts b/x-pack/test/functional/services/uptime/certificates.ts index 2ceab1ca89e54..06de9be5af7e9 100644 --- a/x-pack/test/functional/services/uptime/certificates.ts +++ b/x-pack/test/functional/services/uptime/certificates.ts @@ -7,10 +7,12 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; -export function UptimeCertProvider({ getService }: FtrProviderContext) { +export function UptimeCertProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const retry = getService('retry'); + const PageObjects = getPageObjects(['common', 'timePicker', 'header']); + const changeSearchField = async (text: string) => { const input = await testSubjects.find('uptimeCertSearch'); await input.clearValueWithKeyboard(); @@ -61,6 +63,7 @@ export function UptimeCertProvider({ getService }: FtrProviderContext) { const self = this; return retry.tryForTime(60 * 1000, async () => { await changeSearchField(monId); + await PageObjects.header.waitUntilLoadingHasFinished(); await self.hasCertificates(1); }); }, From a1872b1a7029d871bd72cf29e5edac7cadc3e25a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Istv=C3=A1n=20Zolt=C3=A1n=20Szab=C3=B3?= Date: Fri, 31 Jul 2020 17:11:01 +0200 Subject: [PATCH 11/29] [ML] Removes link from helper text on ML overview page (#73819) --- .../overview/components/sidebar.tsx | 20 +------------------ .../translations/translations/ja-JP.json | 2 -- .../translations/translations/zh-CN.json | 2 -- 3 files changed, 1 insertion(+), 23 deletions(-) diff --git a/x-pack/plugins/ml/public/application/overview/components/sidebar.tsx b/x-pack/plugins/ml/public/application/overview/components/sidebar.tsx index 119346ec8035a..903a3c467a38b 100644 --- a/x-pack/plugins/ml/public/application/overview/components/sidebar.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/sidebar.tsx @@ -9,29 +9,12 @@ import { EuiFlexItem, EuiLink, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui import { FormattedMessage } from '@kbn/i18n/react'; import { useMlKibana } from '../../contexts/kibana'; -const createJobLink = '#/jobs/new_job/step/index_or_search'; const feedbackLink = 'https://www.elastic.co/community/'; interface Props { createAnomalyDetectionJobDisabled: boolean; } -function getCreateJobLink(createAnomalyDetectionJobDisabled: boolean) { - return createAnomalyDetectionJobDisabled === true ? ( - - ) : ( - - - - ); -} - export const OverviewSideBar: FC = ({ createAnomalyDetectionJobDisabled }) => { const { services: { @@ -59,7 +42,7 @@ export const OverviewSideBar: FC = ({ createAnomalyDetectionJobDisabled }

@@ -69,7 +52,6 @@ export const OverviewSideBar: FC = ({ createAnomalyDetectionJobDisabled } /> ), - createJob: getCreateJobLink(createAnomalyDetectionJobDisabled), transforms: ( Date: Fri, 31 Jul 2020 11:14:00 -0400 Subject: [PATCH 12/29] moved config option for allowing or disallowing by value embeddables to dashboard plugin (#73870) --- src/plugins/dashboard/config.ts | 26 +++++++++++++++++++++++++ src/plugins/dashboard/public/plugin.tsx | 6 ++++++ src/plugins/dashboard/server/index.ts | 10 +++++++++- 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 src/plugins/dashboard/config.ts diff --git a/src/plugins/dashboard/config.ts b/src/plugins/dashboard/config.ts new file mode 100644 index 0000000000000..ff968a51679e0 --- /dev/null +++ b/src/plugins/dashboard/config.ts @@ -0,0 +1,26 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; + +export const configSchema = schema.object({ + allowByValueEmbeddables: schema.boolean({ defaultValue: false }), +}); + +export type ConfigSchema = TypeOf; diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index f0b57fec169fd..f1319665d258b 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -94,6 +94,10 @@ declare module '../../share/public' { export type DashboardUrlGenerator = UrlGeneratorContract; +interface DashboardFeatureFlagConfig { + allowByValueEmbeddables: boolean; +} + interface SetupDependencies { data: DataPublicPluginSetup; embeddable: EmbeddableSetup; @@ -125,6 +129,7 @@ export interface DashboardStart { embeddableType: string; }) => void | undefined; dashboardUrlGenerator?: DashboardUrlGenerator; + dashboardFeatureFlagConfig: DashboardFeatureFlagConfig; DashboardContainerByValueRenderer: ReturnType; } @@ -411,6 +416,7 @@ export class DashboardPlugin getSavedDashboardLoader: () => savedDashboardLoader, addEmbeddableToDashboard: this.addEmbeddableToDashboard.bind(this, core), dashboardUrlGenerator: this.dashboardUrlGenerator, + dashboardFeatureFlagConfig: this.initializerContext.config.get(), DashboardContainerByValueRenderer: createDashboardContainerByValueRenderer({ factory: dashboardContainerFactory, }), diff --git a/src/plugins/dashboard/server/index.ts b/src/plugins/dashboard/server/index.ts index 9719586001c59..3ef7abba5776b 100644 --- a/src/plugins/dashboard/server/index.ts +++ b/src/plugins/dashboard/server/index.ts @@ -17,8 +17,16 @@ * under the License. */ -import { PluginInitializerContext } from '../../../core/server'; +import { PluginInitializerContext, PluginConfigDescriptor } from '../../../core/server'; import { DashboardPlugin } from './plugin'; +import { configSchema, ConfigSchema } from '../config'; + +export const config: PluginConfigDescriptor = { + exposeToBrowser: { + allowByValueEmbeddables: true, + }, + schema: configSchema, +}; // This exports static code and TypeScript types, // as well as, Kibana Platform `plugin()` initializer. From 10cc600d5cdc9239ce174683b9fee6c3b22c8f4c Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Fri, 31 Jul 2020 09:19:43 -0600 Subject: [PATCH 13/29] Fix aborted$ event and add completed$ event to KibanaRequest (#73898) --- ...e-server.kibanarequestevents.completed_.md | 18 ++++ ...-plugin-core-server.kibanarequestevents.md | 1 + .../http/integration_tests/request.test.ts | 91 +++++++++++++++++++ src/core/server/http/router/request.ts | 19 +++- src/core/server/server.api.md | 1 + 5 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.kibanarequestevents.completed_.md diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanarequestevents.completed_.md b/docs/development/core/server/kibana-plugin-core-server.kibanarequestevents.completed_.md new file mode 100644 index 0000000000000..c9f8ab11f6b12 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.kibanarequestevents.completed_.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaRequestEvents](./kibana-plugin-core-server.kibanarequestevents.md) > [completed$](./kibana-plugin-core-server.kibanarequestevents.completed_.md) + +## KibanaRequestEvents.completed$ property + +Observable that emits once if and when the request has been completely handled. + +Signature: + +```typescript +completed$: Observable; +``` + +## Remarks + +The request may be considered completed if: - A response has been sent to the client; or - The request was aborted. + diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanarequestevents.md b/docs/development/core/server/kibana-plugin-core-server.kibanarequestevents.md index 21826c8b29383..dfd7efd27cb5a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.kibanarequestevents.md +++ b/docs/development/core/server/kibana-plugin-core-server.kibanarequestevents.md @@ -17,4 +17,5 @@ export interface KibanaRequestEvents | Property | Type | Description | | --- | --- | --- | | [aborted$](./kibana-plugin-core-server.kibanarequestevents.aborted_.md) | Observable<void> | Observable that emits once if and when the request has been aborted. | +| [completed$](./kibana-plugin-core-server.kibanarequestevents.completed_.md) | Observable<void> | Observable that emits once if and when the request has been completely handled. | diff --git a/src/core/server/http/integration_tests/request.test.ts b/src/core/server/http/integration_tests/request.test.ts index 2d018f7f464b5..3a7335583296e 100644 --- a/src/core/server/http/integration_tests/request.test.ts +++ b/src/core/server/http/integration_tests/request.test.ts @@ -23,6 +23,7 @@ import { HttpService } from '../http_service'; import { contextServiceMock } from '../../context/context_service.mock'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { createHttpServer } from '../test_utils'; +import { schema } from '@kbn/config-schema'; let server: HttpService; @@ -195,6 +196,96 @@ describe('KibanaRequest', () => { expect(nextSpy).toHaveBeenCalledTimes(0); expect(completeSpy).toHaveBeenCalledTimes(1); }); + + it('does not complete before response has been sent', async () => { + const { server: innerServer, createRouter, registerOnPreAuth } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + const nextSpy = jest.fn(); + const completeSpy = jest.fn(); + + registerOnPreAuth((req, res, toolkit) => { + req.events.aborted$.subscribe({ + next: nextSpy, + complete: completeSpy, + }); + return toolkit.next(); + }); + + router.post( + { path: '/', validate: { body: schema.any() } }, + async (context, request, res) => { + expect(completeSpy).not.toHaveBeenCalled(); + return res.ok({ body: 'ok' }); + } + ); + + await server.start(); + + await supertest(innerServer.listener).post('/').send({ data: 'test' }).expect(200); + + expect(nextSpy).toHaveBeenCalledTimes(0); + expect(completeSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('completed$', () => { + it('emits once and completes when response is sent', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + const nextSpy = jest.fn(); + const completeSpy = jest.fn(); + + router.get({ path: '/', validate: false }, async (context, req, res) => { + req.events.completed$.subscribe({ + next: nextSpy, + complete: completeSpy, + }); + + expect(nextSpy).not.toHaveBeenCalled(); + expect(completeSpy).not.toHaveBeenCalled(); + return res.ok({ body: 'ok' }); + }); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200); + expect(nextSpy).toHaveBeenCalledTimes(1); + expect(completeSpy).toHaveBeenCalledTimes(1); + }); + + it('emits once and completes when response is aborted', async (done) => { + expect.assertions(2); + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + const nextSpy = jest.fn(); + + router.get({ path: '/', validate: false }, async (context, req, res) => { + req.events.completed$.subscribe({ + next: nextSpy, + complete: () => { + expect(nextSpy).toHaveBeenCalledTimes(1); + done(); + }, + }); + + expect(nextSpy).not.toHaveBeenCalled(); + await delay(30000); + return res.ok({ body: 'ok' }); + }); + + await server.start(); + + const incomingRequest = supertest(innerServer.listener) + .get('/') + // end required to send request + .end(); + setTimeout(() => incomingRequest.abort(), 50); + }); }); }); }); diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts index 0e73431fe7c6d..93ffb5aa48259 100644 --- a/src/core/server/http/router/request.ts +++ b/src/core/server/http/router/request.ts @@ -64,6 +64,16 @@ export interface KibanaRequestEvents { * Observable that emits once if and when the request has been aborted. */ aborted$: Observable; + + /** + * Observable that emits once if and when the request has been completely handled. + * + * @remarks + * The request may be considered completed if: + * - A response has been sent to the client; or + * - The request was aborted. + */ + completed$: Observable; } /** @@ -186,11 +196,16 @@ export class KibanaRequest< private getEvents(request: Request): KibanaRequestEvents { const finish$ = merge( - fromEvent(request.raw.req, 'end'), // all data consumed + fromEvent(request.raw.res, 'finish'), // Response has been sent fromEvent(request.raw.req, 'close') // connection was closed ).pipe(shareReplay(1), first()); + + const aborted$ = fromEvent(request.raw.req, 'aborted').pipe(first(), takeUntil(finish$)); + const completed$ = merge(finish$, aborted$).pipe(shareReplay(1), first()); + return { - aborted$: fromEvent(request.raw.req, 'aborted').pipe(first(), takeUntil(finish$)), + aborted$, + completed$, } as const; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index c1054c27d084e..21ef66230f698 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1071,6 +1071,7 @@ export class KibanaRequest; + completed$: Observable; } // @public From 708a30abb329355a9df7fab2f99938a2fd0c6654 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Fri, 31 Jul 2020 11:39:12 -0400 Subject: [PATCH 14/29] [Canvas][tech-debt] Update Redux components to reflect new structure (#73844) Co-authored-by: Elastic Machine --- .../embeddable_flyout/flyout.component.tsx | 78 ++++++ .../components/embeddable_flyout/flyout.tsx | 160 +++++++----- .../components/embeddable_flyout/index.ts | 11 + .../components/embeddable_flyout/index.tsx | 113 -------- .../public/components/page_manager/index.ts | 27 +- ...manager.tsx => page_manager.component.tsx} | 0 .../components/page_manager/page_manager.ts | 31 +++ .../public/components/page_preview/index.ts | 20 +- ...preview.tsx => page_preview.component.tsx} | 0 .../components/page_preview/page_preview.ts | 24 ++ .../saved_elements_modal.stories.tsx | 2 +- .../components/saved_elements_modal/index.ts | 132 +--------- ...tsx => saved_elements_modal.component.tsx} | 0 .../saved_elements_modal.ts | 136 ++++++++++ .../element_settings.component.tsx | 55 ++++ .../element_settings/element_settings.tsx | 63 ++--- .../sidebar/element_settings/index.ts | 8 + .../sidebar/element_settings/index.tsx | 34 --- .../components/workpad_color_picker/index.ts | 19 +- ...tsx => workpad_color_picker.component.tsx} | 0 .../workpad_color_picker.ts | 23 ++ .../public/components/workpad_config/index.ts | 39 +-- ...onfig.tsx => workpad_config.component.tsx} | 0 .../workpad_config/workpad_config.ts | 43 ++++ .../__examples__/edit_menu.stories.tsx | 2 +- ...{edit_menu.tsx => edit_menu.component.tsx} | 0 .../workpad_header/edit_menu/edit_menu.ts | 133 ++++++++++ .../workpad_header/edit_menu/index.ts | 129 +--------- .../__examples__/element_menu.stories.tsx | 2 +- .../element_menu/element_menu.component.tsx | 214 ++++++++++++++++ .../element_menu/element_menu.tsx | 241 +++--------------- .../workpad_header/element_menu/index.ts | 8 + .../workpad_header/element_menu/index.tsx | 47 ---- .../public/components/workpad_header/index.ts | 8 + .../components/workpad_header/index.tsx | 46 ---- .../workpad_header/refresh_control/index.ts | 18 +- ...trol.tsx => refresh_control.component.tsx} | 0 .../refresh_control/refresh_control.ts | 22 ++ .../__examples__/share_menu.stories.tsx | 2 +- ..._flyout.stories.tsx => flyout.stories.tsx} | 2 +- ...ebsite_flyout.tsx => flyout.component.tsx} | 2 +- .../share_menu/flyout/flyout.ts | 101 ++++++++ .../workpad_header/share_menu/flyout/index.ts | 94 +------ .../share_menu/flyout/runtime_step.tsx | 2 +- .../share_menu/flyout/snippets_step.tsx | 2 +- .../share_menu/flyout/workpad_step.tsx | 2 +- .../workpad_header/share_menu/index.ts | 94 +------ ...hare_menu.tsx => share_menu.component.tsx} | 0 .../workpad_header/share_menu/share_menu.ts | 98 +++++++ .../__examples__/view_menu.stories.tsx | 2 +- .../workpad_header/view_menu/index.ts | 96 +------ ...{view_menu.tsx => view_menu.component.tsx} | 0 .../workpad_header/view_menu/view_menu.ts | 100 ++++++++ .../workpad_header.component.tsx | 150 +++++++++++ .../workpad_header/workpad_header.tsx | 174 +++---------- .../canvas/storybook/storyshots.test.js | 2 +- 56 files changed, 1466 insertions(+), 1345 deletions(-) create mode 100644 x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/embeddable_flyout/index.ts delete mode 100644 x-pack/plugins/canvas/public/components/embeddable_flyout/index.tsx rename x-pack/plugins/canvas/public/components/page_manager/{page_manager.tsx => page_manager.component.tsx} (100%) create mode 100644 x-pack/plugins/canvas/public/components/page_manager/page_manager.ts rename x-pack/plugins/canvas/public/components/page_preview/{page_preview.tsx => page_preview.component.tsx} (100%) create mode 100644 x-pack/plugins/canvas/public/components/page_preview/page_preview.ts rename x-pack/plugins/canvas/public/components/saved_elements_modal/{saved_elements_modal.tsx => saved_elements_modal.component.tsx} (100%) create mode 100644 x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.ts create mode 100644 x-pack/plugins/canvas/public/components/sidebar/element_settings/element_settings.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/sidebar/element_settings/index.ts delete mode 100644 x-pack/plugins/canvas/public/components/sidebar/element_settings/index.tsx rename x-pack/plugins/canvas/public/components/workpad_color_picker/{workpad_color_picker.tsx => workpad_color_picker.component.tsx} (100%) create mode 100644 x-pack/plugins/canvas/public/components/workpad_color_picker/workpad_color_picker.ts rename x-pack/plugins/canvas/public/components/workpad_config/{workpad_config.tsx => workpad_config.component.tsx} (100%) create mode 100644 x-pack/plugins/canvas/public/components/workpad_config/workpad_config.ts rename x-pack/plugins/canvas/public/components/workpad_header/edit_menu/{edit_menu.tsx => edit_menu.component.tsx} (100%) create mode 100644 x-pack/plugins/canvas/public/components/workpad_header/edit_menu/edit_menu.ts create mode 100644 x-pack/plugins/canvas/public/components/workpad_header/element_menu/element_menu.component.tsx create mode 100644 x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.ts delete mode 100644 x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.tsx create mode 100644 x-pack/plugins/canvas/public/components/workpad_header/index.ts delete mode 100644 x-pack/plugins/canvas/public/components/workpad_header/index.tsx rename x-pack/plugins/canvas/public/components/workpad_header/refresh_control/{refresh_control.tsx => refresh_control.component.tsx} (100%) create mode 100644 x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.ts rename x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/__examples__/{share_website_flyout.stories.tsx => flyout.stories.tsx} (94%) rename x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/{share_website_flyout.tsx => flyout.component.tsx} (98%) create mode 100644 x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/flyout.ts rename x-pack/plugins/canvas/public/components/workpad_header/share_menu/{share_menu.tsx => share_menu.component.tsx} (100%) create mode 100644 x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts rename x-pack/plugins/canvas/public/components/workpad_header/view_menu/{view_menu.tsx => view_menu.component.tsx} (100%) create mode 100644 x-pack/plugins/canvas/public/components/workpad_header/view_menu/view_menu.ts create mode 100644 x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx diff --git a/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.component.tsx b/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.component.tsx new file mode 100644 index 0000000000000..0b5bd8adf8cb9 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.component.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiFlyout, EuiFlyoutHeader, EuiFlyoutBody, EuiTitle } from '@elastic/eui'; +import { + SavedObjectFinderUi, + SavedObjectMetaData, +} from '../../../../../../src/plugins/saved_objects/public/'; +import { ComponentStrings } from '../../../i18n'; +import { useServices } from '../../services'; + +const { AddEmbeddableFlyout: strings } = ComponentStrings; + +export interface Props { + onClose: () => void; + onSelect: (id: string, embeddableType: string) => void; + availableEmbeddables: string[]; +} + +export const AddEmbeddableFlyout: FC = ({ onSelect, availableEmbeddables, onClose }) => { + const services = useServices(); + const { embeddables, platform } = services; + const { getEmbeddableFactories } = embeddables; + const { getSavedObjects, getUISettings } = platform; + + const onAddPanel = (id: string, savedObjectType: string, name: string) => { + const embeddableFactories = getEmbeddableFactories(); + + // Find the embeddable type from the saved object type + const found = Array.from(embeddableFactories).find((embeddableFactory) => { + return Boolean( + embeddableFactory.savedObjectMetaData && + embeddableFactory.savedObjectMetaData.type === savedObjectType + ); + }); + + const foundEmbeddableType = found ? found.type : 'unknown'; + + onSelect(id, foundEmbeddableType); + }; + + const embeddableFactories = getEmbeddableFactories(); + + const availableSavedObjects = Array.from(embeddableFactories) + .filter((factory) => { + return availableEmbeddables.includes(factory.type); + }) + .map((factory) => factory.savedObjectMetaData) + .filter>(function ( + maybeSavedObjectMetaData + ): maybeSavedObjectMetaData is SavedObjectMetaData<{}> { + return maybeSavedObjectMetaData !== undefined; + }); + + return ( + + + +

{strings.getTitleText()}

+ + + + + + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.tsx b/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.tsx index 0b5bd8adf8cb9..8c84e3d7a85d8 100644 --- a/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.tsx +++ b/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.tsx @@ -4,75 +4,107 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC } from 'react'; -import { EuiFlyout, EuiFlyoutHeader, EuiFlyoutBody, EuiTitle } from '@elastic/eui'; -import { - SavedObjectFinderUi, - SavedObjectMetaData, -} from '../../../../../../src/plugins/saved_objects/public/'; -import { ComponentStrings } from '../../../i18n'; -import { useServices } from '../../services'; - -const { AddEmbeddableFlyout: strings } = ComponentStrings; - -export interface Props { - onClose: () => void; - onSelect: (id: string, embeddableType: string) => void; - availableEmbeddables: string[]; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { compose } from 'recompose'; +import { connect } from 'react-redux'; +import { Dispatch } from 'redux'; +import { AddEmbeddableFlyout as Component, Props as ComponentProps } from './flyout.component'; +// @ts-expect-error untyped local +import { addElement } from '../../state/actions/elements'; +import { getSelectedPage } from '../../state/selectors/workpad'; +import { EmbeddableTypes } from '../../../canvas_plugin_src/expression_types/embeddable'; + +const allowedEmbeddables = { + [EmbeddableTypes.map]: (id: string) => { + return `savedMap id="${id}" | render`; + }, + [EmbeddableTypes.lens]: (id: string) => { + return `savedLens id="${id}" | render`; + }, + [EmbeddableTypes.visualization]: (id: string) => { + return `savedVisualization id="${id}" | render`; + }, + /* + [EmbeddableTypes.search]: (id: string) => { + return `filters | savedSearch id="${id}" | render`; + },*/ +}; + +interface StateProps { + pageId: string; +} + +interface DispatchProps { + addEmbeddable: (pageId: string, partialElement: { expression: string }) => void; } -export const AddEmbeddableFlyout: FC = ({ onSelect, availableEmbeddables, onClose }) => { - const services = useServices(); - const { embeddables, platform } = services; - const { getEmbeddableFactories } = embeddables; - const { getSavedObjects, getUISettings } = platform; +// FIX: Missing state type +const mapStateToProps = (state: any) => ({ pageId: getSelectedPage(state) }); - const onAddPanel = (id: string, savedObjectType: string, name: string) => { - const embeddableFactories = getEmbeddableFactories(); +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + addEmbeddable: (pageId, partialElement): DispatchProps['addEmbeddable'] => + dispatch(addElement(pageId, partialElement)), +}); - // Find the embeddable type from the saved object type - const found = Array.from(embeddableFactories).find((embeddableFactory) => { - return Boolean( - embeddableFactory.savedObjectMetaData && - embeddableFactory.savedObjectMetaData.type === savedObjectType - ); - }); +const mergeProps = ( + stateProps: StateProps, + dispatchProps: DispatchProps, + ownProps: ComponentProps +): ComponentProps => { + const { pageId, ...remainingStateProps } = stateProps; + const { addEmbeddable } = dispatchProps; - const foundEmbeddableType = found ? found.type : 'unknown'; + return { + ...remainingStateProps, + ...ownProps, + onSelect: (id: string, type: string): void => { + const partialElement = { + expression: `markdown "Could not find embeddable for type ${type}" | render`, + }; + if (allowedEmbeddables[type]) { + partialElement.expression = allowedEmbeddables[type](id); + } - onSelect(id, foundEmbeddableType); + addEmbeddable(pageId, partialElement); + ownProps.onClose(); + }, }; - - const embeddableFactories = getEmbeddableFactories(); - - const availableSavedObjects = Array.from(embeddableFactories) - .filter((factory) => { - return availableEmbeddables.includes(factory.type); - }) - .map((factory) => factory.savedObjectMetaData) - .filter>(function ( - maybeSavedObjectMetaData - ): maybeSavedObjectMetaData is SavedObjectMetaData<{}> { - return maybeSavedObjectMetaData !== undefined; - }); - - return ( - - - -

{strings.getTitleText()}

-
-
- - - -
- ); }; + +export class EmbeddableFlyoutPortal extends React.Component { + el?: HTMLElement; + + constructor(props: ComponentProps) { + super(props); + + this.el = document.createElement('div'); + } + componentDidMount() { + const body = document.querySelector('body'); + if (body && this.el) { + body.appendChild(this.el); + } + } + + componentWillUnmount() { + const body = document.querySelector('body'); + + if (body && this.el) { + body.removeChild(this.el); + } + } + + render() { + if (this.el) { + return ReactDOM.createPortal( + , + this.el + ); + } + } +} + +export const AddEmbeddablePanel = compose void }>( + connect(mapStateToProps, mapDispatchToProps, mergeProps) +)(EmbeddableFlyoutPortal); diff --git a/x-pack/plugins/canvas/public/components/embeddable_flyout/index.ts b/x-pack/plugins/canvas/public/components/embeddable_flyout/index.ts new file mode 100644 index 0000000000000..a7fac10b0c02d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/embeddable_flyout/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EmbeddableFlyoutPortal, AddEmbeddablePanel } from './flyout'; +export { + AddEmbeddableFlyout as AddEmbeddableFlyoutComponent, + Props as AddEmbeddableFlyoutComponentProps, +} from './flyout.component'; diff --git a/x-pack/plugins/canvas/public/components/embeddable_flyout/index.tsx b/x-pack/plugins/canvas/public/components/embeddable_flyout/index.tsx deleted file mode 100644 index 62a073daf4c59..0000000000000 --- a/x-pack/plugins/canvas/public/components/embeddable_flyout/index.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import { compose } from 'recompose'; -import { connect } from 'react-redux'; -import { Dispatch } from 'redux'; -import { AddEmbeddableFlyout, Props } from './flyout'; -// @ts-expect-error untyped local -import { addElement } from '../../state/actions/elements'; -import { getSelectedPage } from '../../state/selectors/workpad'; -import { EmbeddableTypes } from '../../../canvas_plugin_src/expression_types/embeddable'; - -const allowedEmbeddables = { - [EmbeddableTypes.map]: (id: string) => { - return `savedMap id="${id}" | render`; - }, - [EmbeddableTypes.lens]: (id: string) => { - return `savedLens id="${id}" | render`; - }, - [EmbeddableTypes.visualization]: (id: string) => { - return `savedVisualization id="${id}" | render`; - }, - /* - [EmbeddableTypes.search]: (id: string) => { - return `filters | savedSearch id="${id}" | render`; - },*/ -}; - -interface StateProps { - pageId: string; -} - -interface DispatchProps { - addEmbeddable: (pageId: string, partialElement: { expression: string }) => void; -} - -// FIX: Missing state type -const mapStateToProps = (state: any) => ({ pageId: getSelectedPage(state) }); - -const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ - addEmbeddable: (pageId, partialElement): DispatchProps['addEmbeddable'] => - dispatch(addElement(pageId, partialElement)), -}); - -const mergeProps = ( - stateProps: StateProps, - dispatchProps: DispatchProps, - ownProps: Props -): Props => { - const { pageId, ...remainingStateProps } = stateProps; - const { addEmbeddable } = dispatchProps; - - return { - ...remainingStateProps, - ...ownProps, - onSelect: (id: string, type: string): void => { - const partialElement = { - expression: `markdown "Could not find embeddable for type ${type}" | render`, - }; - if (allowedEmbeddables[type]) { - partialElement.expression = allowedEmbeddables[type](id); - } - - addEmbeddable(pageId, partialElement); - ownProps.onClose(); - }, - }; -}; - -export class EmbeddableFlyoutPortal extends React.Component { - el?: HTMLElement; - - constructor(props: Props) { - super(props); - - this.el = document.createElement('div'); - } - componentDidMount() { - const body = document.querySelector('body'); - if (body && this.el) { - body.appendChild(this.el); - } - } - - componentWillUnmount() { - const body = document.querySelector('body'); - - if (body && this.el) { - body.removeChild(this.el); - } - } - - render() { - if (this.el) { - return ReactDOM.createPortal( - , - this.el - ); - } - } -} - -export const AddEmbeddablePanel = compose void }>( - connect(mapStateToProps, mapDispatchToProps, mergeProps) -)(EmbeddableFlyoutPortal); diff --git a/x-pack/plugins/canvas/public/components/page_manager/index.ts b/x-pack/plugins/canvas/public/components/page_manager/index.ts index d19540cd6a687..abe7a4a3a5bb1 100644 --- a/x-pack/plugins/canvas/public/components/page_manager/index.ts +++ b/x-pack/plugins/canvas/public/components/page_manager/index.ts @@ -4,28 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Dispatch } from 'redux'; -import { connect } from 'react-redux'; -// @ts-expect-error untyped local -import * as pageActions from '../../state/actions/pages'; -import { canUserWrite } from '../../state/selectors/app'; -import { getSelectedPage, getWorkpad, getPages, isWriteable } from '../../state/selectors/workpad'; -import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants'; -import { PageManager as Component } from './page_manager'; -import { State } from '../../../types'; - -const mapStateToProps = (state: State) => ({ - isWriteable: isWriteable(state) && canUserWrite(state), - pages: getPages(state), - selectedPage: getSelectedPage(state), - workpadId: getWorkpad(state).id, - workpadCSS: getWorkpad(state).css || DEFAULT_WORKPAD_CSS, -}); - -const mapDispatchToProps = (dispatch: Dispatch) => ({ - onAddPage: () => dispatch(pageActions.addPage()), - onMovePage: (id: string, position: number) => dispatch(pageActions.movePage(id, position)), - onRemovePage: (id: string) => dispatch(pageActions.removePage(id)), -}); - -export const PageManager = connect(mapStateToProps, mapDispatchToProps)(Component); +export { PageManager } from './page_manager'; +export { PageManager as PageManagerComponent } from './page_manager.component'; diff --git a/x-pack/plugins/canvas/public/components/page_manager/page_manager.tsx b/x-pack/plugins/canvas/public/components/page_manager/page_manager.component.tsx similarity index 100% rename from x-pack/plugins/canvas/public/components/page_manager/page_manager.tsx rename to x-pack/plugins/canvas/public/components/page_manager/page_manager.component.tsx diff --git a/x-pack/plugins/canvas/public/components/page_manager/page_manager.ts b/x-pack/plugins/canvas/public/components/page_manager/page_manager.ts new file mode 100644 index 0000000000000..a92f7c6b4c352 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/page_manager/page_manager.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Dispatch } from 'redux'; +import { connect } from 'react-redux'; +// @ts-expect-error untyped local +import * as pageActions from '../../state/actions/pages'; +import { canUserWrite } from '../../state/selectors/app'; +import { getSelectedPage, getWorkpad, getPages, isWriteable } from '../../state/selectors/workpad'; +import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants'; +import { PageManager as Component } from './page_manager.component'; +import { State } from '../../../types'; + +const mapStateToProps = (state: State) => ({ + isWriteable: isWriteable(state) && canUserWrite(state), + pages: getPages(state), + selectedPage: getSelectedPage(state), + workpadId: getWorkpad(state).id, + workpadCSS: getWorkpad(state).css || DEFAULT_WORKPAD_CSS, +}); + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + onAddPage: () => dispatch(pageActions.addPage()), + onMovePage: (id: string, position: number) => dispatch(pageActions.movePage(id, position)), + onRemovePage: (id: string) => dispatch(pageActions.removePage(id)), +}); + +export const PageManager = connect(mapStateToProps, mapDispatchToProps)(Component); diff --git a/x-pack/plugins/canvas/public/components/page_preview/index.ts b/x-pack/plugins/canvas/public/components/page_preview/index.ts index 25d3254595d2e..22e3861eb9652 100644 --- a/x-pack/plugins/canvas/public/components/page_preview/index.ts +++ b/x-pack/plugins/canvas/public/components/page_preview/index.ts @@ -4,21 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Dispatch } from 'redux'; -import { connect } from 'react-redux'; -// @ts-expect-error untyped local -import * as pageActions from '../../state/actions/pages'; -import { canUserWrite } from '../../state/selectors/app'; -import { isWriteable } from '../../state/selectors/workpad'; -import { PagePreview as Component } from './page_preview'; -import { State } from '../../../types'; - -const mapStateToProps = (state: State) => ({ - isWriteable: isWriteable(state) && canUserWrite(state), -}); - -const mapDispatchToProps = (dispatch: Dispatch) => ({ - onDuplicate: (id: string) => dispatch(pageActions.duplicatePage(id)), -}); - -export const PagePreview = connect(mapStateToProps, mapDispatchToProps)(Component); +export { PagePreview } from './page_preview'; +export { PagePreview as PagePreviewComponent } from './page_preview.component'; diff --git a/x-pack/plugins/canvas/public/components/page_preview/page_preview.tsx b/x-pack/plugins/canvas/public/components/page_preview/page_preview.component.tsx similarity index 100% rename from x-pack/plugins/canvas/public/components/page_preview/page_preview.tsx rename to x-pack/plugins/canvas/public/components/page_preview/page_preview.component.tsx diff --git a/x-pack/plugins/canvas/public/components/page_preview/page_preview.ts b/x-pack/plugins/canvas/public/components/page_preview/page_preview.ts new file mode 100644 index 0000000000000..8768a2fc169ef --- /dev/null +++ b/x-pack/plugins/canvas/public/components/page_preview/page_preview.ts @@ -0,0 +1,24 @@ +/* + * 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 } from 'redux'; +import { connect } from 'react-redux'; +// @ts-expect-error untyped local +import * as pageActions from '../../state/actions/pages'; +import { canUserWrite } from '../../state/selectors/app'; +import { isWriteable } from '../../state/selectors/workpad'; +import { PagePreview as Component } from './page_preview.component'; +import { State } from '../../../types'; + +const mapStateToProps = (state: State) => ({ + isWriteable: isWriteable(state) && canUserWrite(state), +}); + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + onDuplicate: (id: string) => dispatch(pageActions.duplicatePage(id)), +}); + +export const PagePreview = connect(mapStateToProps, mapDispatchToProps)(Component); diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/__examples__/saved_elements_modal.stories.tsx b/x-pack/plugins/canvas/public/components/saved_elements_modal/__examples__/saved_elements_modal.stories.tsx index 4941d8cb2efa7..a811a296f2e7b 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/__examples__/saved_elements_modal.stories.tsx +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/__examples__/saved_elements_modal.stories.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; -import { SavedElementsModal } from '../saved_elements_modal'; +import { SavedElementsModal } from '../saved_elements_modal.component'; import { testCustomElements } from './fixtures/test_elements'; import { CustomElement } from '../../../../types'; diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/index.ts b/x-pack/plugins/canvas/public/components/saved_elements_modal/index.ts index da2955c146193..46faf8d14f9b5 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/index.ts +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/index.ts @@ -4,130 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { connect } from 'react-redux'; -import { Dispatch } from 'redux'; -import { compose, withState } from 'recompose'; -import { camelCase } from 'lodash'; -import { cloneSubgraphs } from '../../lib/clone_subgraphs'; -import * as customElementService from '../../lib/custom_element_service'; -import { withServices, WithServicesProps } from '../../services'; -// @ts-expect-error untyped local -import { selectToplevelNodes } from '../../state/actions/transient'; -// @ts-expect-error untyped local -import { insertNodes } from '../../state/actions/elements'; -import { getSelectedPage } from '../../state/selectors/workpad'; -import { trackCanvasUiMetric, METRIC_TYPE } from '../../lib/ui_metric'; -import { SavedElementsModal as Component, Props as ComponentProps } from './saved_elements_modal'; -import { State, PositionedElement, CustomElement } from '../../../types'; - -const customElementAdded = 'elements-custom-added'; - -interface OwnProps { - onClose: () => void; -} - -interface OwnPropsWithState extends OwnProps { - customElements: CustomElement[]; - setCustomElements: (customElements: CustomElement[]) => void; - search: string; - setSearch: (search: string) => void; -} - -interface DispatchProps { - selectToplevelNodes: (nodes: PositionedElement[]) => void; - insertNodes: (selectedNodes: PositionedElement[], pageId: string) => void; -} - -interface StateProps { - pageId: string; -} - -const mapStateToProps = (state: State): StateProps => ({ - pageId: getSelectedPage(state), -}); - -const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ - selectToplevelNodes: (nodes: PositionedElement[]) => - dispatch( - selectToplevelNodes( - nodes - .filter((e: PositionedElement): boolean => !e.position.parent) - .map((e: PositionedElement): string => e.id) - ) - ), - insertNodes: (selectedNodes: PositionedElement[], pageId: string) => - dispatch(insertNodes(selectedNodes, pageId)), -}); - -const mergeProps = ( - stateProps: StateProps, - dispatchProps: DispatchProps, - ownProps: OwnPropsWithState & WithServicesProps -): ComponentProps => { - const { pageId } = stateProps; - const { onClose, search, setCustomElements } = ownProps; - - const findCustomElements = async () => { - const { customElements } = await customElementService.find(search); - setCustomElements(customElements); - }; - - return { - ...ownProps, - // add custom element to the page - addCustomElement: (customElement: CustomElement) => { - const { selectedNodes = [] } = JSON.parse(customElement.content) || {}; - const clonedNodes = selectedNodes && cloneSubgraphs(selectedNodes); - if (clonedNodes) { - dispatchProps.insertNodes(clonedNodes, pageId); // first clone and persist the new node(s) - dispatchProps.selectToplevelNodes(clonedNodes); // then select the cloned node(s) - } - onClose(); - trackCanvasUiMetric(METRIC_TYPE.LOADED, customElementAdded); - }, - // custom element search - findCustomElements: async (text?: string) => { - try { - await findCustomElements(); - } catch (err) { - ownProps.services.notify.error(err, { - title: `Couldn't find custom elements`, - }); - } - }, - // remove custom element - removeCustomElement: async (id: string) => { - try { - await customElementService.remove(id); - await findCustomElements(); - } catch (err) { - ownProps.services.notify.error(err, { - title: `Couldn't delete custom elements`, - }); - } - }, - // update custom element - updateCustomElement: async (id: string, name: string, description: string, image: string) => { - try { - await customElementService.update(id, { - name: camelCase(name), - displayName: name, - image, - help: description, - }); - await findCustomElements(); - } catch (err) { - ownProps.services.notify.error(err, { - title: `Couldn't update custom elements`, - }); - } - }, - }; -}; - -export const SavedElementsModal = compose( - withServices, - withState('search', 'setSearch', ''), - withState('customElements', 'setCustomElements', []), - connect(mapStateToProps, mapDispatchToProps, mergeProps) -)(Component); +export { SavedElementsModal } from './saved_elements_modal'; +export { + SavedElementsModal as SavedElementsModalComponent, + Props as SavedElementsModalComponentProps, +} from './saved_elements_modal.component'; diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.tsx b/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.component.tsx similarity index 100% rename from x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.tsx rename to x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.component.tsx diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.ts b/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.ts new file mode 100644 index 0000000000000..a5c5a2e0adce9 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.ts @@ -0,0 +1,136 @@ +/* + * 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 { connect } from 'react-redux'; +import { Dispatch } from 'redux'; +import { compose, withState } from 'recompose'; +import { camelCase } from 'lodash'; +import { cloneSubgraphs } from '../../lib/clone_subgraphs'; +import * as customElementService from '../../lib/custom_element_service'; +import { withServices, WithServicesProps } from '../../services'; +// @ts-expect-error untyped local +import { selectToplevelNodes } from '../../state/actions/transient'; +// @ts-expect-error untyped local +import { insertNodes } from '../../state/actions/elements'; +import { getSelectedPage } from '../../state/selectors/workpad'; +import { trackCanvasUiMetric, METRIC_TYPE } from '../../lib/ui_metric'; +import { + SavedElementsModal as Component, + Props as ComponentProps, +} from './saved_elements_modal.component'; +import { State, PositionedElement, CustomElement } from '../../../types'; + +const customElementAdded = 'elements-custom-added'; + +interface OwnProps { + onClose: () => void; +} + +interface OwnPropsWithState extends OwnProps { + customElements: CustomElement[]; + setCustomElements: (customElements: CustomElement[]) => void; + search: string; + setSearch: (search: string) => void; +} + +interface DispatchProps { + selectToplevelNodes: (nodes: PositionedElement[]) => void; + insertNodes: (selectedNodes: PositionedElement[], pageId: string) => void; +} + +interface StateProps { + pageId: string; +} + +const mapStateToProps = (state: State): StateProps => ({ + pageId: getSelectedPage(state), +}); + +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + selectToplevelNodes: (nodes: PositionedElement[]) => + dispatch( + selectToplevelNodes( + nodes + .filter((e: PositionedElement): boolean => !e.position.parent) + .map((e: PositionedElement): string => e.id) + ) + ), + insertNodes: (selectedNodes: PositionedElement[], pageId: string) => + dispatch(insertNodes(selectedNodes, pageId)), +}); + +const mergeProps = ( + stateProps: StateProps, + dispatchProps: DispatchProps, + ownProps: OwnPropsWithState & WithServicesProps +): ComponentProps => { + const { pageId } = stateProps; + const { onClose, search, setCustomElements } = ownProps; + + const findCustomElements = async () => { + const { customElements } = await customElementService.find(search); + setCustomElements(customElements); + }; + + return { + ...ownProps, + // add custom element to the page + addCustomElement: (customElement: CustomElement) => { + const { selectedNodes = [] } = JSON.parse(customElement.content) || {}; + const clonedNodes = selectedNodes && cloneSubgraphs(selectedNodes); + if (clonedNodes) { + dispatchProps.insertNodes(clonedNodes, pageId); // first clone and persist the new node(s) + dispatchProps.selectToplevelNodes(clonedNodes); // then select the cloned node(s) + } + onClose(); + trackCanvasUiMetric(METRIC_TYPE.LOADED, customElementAdded); + }, + // custom element search + findCustomElements: async (text?: string) => { + try { + await findCustomElements(); + } catch (err) { + ownProps.services.notify.error(err, { + title: `Couldn't find custom elements`, + }); + } + }, + // remove custom element + removeCustomElement: async (id: string) => { + try { + await customElementService.remove(id); + await findCustomElements(); + } catch (err) { + ownProps.services.notify.error(err, { + title: `Couldn't delete custom elements`, + }); + } + }, + // update custom element + updateCustomElement: async (id: string, name: string, description: string, image: string) => { + try { + await customElementService.update(id, { + name: camelCase(name), + displayName: name, + image, + help: description, + }); + await findCustomElements(); + } catch (err) { + ownProps.services.notify.error(err, { + title: `Couldn't update custom elements`, + }); + } + }, + }; +}; + +export const SavedElementsModal = compose( + withServices, + withState('search', 'setSearch', ''), + withState('customElements', 'setCustomElements', []), + connect(mapStateToProps, mapDispatchToProps, mergeProps) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/sidebar/element_settings/element_settings.component.tsx b/x-pack/plugins/canvas/public/components/sidebar/element_settings/element_settings.component.tsx new file mode 100644 index 0000000000000..e3f4e00f4de01 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar/element_settings/element_settings.component.tsx @@ -0,0 +1,55 @@ +/* + * 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, { FunctionComponent } from 'react'; +import PropTypes from 'prop-types'; +import { EuiTabbedContent } from '@elastic/eui'; +// @ts-expect-error unconverted component +import { Datasource } from '../../datasource'; +// @ts-expect-error unconverted component +import { FunctionFormList } from '../../function_form_list'; +import { PositionedElement } from '../../../../types'; +import { ComponentStrings } from '../../../../i18n'; + +interface Props { + /** + * a Canvas element used to populate config forms + */ + element: PositionedElement; +} + +const { ElementSettings: strings } = ComponentStrings; + +export const ElementSettings: FunctionComponent = ({ element }) => { + const tabs = [ + { + id: 'edit', + name: strings.getDisplayTabLabel(), + content: ( +
+
+ +
+
+ ), + }, + { + id: 'data', + name: strings.getDataTabLabel(), + content: ( +
+ +
+ ), + }, + ]; + + return ; +}; + +ElementSettings.propTypes = { + element: PropTypes.object, +}; diff --git a/x-pack/plugins/canvas/public/components/sidebar/element_settings/element_settings.tsx b/x-pack/plugins/canvas/public/components/sidebar/element_settings/element_settings.tsx index e3f4e00f4de01..ba7e31a25daba 100644 --- a/x-pack/plugins/canvas/public/components/sidebar/element_settings/element_settings.tsx +++ b/x-pack/plugins/canvas/public/components/sidebar/element_settings/element_settings.tsx @@ -3,53 +3,32 @@ * 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, { FunctionComponent } from 'react'; -import PropTypes from 'prop-types'; -import { EuiTabbedContent } from '@elastic/eui'; -// @ts-expect-error unconverted component -import { Datasource } from '../../datasource'; -// @ts-expect-error unconverted component -import { FunctionFormList } from '../../function_form_list'; -import { PositionedElement } from '../../../../types'; -import { ComponentStrings } from '../../../../i18n'; +import React from 'react'; +import { connect } from 'react-redux'; +import { getElementById, getSelectedPage } from '../../../state/selectors/workpad'; +import { ElementSettings as Component } from './element_settings.component'; +import { State, PositionedElement } from '../../../../types'; interface Props { - /** - * a Canvas element used to populate config forms - */ - element: PositionedElement; + selectedElementId: string; } -const { ElementSettings: strings } = ComponentStrings; +const mapStateToProps = (state: State, { selectedElementId }: Props): StateProps => ({ + element: getElementById(state, selectedElementId, getSelectedPage(state)), +}); + +interface StateProps { + element: PositionedElement | undefined; +} -export const ElementSettings: FunctionComponent = ({ element }) => { - const tabs = [ - { - id: 'edit', - name: strings.getDisplayTabLabel(), - content: ( -
-
- -
-
- ), - }, - { - id: 'data', - name: strings.getDataTabLabel(), - content: ( -
- -
- ), - }, - ]; +const renderIfElement: React.FunctionComponent = (props) => { + if (props.element) { + return ; + } - return ; + return null; }; -ElementSettings.propTypes = { - element: PropTypes.object, -}; +export const ElementSettings = connect(mapStateToProps)( + renderIfElement +); diff --git a/x-pack/plugins/canvas/public/components/sidebar/element_settings/index.ts b/x-pack/plugins/canvas/public/components/sidebar/element_settings/index.ts new file mode 100644 index 0000000000000..68b90f232fb8b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/sidebar/element_settings/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ElementSettings } from './element_settings'; +export { ElementSettings as ElementSettingsComponent } from './element_settings.component'; diff --git a/x-pack/plugins/canvas/public/components/sidebar/element_settings/index.tsx b/x-pack/plugins/canvas/public/components/sidebar/element_settings/index.tsx deleted file mode 100644 index b8d5882234899..0000000000000 --- a/x-pack/plugins/canvas/public/components/sidebar/element_settings/index.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; -import { connect } from 'react-redux'; -import { getElementById, getSelectedPage } from '../../../state/selectors/workpad'; -import { ElementSettings as Component } from './element_settings'; -import { State, PositionedElement } from '../../../../types'; - -interface Props { - selectedElementId: string; -} - -const mapStateToProps = (state: State, { selectedElementId }: Props): StateProps => ({ - element: getElementById(state, selectedElementId, getSelectedPage(state)), -}); - -interface StateProps { - element: PositionedElement | undefined; -} - -const renderIfElement: React.FunctionComponent = (props) => { - if (props.element) { - return ; - } - - return null; -}; - -export const ElementSettings = connect(mapStateToProps)( - renderIfElement -); diff --git a/x-pack/plugins/canvas/public/components/workpad_color_picker/index.ts b/x-pack/plugins/canvas/public/components/workpad_color_picker/index.ts index abd40731078ec..34e3d3ff4b057 100644 --- a/x-pack/plugins/canvas/public/components/workpad_color_picker/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_color_picker/index.ts @@ -4,20 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -import { connect } from 'react-redux'; -import { addColor, removeColor } from '../../state/actions/workpad'; -import { getWorkpadColors } from '../../state/selectors/workpad'; - -import { WorkpadColorPicker as Component } from '../workpad_color_picker/workpad_color_picker'; -import { State } from '../../../types'; - -const mapStateToProps = (state: State) => ({ - colors: getWorkpadColors(state), -}); - -const mapDispatchToProps = { - onAddColor: addColor, - onRemoveColor: removeColor, -}; - -export const WorkpadColorPicker = connect(mapStateToProps, mapDispatchToProps)(Component); +export { WorkpadColorPicker } from './workpad_color_picker'; +export { WorkpadColorPicker as WorkpadColorPickerComponent } from './workpad_color_picker.component'; diff --git a/x-pack/plugins/canvas/public/components/workpad_color_picker/workpad_color_picker.tsx b/x-pack/plugins/canvas/public/components/workpad_color_picker/workpad_color_picker.component.tsx similarity index 100% rename from x-pack/plugins/canvas/public/components/workpad_color_picker/workpad_color_picker.tsx rename to x-pack/plugins/canvas/public/components/workpad_color_picker/workpad_color_picker.component.tsx diff --git a/x-pack/plugins/canvas/public/components/workpad_color_picker/workpad_color_picker.ts b/x-pack/plugins/canvas/public/components/workpad_color_picker/workpad_color_picker.ts new file mode 100644 index 0000000000000..2f4b0fe7b4ec1 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_color_picker/workpad_color_picker.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { addColor, removeColor } from '../../state/actions/workpad'; +import { getWorkpadColors } from '../../state/selectors/workpad'; + +import { WorkpadColorPicker as Component } from '../workpad_color_picker/workpad_color_picker.component'; +import { State } from '../../../types'; + +const mapStateToProps = (state: State) => ({ + colors: getWorkpadColors(state), +}); + +const mapDispatchToProps = { + onAddColor: addColor, + onRemoveColor: removeColor, +}; + +export const WorkpadColorPicker = connect(mapStateToProps, mapDispatchToProps)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_config/index.ts b/x-pack/plugins/canvas/public/components/workpad_config/index.ts index bba08d7647e9e..63db96ca5aef9 100644 --- a/x-pack/plugins/canvas/public/components/workpad_config/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_config/index.ts @@ -4,40 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -import { connect } from 'react-redux'; - -import { get } from 'lodash'; -import { - sizeWorkpad as setSize, - setName, - setWorkpadCSS, - updateWorkpadVariables, -} from '../../state/actions/workpad'; - -import { getWorkpad } from '../../state/selectors/workpad'; -import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants'; -import { WorkpadConfig as Component } from './workpad_config'; -import { State, CanvasVariable } from '../../../types'; - -const mapStateToProps = (state: State) => { - const workpad = getWorkpad(state); - - return { - name: get(workpad, 'name'), - size: { - width: get(workpad, 'width'), - height: get(workpad, 'height'), - }, - css: get(workpad, 'css', DEFAULT_WORKPAD_CSS), - variables: get(workpad, 'variables', []), - }; -}; - -const mapDispatchToProps = { - setSize, - setName, - setWorkpadCSS, - setWorkpadVariables: (vars: CanvasVariable[]) => updateWorkpadVariables(vars), -}; - -export const WorkpadConfig = connect(mapStateToProps, mapDispatchToProps)(Component); +export { WorkpadConfig } from './workpad_config'; +export { WorkpadConfig as WorkpadConfigComponent } from './workpad_config.component'; diff --git a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.component.tsx similarity index 100% rename from x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx rename to x-pack/plugins/canvas/public/components/workpad_config/workpad_config.component.tsx diff --git a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.ts b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.ts new file mode 100644 index 0000000000000..e4ddf31141972 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; + +import { get } from 'lodash'; +import { + sizeWorkpad as setSize, + setName, + setWorkpadCSS, + updateWorkpadVariables, +} from '../../state/actions/workpad'; + +import { getWorkpad } from '../../state/selectors/workpad'; +import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants'; +import { WorkpadConfig as Component } from './workpad_config.component'; +import { State, CanvasVariable } from '../../../types'; + +const mapStateToProps = (state: State) => { + const workpad = getWorkpad(state); + + return { + name: get(workpad, 'name'), + size: { + width: get(workpad, 'width'), + height: get(workpad, 'height'), + }, + css: get(workpad, 'css', DEFAULT_WORKPAD_CSS), + variables: get(workpad, 'variables', []), + }; +}; + +const mapDispatchToProps = { + setSize, + setName, + setWorkpadCSS, + setWorkpadVariables: (vars: CanvasVariable[]) => updateWorkpadVariables(vars), +}; + +export const WorkpadConfig = connect(mapStateToProps, mapDispatchToProps)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/edit_menu.stories.tsx b/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/edit_menu.stories.tsx index 8bbc3e09af4bf..be6247b0bbcab 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/edit_menu.stories.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/edit_menu.stories.tsx @@ -6,7 +6,7 @@ import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import React from 'react'; -import { EditMenu } from '../edit_menu'; +import { EditMenu } from '../edit_menu.component'; import { PositionedElement } from '../../../../../types'; const handlers = { diff --git a/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/edit_menu.tsx b/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/edit_menu.component.tsx similarity index 100% rename from x-pack/plugins/canvas/public/components/workpad_header/edit_menu/edit_menu.tsx rename to x-pack/plugins/canvas/public/components/workpad_header/edit_menu/edit_menu.component.tsx diff --git a/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/edit_menu.ts b/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/edit_menu.ts new file mode 100644 index 0000000000000..3a2264c05eb4b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/edit_menu.ts @@ -0,0 +1,133 @@ +/* + * 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 { connect } from 'react-redux'; +import { compose, withHandlers, withProps } from 'recompose'; +import { Dispatch } from 'redux'; +import { State, PositionedElement } from '../../../../types'; +import { getClipboardData } from '../../../lib/clipboard'; +// @ts-expect-error untyped local +import { flatten } from '../../../lib/aeroelastic/functional'; +// @ts-expect-error untyped local +import { globalStateUpdater } from '../../workpad_page/integration_utils'; +// @ts-expect-error untyped local +import { crawlTree } from '../../workpad_page/integration_utils'; +// @ts-expect-error untyped local +import { insertNodes, elementLayer, removeElements } from '../../../state/actions/elements'; +// @ts-expect-error untyped local +import { undoHistory, redoHistory } from '../../../state/actions/history'; +// @ts-expect-error untyped local +import { selectToplevelNodes } from '../../../state/actions/transient'; +import { + getSelectedPage, + getNodes, + getSelectedToplevelNodes, +} from '../../../state/selectors/workpad'; +import { + layerHandlerCreators, + clipboardHandlerCreators, + basicHandlerCreators, + groupHandlerCreators, + alignmentDistributionHandlerCreators, +} from '../../../lib/element_handler_creators'; +import { EditMenu as Component, Props as ComponentProps } from './edit_menu.component'; + +type LayoutState = any; + +type CommitFn = (type: string, payload: any) => LayoutState; + +interface OwnProps { + commit: CommitFn; +} + +const withGlobalState = ( + commit: CommitFn, + updateGlobalState: (layoutState: LayoutState) => void +) => (type: string, payload: any) => { + const newLayoutState = commit(type, payload); + if (newLayoutState.currentScene.gestureEnd) { + updateGlobalState(newLayoutState); + } +}; + +/* + * TODO: this is all copied from interactive_workpad_page and workpad_shortcuts + */ +const mapStateToProps = (state: State) => { + const pageId = getSelectedPage(state); + const nodes = getNodes(state, pageId) as PositionedElement[]; + const selectedToplevelNodes = getSelectedToplevelNodes(state); + + const selectedPrimaryShapeObjects = selectedToplevelNodes + .map((id: string) => nodes.find((s: PositionedElement) => s.id === id)) + .filter((shape?: PositionedElement) => shape) as PositionedElement[]; + + const selectedPersistentPrimaryNodes = flatten( + selectedPrimaryShapeObjects.map((shape: PositionedElement) => + nodes.find((n: PositionedElement) => n.id === shape.id) // is it a leaf or a persisted group? + ? [shape.id] + : nodes.filter((s: PositionedElement) => s.position.parent === shape.id).map((s) => s.id) + ) + ); + + const selectedNodeIds: string[] = flatten(selectedPersistentPrimaryNodes.map(crawlTree(nodes))); + const selectedNodes = selectedNodeIds + .map((id: string) => nodes.find((s) => s.id === id)) + .filter((node: PositionedElement | undefined): node is PositionedElement => { + return !!node; + }); + + return { + pageId, + selectedToplevelNodes, + selectedNodes, + state, + }; +}; + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + insertNodes: (selectedNodes: PositionedElement[], pageId: string) => + dispatch(insertNodes(selectedNodes, pageId)), + removeNodes: (nodeIds: string[], pageId: string) => dispatch(removeElements(nodeIds, pageId)), + selectToplevelNodes: (nodes: PositionedElement[]) => + dispatch( + selectToplevelNodes( + nodes.filter((e: PositionedElement) => !e.position.parent).map((e) => e.id) + ) + ), + elementLayer: (pageId: string, elementId: string, movement: number) => { + dispatch(elementLayer({ pageId, elementId, movement })); + }, + undoHistory: () => dispatch(undoHistory()), + redoHistory: () => dispatch(redoHistory()), + dispatch, +}); + +const mergeProps = ( + { state, selectedToplevelNodes, ...restStateProps }: ReturnType, + { dispatch, ...restDispatchProps }: ReturnType, + { commit }: OwnProps +) => { + const updateGlobalState = globalStateUpdater(dispatch, state); + + return { + ...restDispatchProps, + ...restStateProps, + commit: withGlobalState(commit, updateGlobalState), + groupIsSelected: + selectedToplevelNodes.length === 1 && selectedToplevelNodes[0].includes('group'), + }; +}; + +export const EditMenu = compose( + connect(mapStateToProps, mapDispatchToProps, mergeProps), + withProps(() => ({ hasPasteData: Boolean(getClipboardData()) })), + withHandlers(basicHandlerCreators), + withHandlers(clipboardHandlerCreators), + withHandlers(layerHandlerCreators), + withHandlers(groupHandlerCreators), + withHandlers(alignmentDistributionHandlerCreators) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/index.ts index 8f013f70aefcd..0db425f01cccd 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/edit_menu/index.ts @@ -4,130 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -import { connect } from 'react-redux'; -import { compose, withHandlers, withProps } from 'recompose'; -import { Dispatch } from 'redux'; -import { State, PositionedElement } from '../../../../types'; -import { getClipboardData } from '../../../lib/clipboard'; -// @ts-expect-error untyped local -import { flatten } from '../../../lib/aeroelastic/functional'; -// @ts-expect-error untyped local -import { globalStateUpdater } from '../../workpad_page/integration_utils'; -// @ts-expect-error untyped local -import { crawlTree } from '../../workpad_page/integration_utils'; -// @ts-expect-error untyped local -import { insertNodes, elementLayer, removeElements } from '../../../state/actions/elements'; -// @ts-expect-error untyped local -import { undoHistory, redoHistory } from '../../../state/actions/history'; -// @ts-expect-error untyped local -import { selectToplevelNodes } from '../../../state/actions/transient'; -import { - getSelectedPage, - getNodes, - getSelectedToplevelNodes, -} from '../../../state/selectors/workpad'; -import { - layerHandlerCreators, - clipboardHandlerCreators, - basicHandlerCreators, - groupHandlerCreators, - alignmentDistributionHandlerCreators, -} from '../../../lib/element_handler_creators'; -import { EditMenu as Component, Props as ComponentProps } from './edit_menu'; - -type LayoutState = any; - -type CommitFn = (type: string, payload: any) => LayoutState; - -interface OwnProps { - commit: CommitFn; -} - -const withGlobalState = ( - commit: CommitFn, - updateGlobalState: (layoutState: LayoutState) => void -) => (type: string, payload: any) => { - const newLayoutState = commit(type, payload); - if (newLayoutState.currentScene.gestureEnd) { - updateGlobalState(newLayoutState); - } -}; - -/* - * TODO: this is all copied from interactive_workpad_page and workpad_shortcuts - */ -const mapStateToProps = (state: State) => { - const pageId = getSelectedPage(state); - const nodes = getNodes(state, pageId) as PositionedElement[]; - const selectedToplevelNodes = getSelectedToplevelNodes(state); - - const selectedPrimaryShapeObjects = selectedToplevelNodes - .map((id: string) => nodes.find((s: PositionedElement) => s.id === id)) - .filter((shape?: PositionedElement) => shape) as PositionedElement[]; - - const selectedPersistentPrimaryNodes = flatten( - selectedPrimaryShapeObjects.map((shape: PositionedElement) => - nodes.find((n: PositionedElement) => n.id === shape.id) // is it a leaf or a persisted group? - ? [shape.id] - : nodes.filter((s: PositionedElement) => s.position.parent === shape.id).map((s) => s.id) - ) - ); - - const selectedNodeIds: string[] = flatten(selectedPersistentPrimaryNodes.map(crawlTree(nodes))); - const selectedNodes = selectedNodeIds - .map((id: string) => nodes.find((s) => s.id === id)) - .filter((node: PositionedElement | undefined): node is PositionedElement => { - return !!node; - }); - - return { - pageId, - selectedToplevelNodes, - selectedNodes, - state, - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => ({ - insertNodes: (selectedNodes: PositionedElement[], pageId: string) => - dispatch(insertNodes(selectedNodes, pageId)), - removeNodes: (nodeIds: string[], pageId: string) => dispatch(removeElements(nodeIds, pageId)), - selectToplevelNodes: (nodes: PositionedElement[]) => - dispatch( - selectToplevelNodes( - nodes.filter((e: PositionedElement) => !e.position.parent).map((e) => e.id) - ) - ), - elementLayer: (pageId: string, elementId: string, movement: number) => { - dispatch(elementLayer({ pageId, elementId, movement })); - }, - undoHistory: () => dispatch(undoHistory()), - redoHistory: () => dispatch(redoHistory()), - dispatch, -}); - -const mergeProps = ( - { state, selectedToplevelNodes, ...restStateProps }: ReturnType, - { dispatch, ...restDispatchProps }: ReturnType, - { commit }: OwnProps -) => { - const updateGlobalState = globalStateUpdater(dispatch, state); - - return { - ...restDispatchProps, - ...restStateProps, - commit: withGlobalState(commit, updateGlobalState), - groupIsSelected: - selectedToplevelNodes.length === 1 && selectedToplevelNodes[0].includes('group'), - }; -}; - -export const EditMenu = compose( - connect(mapStateToProps, mapDispatchToProps, mergeProps), - withProps(() => ({ hasPasteData: Boolean(getClipboardData()) })), - withHandlers(basicHandlerCreators), - withHandlers(clipboardHandlerCreators), - withHandlers(layerHandlerCreators), - withHandlers(groupHandlerCreators), - withHandlers(alignmentDistributionHandlerCreators) -)(Component); +export { EditMenu } from './edit_menu'; +export { EditMenu as EditMenuComponent } from './edit_menu.component'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/__examples__/element_menu.stories.tsx b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/__examples__/element_menu.stories.tsx index 9aca5ce33ba02..cf9b334ffe8ea 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/__examples__/element_menu.stories.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/__examples__/element_menu.stories.tsx @@ -8,7 +8,7 @@ import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import React from 'react'; import { ElementSpec } from '../../../../../types'; -import { ElementMenu } from '../element_menu'; +import { ElementMenu } from '../element_menu.component'; const testElements: { [key: string]: ElementSpec } = { areaChart: { diff --git a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/element_menu.component.tsx b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/element_menu.component.tsx new file mode 100644 index 0000000000000..6d9233aaba22b --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/element_menu.component.tsx @@ -0,0 +1,214 @@ +/* + * 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 { sortBy } from 'lodash'; +import React, { Fragment, FunctionComponent, useState } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiButton, + EuiContextMenu, + EuiIcon, + EuiContextMenuPanelItemDescriptor, +} from '@elastic/eui'; +import { CONTEXT_MENU_TOP_BORDER_CLASSNAME } from '../../../../common/lib'; +import { ComponentStrings } from '../../../../i18n/components'; +import { ElementSpec } from '../../../../types'; +import { flattenPanelTree } from '../../../lib/flatten_panel_tree'; +import { getId } from '../../../lib/get_id'; +import { Popover, ClosePopoverFn } from '../../popover'; +import { AssetManager } from '../../asset_manager'; +import { SavedElementsModal } from '../../saved_elements_modal'; + +interface CategorizedElementLists { + [key: string]: ElementSpec[]; +} + +interface ElementTypeMeta { + [key: string]: { name: string; icon: string }; +} + +export const { WorkpadHeaderElementMenu: strings } = ComponentStrings; + +// label and icon for the context menu item for each element type +const elementTypeMeta: ElementTypeMeta = { + chart: { name: strings.getChartMenuItemLabel(), icon: 'visArea' }, + filter: { name: strings.getFilterMenuItemLabel(), icon: 'filter' }, + image: { name: strings.getImageMenuItemLabel(), icon: 'image' }, + other: { name: strings.getOtherMenuItemLabel(), icon: 'empty' }, + progress: { name: strings.getProgressMenuItemLabel(), icon: 'visGoal' }, + shape: { name: strings.getShapeMenuItemLabel(), icon: 'node' }, + text: { name: strings.getTextMenuItemLabel(), icon: 'visText' }, +}; + +const getElementType = (element: ElementSpec): string => + element && element.type && Object.keys(elementTypeMeta).includes(element.type) + ? element.type + : 'other'; + +const categorizeElementsByType = (elements: ElementSpec[]): { [key: string]: ElementSpec[] } => { + elements = sortBy(elements, 'displayName'); + + const categories: CategorizedElementLists = { other: [] }; + + elements.forEach((element: ElementSpec) => { + const type = getElementType(element); + + if (categories[type]) { + categories[type].push(element); + } else { + categories[type] = [element]; + } + }); + + return categories; +}; + +export interface Props { + /** + * Dictionary of elements from elements registry + */ + elements: { [key: string]: ElementSpec }; + /** + * Handler for adding a selected element to the workpad + */ + addElement: (element: ElementSpec) => void; + /** + * Renders embeddable flyout + */ + renderEmbedPanel: (onClose: () => void) => JSX.Element; +} + +export const ElementMenu: FunctionComponent = ({ + elements, + addElement, + renderEmbedPanel, +}) => { + const [isAssetModalVisible, setAssetModalVisible] = useState(false); + const [isEmbedPanelVisible, setEmbedPanelVisible] = useState(false); + const [isSavedElementsModalVisible, setSavedElementsModalVisible] = useState(false); + + const hideAssetModal = () => setAssetModalVisible(false); + const showAssetModal = () => setAssetModalVisible(true); + const hideEmbedPanel = () => setEmbedPanelVisible(false); + const showEmbedPanel = () => setEmbedPanelVisible(true); + const hideSavedElementsModal = () => setSavedElementsModalVisible(false); + const showSavedElementsModal = () => setSavedElementsModalVisible(true); + + const { + chart: chartElements, + filter: filterElements, + image: imageElements, + other: otherElements, + progress: progressElements, + shape: shapeElements, + text: textElements, + } = categorizeElementsByType(Object.values(elements)); + + const getPanelTree = (closePopover: ClosePopoverFn) => { + const elementToMenuItem = (element: ElementSpec): EuiContextMenuPanelItemDescriptor => ({ + name: element.displayName || element.name, + icon: element.icon, + onClick: () => { + addElement(element); + closePopover(); + }, + }); + + const elementListToMenuItems = (elementList: ElementSpec[]) => { + const type = getElementType(elementList[0]); + const { name, icon } = elementTypeMeta[type] || elementTypeMeta.other; + + if (elementList.length > 1) { + return { + name, + icon: , + panel: { + id: getId('element-type'), + title: name, + items: elementList.map(elementToMenuItem), + }, + }; + } + + return elementToMenuItem(elementList[0]); + }; + + return { + id: 0, + items: [ + elementListToMenuItems(textElements), + elementListToMenuItems(shapeElements), + elementListToMenuItems(chartElements), + elementListToMenuItems(imageElements), + elementListToMenuItems(filterElements), + elementListToMenuItems(progressElements), + elementListToMenuItems(otherElements), + { + name: strings.getMyElementsMenuItemLabel(), + className: CONTEXT_MENU_TOP_BORDER_CLASSNAME, + 'data-test-subj': 'saved-elements-menu-option', + icon: , + onClick: () => { + showSavedElementsModal(); + closePopover(); + }, + }, + { + name: strings.getAssetsMenuItemLabel(), + icon: , + onClick: () => { + showAssetModal(); + closePopover(); + }, + }, + { + name: strings.getEmbedObjectMenuItemLabel(), + className: CONTEXT_MENU_TOP_BORDER_CLASSNAME, + icon: , + onClick: () => { + showEmbedPanel(); + closePopover(); + }, + }, + ], + }; + }; + + const exportControl = (togglePopover: React.MouseEventHandler) => ( + + {strings.getElementMenuButtonLabel()} + + ); + + return ( + + + {({ closePopover }: { closePopover: ClosePopoverFn }) => ( + + )} + + {isAssetModalVisible ? : null} + {isEmbedPanelVisible ? renderEmbedPanel(hideEmbedPanel) : null} + {isSavedElementsModalVisible ? : null} + + ); +}; + +ElementMenu.propTypes = { + elements: PropTypes.object, + addElement: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/element_menu.tsx b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/element_menu.tsx index 6d9233aaba22b..2cbe4ae5a6575 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/element_menu.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/element_menu.tsx @@ -4,211 +4,44 @@ * you may not use this file except in compliance with the Elastic License. */ -import { sortBy } from 'lodash'; -import React, { Fragment, FunctionComponent, useState } from 'react'; -import PropTypes from 'prop-types'; -import { - EuiButton, - EuiContextMenu, - EuiIcon, - EuiContextMenuPanelItemDescriptor, -} from '@elastic/eui'; -import { CONTEXT_MENU_TOP_BORDER_CLASSNAME } from '../../../../common/lib'; -import { ComponentStrings } from '../../../../i18n/components'; -import { ElementSpec } from '../../../../types'; -import { flattenPanelTree } from '../../../lib/flatten_panel_tree'; -import { getId } from '../../../lib/get_id'; -import { Popover, ClosePopoverFn } from '../../popover'; -import { AssetManager } from '../../asset_manager'; -import { SavedElementsModal } from '../../saved_elements_modal'; - -interface CategorizedElementLists { - [key: string]: ElementSpec[]; -} - -interface ElementTypeMeta { - [key: string]: { name: string; icon: string }; +import React from 'react'; +import { connect } from 'react-redux'; +import { compose, withProps } from 'recompose'; +import { Dispatch } from 'redux'; +import { State, ElementSpec } from '../../../../types'; +// @ts-expect-error untyped local +import { elementsRegistry } from '../../../lib/elements_registry'; +import { ElementMenu as Component, Props as ComponentProps } from './element_menu.component'; +// @ts-expect-error untyped local +import { addElement } from '../../../state/actions/elements'; +import { getSelectedPage } from '../../../state/selectors/workpad'; +import { AddEmbeddablePanel } from '../../embeddable_flyout'; + +interface StateProps { + pageId: string; } -export const { WorkpadHeaderElementMenu: strings } = ComponentStrings; - -// label and icon for the context menu item for each element type -const elementTypeMeta: ElementTypeMeta = { - chart: { name: strings.getChartMenuItemLabel(), icon: 'visArea' }, - filter: { name: strings.getFilterMenuItemLabel(), icon: 'filter' }, - image: { name: strings.getImageMenuItemLabel(), icon: 'image' }, - other: { name: strings.getOtherMenuItemLabel(), icon: 'empty' }, - progress: { name: strings.getProgressMenuItemLabel(), icon: 'visGoal' }, - shape: { name: strings.getShapeMenuItemLabel(), icon: 'node' }, - text: { name: strings.getTextMenuItemLabel(), icon: 'visText' }, -}; - -const getElementType = (element: ElementSpec): string => - element && element.type && Object.keys(elementTypeMeta).includes(element.type) - ? element.type - : 'other'; - -const categorizeElementsByType = (elements: ElementSpec[]): { [key: string]: ElementSpec[] } => { - elements = sortBy(elements, 'displayName'); - - const categories: CategorizedElementLists = { other: [] }; - - elements.forEach((element: ElementSpec) => { - const type = getElementType(element); - - if (categories[type]) { - categories[type].push(element); - } else { - categories[type] = [element]; - } - }); - - return categories; -}; - -export interface Props { - /** - * Dictionary of elements from elements registry - */ - elements: { [key: string]: ElementSpec }; - /** - * Handler for adding a selected element to the workpad - */ - addElement: (element: ElementSpec) => void; - /** - * Renders embeddable flyout - */ - renderEmbedPanel: (onClose: () => void) => JSX.Element; +interface DispatchProps { + addElement: (pageId: string) => (partialElement: ElementSpec) => void; } -export const ElementMenu: FunctionComponent = ({ - elements, - addElement, - renderEmbedPanel, -}) => { - const [isAssetModalVisible, setAssetModalVisible] = useState(false); - const [isEmbedPanelVisible, setEmbedPanelVisible] = useState(false); - const [isSavedElementsModalVisible, setSavedElementsModalVisible] = useState(false); - - const hideAssetModal = () => setAssetModalVisible(false); - const showAssetModal = () => setAssetModalVisible(true); - const hideEmbedPanel = () => setEmbedPanelVisible(false); - const showEmbedPanel = () => setEmbedPanelVisible(true); - const hideSavedElementsModal = () => setSavedElementsModalVisible(false); - const showSavedElementsModal = () => setSavedElementsModalVisible(true); - - const { - chart: chartElements, - filter: filterElements, - image: imageElements, - other: otherElements, - progress: progressElements, - shape: shapeElements, - text: textElements, - } = categorizeElementsByType(Object.values(elements)); - - const getPanelTree = (closePopover: ClosePopoverFn) => { - const elementToMenuItem = (element: ElementSpec): EuiContextMenuPanelItemDescriptor => ({ - name: element.displayName || element.name, - icon: element.icon, - onClick: () => { - addElement(element); - closePopover(); - }, - }); - - const elementListToMenuItems = (elementList: ElementSpec[]) => { - const type = getElementType(elementList[0]); - const { name, icon } = elementTypeMeta[type] || elementTypeMeta.other; - - if (elementList.length > 1) { - return { - name, - icon: , - panel: { - id: getId('element-type'), - title: name, - items: elementList.map(elementToMenuItem), - }, - }; - } - - return elementToMenuItem(elementList[0]); - }; - - return { - id: 0, - items: [ - elementListToMenuItems(textElements), - elementListToMenuItems(shapeElements), - elementListToMenuItems(chartElements), - elementListToMenuItems(imageElements), - elementListToMenuItems(filterElements), - elementListToMenuItems(progressElements), - elementListToMenuItems(otherElements), - { - name: strings.getMyElementsMenuItemLabel(), - className: CONTEXT_MENU_TOP_BORDER_CLASSNAME, - 'data-test-subj': 'saved-elements-menu-option', - icon: , - onClick: () => { - showSavedElementsModal(); - closePopover(); - }, - }, - { - name: strings.getAssetsMenuItemLabel(), - icon: , - onClick: () => { - showAssetModal(); - closePopover(); - }, - }, - { - name: strings.getEmbedObjectMenuItemLabel(), - className: CONTEXT_MENU_TOP_BORDER_CLASSNAME, - icon: , - onClick: () => { - showEmbedPanel(); - closePopover(); - }, - }, - ], - }; - }; - - const exportControl = (togglePopover: React.MouseEventHandler) => ( - - {strings.getElementMenuButtonLabel()} - - ); - - return ( - - - {({ closePopover }: { closePopover: ClosePopoverFn }) => ( - - )} - - {isAssetModalVisible ? : null} - {isEmbedPanelVisible ? renderEmbedPanel(hideEmbedPanel) : null} - {isSavedElementsModalVisible ? : null} - - ); -}; - -ElementMenu.propTypes = { - elements: PropTypes.object, - addElement: PropTypes.func.isRequired, -}; +const mapStateToProps = (state: State) => ({ + pageId: getSelectedPage(state), +}); + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + addElement: (pageId: string) => (element: ElementSpec) => dispatch(addElement(pageId, element)), +}); + +const mergeProps = (stateProps: StateProps, dispatchProps: DispatchProps) => ({ + ...stateProps, + ...dispatchProps, + addElement: dispatchProps.addElement(stateProps.pageId), + // Moved this section out of the main component to enable stories + renderEmbedPanel: (onClose: () => void) => , +}); + +export const ElementMenu = compose( + connect(mapStateToProps, mapDispatchToProps, mergeProps), + withProps(() => ({ elements: elementsRegistry.toJS() })) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.ts new file mode 100644 index 0000000000000..26f81e125f6e2 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ElementMenu } from './element_menu'; +export { ElementMenu as ElementMenuComponent } from './element_menu.component'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.tsx b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.tsx deleted file mode 100644 index 264873fc994dd..0000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { connect } from 'react-redux'; -import { compose, withProps } from 'recompose'; -import { Dispatch } from 'redux'; -import { State, ElementSpec } from '../../../../types'; -// @ts-expect-error untyped local -import { elementsRegistry } from '../../../lib/elements_registry'; -import { ElementMenu as Component, Props as ComponentProps } from './element_menu'; -// @ts-expect-error untyped local -import { addElement } from '../../../state/actions/elements'; -import { getSelectedPage } from '../../../state/selectors/workpad'; -import { AddEmbeddablePanel } from '../../embeddable_flyout'; - -interface StateProps { - pageId: string; -} - -interface DispatchProps { - addElement: (pageId: string) => (partialElement: ElementSpec) => void; -} - -const mapStateToProps = (state: State) => ({ - pageId: getSelectedPage(state), -}); - -const mapDispatchToProps = (dispatch: Dispatch) => ({ - addElement: (pageId: string) => (element: ElementSpec) => dispatch(addElement(pageId, element)), -}); - -const mergeProps = (stateProps: StateProps, dispatchProps: DispatchProps) => ({ - ...stateProps, - ...dispatchProps, - addElement: dispatchProps.addElement(stateProps.pageId), - // Moved this section out of the main component to enable stories - renderEmbedPanel: (onClose: () => void) => , -}); - -export const ElementMenu = compose( - connect(mapStateToProps, mapDispatchToProps, mergeProps), - withProps(() => ({ elements: elementsRegistry.toJS() })) -)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/index.ts new file mode 100644 index 0000000000000..0b6f8cc06d198 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { WorkpadHeader } from './workpad_header'; +export { WorkpadHeader as WorkpadHeaderComponent } from './workpad_header.component'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/index.tsx b/x-pack/plugins/canvas/public/components/workpad_header/index.tsx deleted file mode 100644 index 407b4ff932811..0000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_header/index.tsx +++ /dev/null @@ -1,46 +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 { connect } from 'react-redux'; -import { Dispatch } from 'redux'; -import { canUserWrite } from '../../state/selectors/app'; -import { getSelectedPage, isWriteable } from '../../state/selectors/workpad'; -import { setWriteable } from '../../state/actions/workpad'; -import { State } from '../../../types'; -import { WorkpadHeader as Component, Props as ComponentProps } from './workpad_header'; - -interface StateProps { - isWriteable: boolean; - canUserWrite: boolean; - selectedPage: string; -} - -interface DispatchProps { - setWriteable: (isWorkpadWriteable: boolean) => void; -} - -const mapStateToProps = (state: State): StateProps => ({ - isWriteable: isWriteable(state) && canUserWrite(state), - canUserWrite: canUserWrite(state), - selectedPage: getSelectedPage(state), -}); - -const mapDispatchToProps = (dispatch: Dispatch) => ({ - setWriteable: (isWorkpadWriteable: boolean) => dispatch(setWriteable(isWorkpadWriteable)), -}); - -const mergeProps = ( - stateProps: StateProps, - dispatchProps: DispatchProps, - ownProps: ComponentProps -): ComponentProps => ({ - ...stateProps, - ...dispatchProps, - ...ownProps, - toggleWriteable: () => dispatchProps.setWriteable(!stateProps.isWriteable), -}); - -export const WorkpadHeader = connect(mapStateToProps, mapDispatchToProps, mergeProps)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/index.ts index 87b926d93ccb9..8db62f5ac2d87 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/index.ts @@ -4,19 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -import { connect } from 'react-redux'; -// @ts-expect-error untyped local -import { fetchAllRenderables } from '../../../state/actions/elements'; -import { getInFlight } from '../../../state/selectors/resolved_args'; -import { State } from '../../../../types'; -import { RefreshControl as Component } from './refresh_control'; - -const mapStateToProps = (state: State) => ({ - inFlight: getInFlight(state), -}); - -const mapDispatchToProps = { - doRefresh: fetchAllRenderables, -}; - -export const RefreshControl = connect(mapStateToProps, mapDispatchToProps)(Component); +export { RefreshControl } from './refresh_control'; +export { RefreshControl as RefreshControlComponent } from './refresh_control.component'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.tsx b/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.component.tsx similarity index 100% rename from x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.tsx rename to x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.component.tsx diff --git a/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.ts b/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.ts new file mode 100644 index 0000000000000..a7f01e46927ce --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.ts @@ -0,0 +1,22 @@ +/* + * 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 { connect } from 'react-redux'; +// @ts-expect-error untyped local +import { fetchAllRenderables } from '../../../state/actions/elements'; +import { getInFlight } from '../../../state/selectors/resolved_args'; +import { State } from '../../../../types'; +import { RefreshControl as Component } from './refresh_control.component'; + +const mapStateToProps = (state: State) => ({ + inFlight: getInFlight(state), +}); + +const mapDispatchToProps = { + doRefresh: fetchAllRenderables, +}; + +export const RefreshControl = connect(mapStateToProps, mapDispatchToProps)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__examples__/share_menu.stories.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__examples__/share_menu.stories.tsx index ab9137b1676c9..e0a1f0e381fd3 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__examples__/share_menu.stories.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__examples__/share_menu.stories.tsx @@ -6,7 +6,7 @@ import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import React from 'react'; -import { ShareMenu } from '../share_menu'; +import { ShareMenu } from '../share_menu.component'; storiesOf('components/WorkpadHeader/ShareMenu', module).add('default', () => ( { + const renderers: string[] = []; + const expressions = getRenderedWorkpadExpressions(state); + expressions.forEach((expression) => { + if (!renderFunctionNames.includes(expression)) { + renderers.push(expression); + } + }); + + return renderers; +}; + +const mapStateToProps = (state: State) => ({ + renderedWorkpad: getRenderedWorkpad(state), + unsupportedRenderers: getUnsupportedRenderers(state), + workpad: getWorkpad(state), +}); + +interface Props { + onClose: OnCloseFn; + renderedWorkpad: CanvasRenderedWorkpad; + unsupportedRenderers: string[]; + workpad: CanvasWorkpad; +} + +export const ShareWebsiteFlyout = compose>( + connect(mapStateToProps), + withKibana, + withProps( + ({ + unsupportedRenderers, + renderedWorkpad, + onClose, + workpad, + kibana, + }: Props & WithKibanaProps): ComponentProps => ({ + unsupportedRenderers, + onClose, + onCopy: () => { + kibana.services.canvas.notify.info(strings.getCopyShareConfigMessage()); + }, + onDownload: (type) => { + switch (type) { + case 'share': + downloadRenderedWorkpad(renderedWorkpad); + return; + case 'shareRuntime': + downloadRuntime(kibana.services.http.basePath.get()); + return; + case 'shareZip': + const basePath = kibana.services.http.basePath.get(); + arrayBufferFetch + .post(`${basePath}${API_ROUTE_SHAREABLE_ZIP}`, JSON.stringify(renderedWorkpad)) + .then((blob) => downloadZippedRuntime(blob.data)) + .catch((err: Error) => { + kibana.services.canvas.notify.error(err, { + title: strings.getShareableZipErrorTitle(workpad.name), + }); + }); + return; + default: + throw new Error(strings.getUnknownExportErrorMessage(type)); + } + }, + }) + ) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/index.ts index 1e1eac2a1dcf3..335c5dff6ed74 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/index.ts @@ -4,95 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -import { connect } from 'react-redux'; -import { compose, withProps } from 'recompose'; -import { - getWorkpad, - getRenderedWorkpad, - getRenderedWorkpadExpressions, -} from '../../../../state/selectors/workpad'; -import { - downloadRenderedWorkpad, - downloadRuntime, - downloadZippedRuntime, -} from '../../../../lib/download_workpad'; -import { ShareWebsiteFlyout as Component, Props as ComponentProps } from './share_website_flyout'; -import { State, CanvasWorkpad } from '../../../../../types'; -import { CanvasRenderedWorkpad } from '../../../../../shareable_runtime/types'; -import { arrayBufferFetch } from '../../../../../common/lib/fetch'; -import { API_ROUTE_SHAREABLE_ZIP } from '../../../../../common/lib/constants'; -import { renderFunctionNames } from '../../../../../shareable_runtime/supported_renderers'; - -import { ComponentStrings } from '../../../../../i18n/components'; -import { withKibana } from '../../../../../../../../src/plugins/kibana_react/public/'; -import { OnCloseFn } from '../share_menu'; -import { WithKibanaProps } from '../../../../index'; -const { WorkpadHeaderShareMenu: strings } = ComponentStrings; - -const getUnsupportedRenderers = (state: State) => { - const renderers: string[] = []; - const expressions = getRenderedWorkpadExpressions(state); - expressions.forEach((expression) => { - if (!renderFunctionNames.includes(expression)) { - renderers.push(expression); - } - }); - - return renderers; -}; - -const mapStateToProps = (state: State) => ({ - renderedWorkpad: getRenderedWorkpad(state), - unsupportedRenderers: getUnsupportedRenderers(state), - workpad: getWorkpad(state), -}); - -interface Props { - onClose: OnCloseFn; - renderedWorkpad: CanvasRenderedWorkpad; - unsupportedRenderers: string[]; - workpad: CanvasWorkpad; -} - -export const ShareWebsiteFlyout = compose>( - connect(mapStateToProps), - withKibana, - withProps( - ({ - unsupportedRenderers, - renderedWorkpad, - onClose, - workpad, - kibana, - }: Props & WithKibanaProps): ComponentProps => ({ - unsupportedRenderers, - onClose, - onCopy: () => { - kibana.services.canvas.notify.info(strings.getCopyShareConfigMessage()); - }, - onDownload: (type) => { - switch (type) { - case 'share': - downloadRenderedWorkpad(renderedWorkpad); - return; - case 'shareRuntime': - downloadRuntime(kibana.services.http.basePath.get()); - return; - case 'shareZip': - const basePath = kibana.services.http.basePath.get(); - arrayBufferFetch - .post(`${basePath}${API_ROUTE_SHAREABLE_ZIP}`, JSON.stringify(renderedWorkpad)) - .then((blob) => downloadZippedRuntime(blob.data)) - .catch((err: Error) => { - kibana.services.canvas.notify.error(err, { - title: strings.getShareableZipErrorTitle(workpad.name), - }); - }); - return; - default: - throw new Error(strings.getUnknownExportErrorMessage(type)); - } - }, - }) - ) -)(Component); +export { ShareWebsiteFlyout } from './flyout'; +export { ShareWebsiteFlyout as ShareWebsiteFlyoutComponent } from './flyout.component'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/runtime_step.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/runtime_step.tsx index ea8aba688b2a6..b38226bb12a23 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/runtime_step.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/runtime_step.tsx @@ -9,7 +9,7 @@ import { EuiText, EuiSpacer, EuiButton } from '@elastic/eui'; import { ComponentStrings } from '../../../../../i18n/components'; -import { OnDownloadFn } from './share_website_flyout'; +import { OnDownloadFn } from './flyout'; const { ShareWebsiteRuntimeStep: strings } = ComponentStrings; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/snippets_step.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/snippets_step.tsx index 81f559651eb25..42497fcd316fe 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/snippets_step.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/snippets_step.tsx @@ -19,7 +19,7 @@ import { import { ComponentStrings } from '../../../../../i18n/components'; import { Clipboard } from '../../../clipboard'; -import { OnCopyFn } from './share_website_flyout'; +import { OnCopyFn } from './flyout'; const { ShareWebsiteSnippetsStep: strings } = ComponentStrings; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/workpad_step.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/workpad_step.tsx index 1a5884d89d066..ac4dfe6872d3c 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/workpad_step.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/flyout/workpad_step.tsx @@ -9,7 +9,7 @@ import { EuiText, EuiSpacer, EuiButton } from '@elastic/eui'; import { ComponentStrings } from '../../../../../i18n/components'; -import { OnDownloadFn } from './share_website_flyout'; +import { OnDownloadFn } from './flyout'; const { ShareWebsiteWorkpadStep: strings } = ComponentStrings; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/index.ts index 01bcfebc0dba9..19dc9b668e61a 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/index.ts @@ -4,95 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -import { connect } from 'react-redux'; -import { compose, withProps } from 'recompose'; -import { jobCompletionNotifications } from '../../../../../../plugins/reporting/public'; -import { getWorkpad, getPages } from '../../../state/selectors/workpad'; -import { getWindow } from '../../../lib/get_window'; -import { downloadWorkpad } from '../../../lib/download_workpad'; -import { ShareMenu as Component, Props as ComponentProps } from './share_menu'; -import { getPdfUrl, createPdf } from './utils'; -import { State, CanvasWorkpad } from '../../../../types'; -import { withServices, WithServicesProps } from '../../../services'; - -import { ComponentStrings } from '../../../../i18n'; - -const { WorkpadHeaderShareMenu: strings } = ComponentStrings; - -const mapStateToProps = (state: State) => ({ - workpad: getWorkpad(state), - pageCount: getPages(state).length, -}); - -const getAbsoluteUrl = (path: string) => { - const { location } = getWindow(); - - if (!location) { - return path; - } // fallback for mocked window object - - const { protocol, hostname, port } = location; - return `${protocol}//${hostname}:${port}${path}`; -}; - -interface Props { - workpad: CanvasWorkpad; - pageCount: number; -} - -export const ShareMenu = compose( - connect(mapStateToProps), - withServices, - withProps( - ({ workpad, pageCount, services }: Props & WithServicesProps): ComponentProps => ({ - getExportUrl: (type) => { - if (type === 'pdf') { - const pdfUrl = getPdfUrl( - workpad, - { pageCount }, - services.platform.getBasePathInterface() - ); - return getAbsoluteUrl(pdfUrl); - } - - throw new Error(strings.getUnknownExportErrorMessage(type)); - }, - onCopy: (type) => { - switch (type) { - case 'pdf': - services.notify.info(strings.getCopyPDFMessage()); - break; - case 'reportingConfig': - services.notify.info(strings.getCopyReportingConfigMessage()); - break; - default: - throw new Error(strings.getUnknownExportErrorMessage(type)); - } - }, - onExport: (type) => { - switch (type) { - case 'pdf': - return createPdf(workpad, { pageCount }, services.platform.getBasePathInterface()) - .then(({ data }: { data: { job: { id: string } } }) => { - services.notify.info(strings.getExportPDFMessage(), { - title: strings.getExportPDFTitle(workpad.name), - }); - - // register the job so a completion notification shows up when it's ready - jobCompletionNotifications.add(data.job.id); - }) - .catch((err: Error) => { - services.notify.error(err, { - title: strings.getExportPDFErrorTitle(workpad.name), - }); - }); - case 'json': - downloadWorkpad(workpad.id); - return; - default: - throw new Error(strings.getUnknownExportErrorMessage(type)); - } - }, - }) - ) -)(Component); +export { ShareMenu } from './share_menu'; +export { ShareMenu as ShareMenuComponent } from './share_menu.component'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.component.tsx similarity index 100% rename from x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx rename to x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.component.tsx diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts new file mode 100644 index 0000000000000..85c4b14a28c13 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/share_menu.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { compose, withProps } from 'recompose'; +import { jobCompletionNotifications } from '../../../../../../plugins/reporting/public'; +import { getWorkpad, getPages } from '../../../state/selectors/workpad'; +import { getWindow } from '../../../lib/get_window'; +import { downloadWorkpad } from '../../../lib/download_workpad'; +import { ShareMenu as Component, Props as ComponentProps } from './share_menu.component'; +import { getPdfUrl, createPdf } from './utils'; +import { State, CanvasWorkpad } from '../../../../types'; +import { withServices, WithServicesProps } from '../../../services'; + +import { ComponentStrings } from '../../../../i18n'; + +const { WorkpadHeaderShareMenu: strings } = ComponentStrings; + +const mapStateToProps = (state: State) => ({ + workpad: getWorkpad(state), + pageCount: getPages(state).length, +}); + +const getAbsoluteUrl = (path: string) => { + const { location } = getWindow(); + + if (!location) { + return path; + } // fallback for mocked window object + + const { protocol, hostname, port } = location; + return `${protocol}//${hostname}:${port}${path}`; +}; + +interface Props { + workpad: CanvasWorkpad; + pageCount: number; +} + +export const ShareMenu = compose( + connect(mapStateToProps), + withServices, + withProps( + ({ workpad, pageCount, services }: Props & WithServicesProps): ComponentProps => ({ + getExportUrl: (type) => { + if (type === 'pdf') { + const pdfUrl = getPdfUrl( + workpad, + { pageCount }, + services.platform.getBasePathInterface() + ); + return getAbsoluteUrl(pdfUrl); + } + + throw new Error(strings.getUnknownExportErrorMessage(type)); + }, + onCopy: (type) => { + switch (type) { + case 'pdf': + services.notify.info(strings.getCopyPDFMessage()); + break; + case 'reportingConfig': + services.notify.info(strings.getCopyReportingConfigMessage()); + break; + default: + throw new Error(strings.getUnknownExportErrorMessage(type)); + } + }, + onExport: (type) => { + switch (type) { + case 'pdf': + return createPdf(workpad, { pageCount }, services.platform.getBasePathInterface()) + .then(({ data }: { data: { job: { id: string } } }) => { + services.notify.info(strings.getExportPDFMessage(), { + title: strings.getExportPDFTitle(workpad.name), + }); + + // register the job so a completion notification shows up when it's ready + jobCompletionNotifications.add(data.job.id); + }) + .catch((err: Error) => { + services.notify.error(err, { + title: strings.getExportPDFErrorTitle(workpad.name), + }); + }); + case 'json': + downloadWorkpad(workpad.id); + return; + default: + throw new Error(strings.getUnknownExportErrorMessage(type)); + } + }, + }) + ) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/view_menu/__examples__/view_menu.stories.tsx b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/__examples__/view_menu.stories.tsx index 5b4de05da3a3d..6b033feb26021 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/view_menu/__examples__/view_menu.stories.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/__examples__/view_menu.stories.tsx @@ -6,7 +6,7 @@ import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import React from 'react'; -import { ViewMenu } from '../view_menu'; +import { ViewMenu } from '../view_menu.component'; const handlers = { setZoomScale: action('setZoomScale'), diff --git a/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts index e2a05d13b017e..167b3822fd13d 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts @@ -4,97 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -import { connect } from 'react-redux'; -import { compose, withHandlers } from 'recompose'; -import { Dispatch } from 'redux'; -import { zoomHandlerCreators } from '../../../lib/app_handler_creators'; -import { State, CanvasWorkpadBoundingBox } from '../../../../types'; -// @ts-expect-error untyped local -import { fetchAllRenderables } from '../../../state/actions/elements'; -// @ts-expect-error untyped local -import { setZoomScale, setFullscreen, selectToplevelNodes } from '../../../state/actions/transient'; -import { - setWriteable, - setRefreshInterval, - enableAutoplay, - setAutoplayInterval, -} from '../../../state/actions/workpad'; -import { getZoomScale, canUserWrite } from '../../../state/selectors/app'; -import { - getWorkpadBoundingBox, - getWorkpadWidth, - getWorkpadHeight, - isWriteable, - getRefreshInterval, - getAutoplay, -} from '../../../state/selectors/workpad'; -import { ViewMenu as Component, Props as ComponentProps } from './view_menu'; -import { getFitZoomScale } from './lib/get_fit_zoom_scale'; - -interface StateProps { - zoomScale: number; - boundingBox: CanvasWorkpadBoundingBox; - workpadWidth: number; - workpadHeight: number; - isWriteable: boolean; -} - -interface DispatchProps { - setWriteable: (isWorkpadWriteable: boolean) => void; - setZoomScale: (scale: number) => void; - setFullscreen: (showFullscreen: boolean) => void; -} - -const mapStateToProps = (state: State) => { - const { enabled, interval } = getAutoplay(state); - - return { - zoomScale: getZoomScale(state), - boundingBox: getWorkpadBoundingBox(state), - workpadWidth: getWorkpadWidth(state), - workpadHeight: getWorkpadHeight(state), - isWriteable: isWriteable(state) && canUserWrite(state), - refreshInterval: getRefreshInterval(state), - autoplayEnabled: enabled, - autoplayInterval: interval, - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch) => ({ - setZoomScale: (scale: number) => dispatch(setZoomScale(scale)), - setWriteable: (isWorkpadWriteable: boolean) => dispatch(setWriteable(isWorkpadWriteable)), - setFullscreen: (value: boolean) => { - dispatch(setFullscreen(value)); - - if (value) { - dispatch(selectToplevelNodes([])); - } - }, - doRefresh: () => dispatch(fetchAllRenderables()), - setRefreshInterval: (interval: number) => dispatch(setRefreshInterval(interval)), - enableAutoplay: (autoplay: number) => dispatch(enableAutoplay(!!autoplay)), - setAutoplayInterval: (interval: number) => dispatch(setAutoplayInterval(interval)), -}); - -const mergeProps = ( - stateProps: StateProps, - dispatchProps: DispatchProps, - ownProps: ComponentProps -): ComponentProps => { - const { boundingBox, workpadWidth, workpadHeight, ...remainingStateProps } = stateProps; - - return { - ...remainingStateProps, - ...dispatchProps, - ...ownProps, - toggleWriteable: () => dispatchProps.setWriteable(!stateProps.isWriteable), - enterFullscreen: () => dispatchProps.setFullscreen(true), - fitToWindow: () => - dispatchProps.setZoomScale(getFitZoomScale(boundingBox, workpadWidth, workpadHeight)), - }; -}; - -export const ViewMenu = compose( - connect(mapStateToProps, mapDispatchToProps, mergeProps), - withHandlers(zoomHandlerCreators) -)(Component); +export { ViewMenu } from './view_menu'; +export { ViewMenu as ViewMenuComponent } from './view_menu.component'; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/view_menu/view_menu.tsx b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/view_menu.component.tsx similarity index 100% rename from x-pack/plugins/canvas/public/components/workpad_header/view_menu/view_menu.tsx rename to x-pack/plugins/canvas/public/components/workpad_header/view_menu/view_menu.component.tsx diff --git a/x-pack/plugins/canvas/public/components/workpad_header/view_menu/view_menu.ts b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/view_menu.ts new file mode 100644 index 0000000000000..c9650a35ea2a6 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/view_menu.ts @@ -0,0 +1,100 @@ +/* + * 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 { connect } from 'react-redux'; +import { compose, withHandlers } from 'recompose'; +import { Dispatch } from 'redux'; +import { zoomHandlerCreators } from '../../../lib/app_handler_creators'; +import { State, CanvasWorkpadBoundingBox } from '../../../../types'; +// @ts-expect-error untyped local +import { fetchAllRenderables } from '../../../state/actions/elements'; +// @ts-expect-error untyped local +import { setZoomScale, setFullscreen, selectToplevelNodes } from '../../../state/actions/transient'; +import { + setWriteable, + setRefreshInterval, + enableAutoplay, + setAutoplayInterval, +} from '../../../state/actions/workpad'; +import { getZoomScale, canUserWrite } from '../../../state/selectors/app'; +import { + getWorkpadBoundingBox, + getWorkpadWidth, + getWorkpadHeight, + isWriteable, + getRefreshInterval, + getAutoplay, +} from '../../../state/selectors/workpad'; +import { ViewMenu as Component, Props as ComponentProps } from './view_menu.component'; +import { getFitZoomScale } from './lib/get_fit_zoom_scale'; + +interface StateProps { + zoomScale: number; + boundingBox: CanvasWorkpadBoundingBox; + workpadWidth: number; + workpadHeight: number; + isWriteable: boolean; +} + +interface DispatchProps { + setWriteable: (isWorkpadWriteable: boolean) => void; + setZoomScale: (scale: number) => void; + setFullscreen: (showFullscreen: boolean) => void; +} + +const mapStateToProps = (state: State) => { + const { enabled, interval } = getAutoplay(state); + + return { + zoomScale: getZoomScale(state), + boundingBox: getWorkpadBoundingBox(state), + workpadWidth: getWorkpadWidth(state), + workpadHeight: getWorkpadHeight(state), + isWriteable: isWriteable(state) && canUserWrite(state), + refreshInterval: getRefreshInterval(state), + autoplayEnabled: enabled, + autoplayInterval: interval, + }; +}; + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + setZoomScale: (scale: number) => dispatch(setZoomScale(scale)), + setWriteable: (isWorkpadWriteable: boolean) => dispatch(setWriteable(isWorkpadWriteable)), + setFullscreen: (value: boolean) => { + dispatch(setFullscreen(value)); + + if (value) { + dispatch(selectToplevelNodes([])); + } + }, + doRefresh: () => dispatch(fetchAllRenderables()), + setRefreshInterval: (interval: number) => dispatch(setRefreshInterval(interval)), + enableAutoplay: (autoplay: number) => dispatch(enableAutoplay(!!autoplay)), + setAutoplayInterval: (interval: number) => dispatch(setAutoplayInterval(interval)), +}); + +const mergeProps = ( + stateProps: StateProps, + dispatchProps: DispatchProps, + ownProps: ComponentProps +): ComponentProps => { + const { boundingBox, workpadWidth, workpadHeight, ...remainingStateProps } = stateProps; + + return { + ...remainingStateProps, + ...dispatchProps, + ...ownProps, + toggleWriteable: () => dispatchProps.setWriteable(!stateProps.isWriteable), + enterFullscreen: () => dispatchProps.setFullscreen(true), + fitToWindow: () => + dispatchProps.setZoomScale(getFitZoomScale(boundingBox, workpadWidth, workpadHeight)), + }; +}; + +export const ViewMenu = compose( + connect(mapStateToProps, mapDispatchToProps, mergeProps), + withHandlers(zoomHandlerCreators) +)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx new file mode 100644 index 0000000000000..eb4b451896b46 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.component.tsx @@ -0,0 +1,150 @@ +/* + * 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, { FunctionComponent } from 'react'; +import PropTypes from 'prop-types'; +// @ts-expect-error no @types definition +import { Shortcuts } from 'react-shortcuts'; +import { EuiFlexItem, EuiFlexGroup, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { ComponentStrings } from '../../../i18n'; +import { ToolTipShortcut } from '../tool_tip_shortcut/'; +import { RefreshControl } from './refresh_control'; +// @ts-expect-error untyped local +import { FullscreenControl } from './fullscreen_control'; +import { EditMenu } from './edit_menu'; +import { ElementMenu } from './element_menu'; +import { ShareMenu } from './share_menu'; +import { ViewMenu } from './view_menu'; + +const { WorkpadHeader: strings } = ComponentStrings; + +export interface Props { + isWriteable: boolean; + toggleWriteable: () => void; + canUserWrite: boolean; + commit: (type: string, payload: any) => any; +} + +export const WorkpadHeader: FunctionComponent = ({ + isWriteable, + canUserWrite, + toggleWriteable, + commit, +}) => { + const keyHandler = (action: string) => { + if (action === 'EDITING') { + toggleWriteable(); + } + }; + + const fullscreenButton = ({ toggleFullscreen }: { toggleFullscreen: () => void }) => ( + + {strings.getFullScreenTooltip()}{' '} + + + } + > + + + ); + + const getEditToggleToolTipText = () => { + if (!canUserWrite) { + return strings.getNoWritePermissionTooltipText(); + } + + const content = isWriteable + ? strings.getHideEditControlTooltip() + : strings.getShowEditControlTooltip(); + + return content; + }; + + const getEditToggleToolTip = ({ textOnly } = { textOnly: false }) => { + const content = getEditToggleToolTipText(); + + if (textOnly) { + return content; + } + + return ( + + {content} + + ); + }; + + return ( + + + + {isWriteable && ( + + + + )} + + + + + + + + + + + + + + + {canUserWrite && ( + + )} + + + + + + + + + {fullscreenButton} + + + + + ); +}; + +WorkpadHeader.propTypes = { + isWriteable: PropTypes.bool, + toggleWriteable: PropTypes.func, + canUserWrite: PropTypes.bool, +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.tsx b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.tsx index eb4b451896b46..1f630040b0c36 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/workpad_header.tsx @@ -4,147 +4,43 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FunctionComponent } from 'react'; -import PropTypes from 'prop-types'; -// @ts-expect-error no @types definition -import { Shortcuts } from 'react-shortcuts'; -import { EuiFlexItem, EuiFlexGroup, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; -import { ComponentStrings } from '../../../i18n'; -import { ToolTipShortcut } from '../tool_tip_shortcut/'; -import { RefreshControl } from './refresh_control'; -// @ts-expect-error untyped local -import { FullscreenControl } from './fullscreen_control'; -import { EditMenu } from './edit_menu'; -import { ElementMenu } from './element_menu'; -import { ShareMenu } from './share_menu'; -import { ViewMenu } from './view_menu'; - -const { WorkpadHeader: strings } = ComponentStrings; - -export interface Props { +import { connect } from 'react-redux'; +import { Dispatch } from 'redux'; +import { canUserWrite } from '../../state/selectors/app'; +import { getSelectedPage, isWriteable } from '../../state/selectors/workpad'; +import { setWriteable } from '../../state/actions/workpad'; +import { State } from '../../../types'; +import { WorkpadHeader as Component, Props as ComponentProps } from './workpad_header.component'; + +interface StateProps { isWriteable: boolean; - toggleWriteable: () => void; canUserWrite: boolean; - commit: (type: string, payload: any) => any; + selectedPage: string; } -export const WorkpadHeader: FunctionComponent = ({ - isWriteable, - canUserWrite, - toggleWriteable, - commit, -}) => { - const keyHandler = (action: string) => { - if (action === 'EDITING') { - toggleWriteable(); - } - }; - - const fullscreenButton = ({ toggleFullscreen }: { toggleFullscreen: () => void }) => ( - - {strings.getFullScreenTooltip()}{' '} - - - } - > - - - ); - - const getEditToggleToolTipText = () => { - if (!canUserWrite) { - return strings.getNoWritePermissionTooltipText(); - } - - const content = isWriteable - ? strings.getHideEditControlTooltip() - : strings.getShowEditControlTooltip(); - - return content; - }; - - const getEditToggleToolTip = ({ textOnly } = { textOnly: false }) => { - const content = getEditToggleToolTipText(); - - if (textOnly) { - return content; - } - - return ( - - {content} - - ); - }; - - return ( - - - - {isWriteable && ( - - - - )} - - - - - - - - - - - - - - - {canUserWrite && ( - - )} - - - - - - - - - {fullscreenButton} - - - - - ); -}; +interface DispatchProps { + setWriteable: (isWorkpadWriteable: boolean) => void; +} -WorkpadHeader.propTypes = { - isWriteable: PropTypes.bool, - toggleWriteable: PropTypes.func, - canUserWrite: PropTypes.bool, -}; +const mapStateToProps = (state: State): StateProps => ({ + isWriteable: isWriteable(state) && canUserWrite(state), + canUserWrite: canUserWrite(state), + selectedPage: getSelectedPage(state), +}); + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + setWriteable: (isWorkpadWriteable: boolean) => dispatch(setWriteable(isWorkpadWriteable)), +}); + +const mergeProps = ( + stateProps: StateProps, + dispatchProps: DispatchProps, + ownProps: ComponentProps +): ComponentProps => ({ + ...stateProps, + ...dispatchProps, + ...ownProps, + toggleWriteable: () => dispatchProps.setWriteable(!stateProps.isWriteable), +}); + +export const WorkpadHeader = connect(mapStateToProps, mapDispatchToProps, mergeProps)(Component); diff --git a/x-pack/plugins/canvas/storybook/storyshots.test.js b/x-pack/plugins/canvas/storybook/storyshots.test.js index e3a9654bb49fa..dbcbbff6398b5 100644 --- a/x-pack/plugins/canvas/storybook/storyshots.test.js +++ b/x-pack/plugins/canvas/storybook/storyshots.test.js @@ -73,7 +73,7 @@ jest.mock('@elastic/eui/lib/components/overlay_mask/overlay_mask', () => { // Disabling this test due to https://github.com/elastic/eui/issues/2242 jest.mock( - '../public/components/workpad_header/share_menu/flyout/__examples__/share_website_flyout.stories', + '../public/components/workpad_header/share_menu/flyout/__examples__/flyout.stories', () => { return 'Disabled Panel'; } From b0d51c8e0a80d34b5356bd4a5c746834a1f5c055 Mon Sep 17 00:00:00 2001 From: Brent Kimmel Date: Fri, 31 Jul 2020 12:01:11 -0400 Subject: [PATCH 15/29] Resolver/test panel presence (#73889) * Test for panel presence --- .../resolver/test_utilities/simulator/index.tsx | 14 ++++++++++++++ .../public/resolver/view/clickthrough.test.tsx | 10 ++++++++++ .../public/resolver/view/panel.tsx | 2 +- .../view/panels/panel_content_process_list.tsx | 7 ++++++- 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx index 7a61427c56a3b..2a2354921a3d4 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx @@ -251,6 +251,20 @@ export class Simulator { return this.findInDOM('[data-test-subj="resolver:graph"]'); } + /** + * The outer panel container. + */ + public panelElement(): ReactWrapper { + return this.findInDOM('[data-test-subj="resolver:panel"]'); + } + + /** + * The panel content element (which may include tables, lists, other data depending on the view). + */ + public panelContentElement(): ReactWrapper { + return this.findInDOM('[data-test-subj^="resolver:panel:"]'); + } + /** * Like `this.wrapper.find` but only returns DOM nodes. */ diff --git a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx index 9cb900736677e..f339d128944cc 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx @@ -63,6 +63,16 @@ describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', ( expect(simulator.processNodeElements().length).toBe(3); }); + it(`should have the default "process list" panel present`, async () => { + expect(simulator.panelElement().length).toBe(1); + expect(simulator.panelContentElement().length).toBe(1); + const testSubjectName = simulator + .panelContentElement() + .getDOMNode() + .getAttribute('data-test-subj'); + expect(testSubjectName).toMatch(/process-list/g); + }); + describe("when the second child node's first button has been clicked", () => { beforeEach(() => { // Click the first button under the second child element. diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.tsx index 83d3930065da6..f378ab36bac94 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.tsx @@ -220,7 +220,7 @@ PanelContent.displayName = 'PanelContent'; export const Panel = memo(function Event({ className }: { className?: string }) { return ( - + ); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx index efb96cde431e5..8ca002ace26fe 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx @@ -187,7 +187,12 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({ {showWarning && } - items={processTableView} columns={columns} sorting /> + + data-test-subj="resolver:panel:process-list" + items={processTableView} + columns={columns} + sorting + /> ); }); From 69844e45eb8247344ea913d006680deced5d3b34 Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Fri, 31 Jul 2020 11:22:14 -0500 Subject: [PATCH 16/29] [ML] Add API integration testing for AD annotations (#73068) Co-authored-by: Elastic Machine --- .../apis/ml/annotations/common_jobs.ts | 58 ++++++ .../apis/ml/annotations/create_annotations.ts | 89 +++++++++ .../apis/ml/annotations/delete_annotations.ts | 91 +++++++++ .../apis/ml/annotations/get_annotations.ts | 130 +++++++++++++ .../apis/ml/annotations/index.ts | 16 ++ .../apis/ml/annotations/update_annotations.ts | 175 ++++++++++++++++++ x-pack/test/api_integration/apis/ml/index.ts | 1 + x-pack/test/functional/services/ml/api.ts | 88 +++++++++ 8 files changed, 648 insertions(+) create mode 100644 x-pack/test/api_integration/apis/ml/annotations/common_jobs.ts create mode 100644 x-pack/test/api_integration/apis/ml/annotations/create_annotations.ts create mode 100644 x-pack/test/api_integration/apis/ml/annotations/delete_annotations.ts create mode 100644 x-pack/test/api_integration/apis/ml/annotations/get_annotations.ts create mode 100644 x-pack/test/api_integration/apis/ml/annotations/index.ts create mode 100644 x-pack/test/api_integration/apis/ml/annotations/update_annotations.ts diff --git a/x-pack/test/api_integration/apis/ml/annotations/common_jobs.ts b/x-pack/test/api_integration/apis/ml/annotations/common_jobs.ts new file mode 100644 index 0000000000000..873cdc5d71baa --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/annotations/common_jobs.ts @@ -0,0 +1,58 @@ +/* + * 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 { ANNOTATION_TYPE } from '../../../../../plugins/ml/common/constants/annotations'; +import { Annotation } from '../../../../../plugins/ml/common/types/annotations'; + +export const commonJobConfig = { + description: 'test_job_annotation', + groups: ['farequote', 'automated', 'single-metric'], + analysis_config: { + bucket_span: '15m', + influencers: [], + detectors: [ + { + function: 'mean', + field_name: 'responsetime', + }, + { + function: 'min', + field_name: 'responsetime', + }, + ], + }, + data_description: { time_field: '@timestamp' }, + analysis_limits: { model_memory_limit: '10mb' }, +}; + +export const createJobConfig = (jobId: string) => { + return { ...commonJobConfig, job_id: jobId }; +}; + +export const testSetupJobConfigs = [1, 2, 3, 4].map((num) => ({ + ...commonJobConfig, + job_id: `job_annotation_${num}_${Date.now()}`, + description: `Test annotation ${num}`, +})); +export const jobIds = testSetupJobConfigs.map((j) => j.job_id); + +export const createAnnotationRequestBody = (jobId: string): Partial => { + return { + timestamp: Date.now(), + end_timestamp: Date.now(), + annotation: 'Test annotation', + job_id: jobId, + type: ANNOTATION_TYPE.ANNOTATION, + event: 'user', + detector_index: 1, + partition_field_name: 'airline', + partition_field_value: 'AAL', + }; +}; + +export const testSetupAnnotations = testSetupJobConfigs.map((job) => + createAnnotationRequestBody(job.job_id) +); diff --git a/x-pack/test/api_integration/apis/ml/annotations/create_annotations.ts b/x-pack/test/api_integration/apis/ml/annotations/create_annotations.ts new file mode 100644 index 0000000000000..14ecf1bfe524e --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/annotations/create_annotations.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { Annotation } from '../../../../../plugins/ml/common/types/annotations'; +import { createJobConfig, createAnnotationRequestBody } from './common_jobs'; +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + const jobId = `job_annotation_${Date.now()}`; + const testJobConfig = createJobConfig(jobId); + const annotationRequestBody = createAnnotationRequestBody(jobId); + + describe('create_annotations', function () { + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await ml.testResources.setKibanaTimeZoneToUTC(); + await ml.api.createAnomalyDetectionJob(testJobConfig); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + }); + + it('should successfully create annotations for anomaly job', async () => { + const { body } = await supertest + .put('/api/ml/annotations/index') + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send(annotationRequestBody) + .expect(200); + const annotationId = body._id; + + const fetchedAnnotation = await ml.api.getAnnotationById(annotationId); + + expect(fetchedAnnotation).to.not.be(undefined); + + if (fetchedAnnotation) { + Object.keys(annotationRequestBody).forEach((key) => { + const field = key as keyof Annotation; + expect(fetchedAnnotation[field]).to.eql(annotationRequestBody[field]); + }); + } + expect(fetchedAnnotation?.create_username).to.eql(USER.ML_POWERUSER); + }); + + it('should successfully create annotation for user with ML read permissions', async () => { + const { body } = await supertest + .put('/api/ml/annotations/index') + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .send(annotationRequestBody) + .expect(200); + + const annotationId = body._id; + const fetchedAnnotation = await ml.api.getAnnotationById(annotationId); + expect(fetchedAnnotation).to.not.be(undefined); + if (fetchedAnnotation) { + Object.keys(annotationRequestBody).forEach((key) => { + const field = key as keyof Annotation; + expect(fetchedAnnotation[field]).to.eql(annotationRequestBody[field]); + }); + } + expect(fetchedAnnotation?.create_username).to.eql(USER.ML_VIEWER); + }); + + it('should not allow to create annotation for unauthorized user', async () => { + const { body } = await supertest + .put('/api/ml/annotations/index') + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .send(annotationRequestBody) + .expect(404); + + expect(body.error).to.eql('Not Found'); + expect(body.message).to.eql('Not Found'); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/annotations/delete_annotations.ts b/x-pack/test/api_integration/apis/ml/annotations/delete_annotations.ts new file mode 100644 index 0000000000000..4fbb26e9b5a3e --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/annotations/delete_annotations.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { testSetupJobConfigs, jobIds, testSetupAnnotations } from './common_jobs'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + describe('delete_annotations', function () { + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await ml.testResources.setKibanaTimeZoneToUTC(); + + // generate one annotation for each job + for (let i = 0; i < testSetupJobConfigs.length; i++) { + const job = testSetupJobConfigs[i]; + const annotationToIndex = testSetupAnnotations[i]; + await ml.api.createAnomalyDetectionJob(job); + await ml.api.indexAnnotation(annotationToIndex); + } + }); + + after(async () => { + await ml.api.cleanMlIndices(); + }); + + it('should delete annotation by id', async () => { + const annotationsForJob = await ml.api.getAnnotations(jobIds[0]); + expect(annotationsForJob).to.have.length(1); + + const annotationIdToDelete = annotationsForJob[0]._id; + + const { body } = await supertest + .delete(`/api/ml/annotations/delete/${annotationIdToDelete}`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .expect(200); + + expect(body._id).to.eql(annotationIdToDelete); + expect(body.result).to.eql('deleted'); + + await ml.api.waitForAnnotationNotToExist(annotationIdToDelete); + }); + + it('should delete annotation by id for user with viewer permission', async () => { + const annotationsForJob = await ml.api.getAnnotations(jobIds[1]); + expect(annotationsForJob).to.have.length(1); + + const annotationIdToDelete = annotationsForJob[0]._id; + + const { body } = await supertest + .delete(`/api/ml/annotations/delete/${annotationIdToDelete}`) + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .expect(200); + + expect(body._id).to.eql(annotationIdToDelete); + expect(body.result).to.eql('deleted'); + + await ml.api.waitForAnnotationNotToExist(annotationIdToDelete); + }); + + it('should not delete annotation for unauthorized user', async () => { + const annotationsForJob = await ml.api.getAnnotations(jobIds[2]); + expect(annotationsForJob).to.have.length(1); + + const annotationIdToDelete = annotationsForJob[0]._id; + + const { body } = await supertest + .delete(`/api/ml/annotations/delete/${annotationIdToDelete}`) + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .expect(404); + + expect(body.error).to.eql('Not Found'); + expect(body.message).to.eql('Not Found'); + + await ml.api.waitForAnnotationToExist(annotationIdToDelete); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/annotations/get_annotations.ts b/x-pack/test/api_integration/apis/ml/annotations/get_annotations.ts new file mode 100644 index 0000000000000..710473eed6901 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/annotations/get_annotations.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { omit } from 'lodash'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { testSetupJobConfigs, jobIds, testSetupAnnotations } from './common_jobs'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + describe('get_annotations', function () { + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await ml.testResources.setKibanaTimeZoneToUTC(); + + // generate one annotation for each job + for (let i = 0; i < testSetupJobConfigs.length; i++) { + const job = testSetupJobConfigs[i]; + const annotationToIndex = testSetupAnnotations[i]; + await ml.api.createAnomalyDetectionJob(job); + await ml.api.indexAnnotation(annotationToIndex); + } + }); + + after(async () => { + await ml.api.cleanMlIndices(); + }); + + it('should fetch all annotations for jobId', async () => { + const requestBody = { + jobIds: [jobIds[0]], + earliestMs: 1454804100000, + latestMs: Date.now(), + maxAnnotations: 500, + }; + const { body } = await supertest + .post('/api/ml/annotations') + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(200); + + expect(body.success).to.eql(true); + expect(body.annotations).not.to.be(undefined); + [jobIds[0]].forEach((jobId, idx) => { + expect(body.annotations).to.have.property(jobId); + expect(body.annotations[jobId]).to.have.length(1); + + const indexedAnnotation = omit(body.annotations[jobId][0], '_id'); + expect(indexedAnnotation).to.eql(testSetupAnnotations[idx]); + }); + }); + + it('should fetch all annotations for multiple jobs', async () => { + const requestBody = { + jobIds, + earliestMs: 1454804100000, + latestMs: Date.now(), + maxAnnotations: 500, + }; + const { body } = await supertest + .post('/api/ml/annotations') + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(200); + + expect(body.success).to.eql(true); + expect(body.annotations).not.to.be(undefined); + jobIds.forEach((jobId, idx) => { + expect(body.annotations).to.have.property(jobId); + expect(body.annotations[jobId]).to.have.length(1); + + const indexedAnnotation = omit(body.annotations[jobId][0], '_id'); + expect(indexedAnnotation).to.eql(testSetupAnnotations[idx]); + }); + }); + + it('should fetch all annotations for user with ML read permissions', async () => { + const requestBody = { + jobIds: testSetupJobConfigs.map((j) => j.job_id), + earliestMs: 1454804100000, + latestMs: Date.now(), + maxAnnotations: 500, + }; + const { body } = await supertest + .post('/api/ml/annotations') + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(200); + expect(body.success).to.eql(true); + expect(body.annotations).not.to.be(undefined); + jobIds.forEach((jobId, idx) => { + expect(body.annotations).to.have.property(jobId); + expect(body.annotations[jobId]).to.have.length(1); + + const indexedAnnotation = omit(body.annotations[jobId][0], '_id'); + expect(indexedAnnotation).to.eql(testSetupAnnotations[idx]); + }); + }); + + it('should not allow to fetch annotation for unauthorized user', async () => { + const requestBody = { + jobIds: testSetupJobConfigs.map((j) => j.job_id), + earliestMs: 1454804100000, + latestMs: Date.now(), + maxAnnotations: 500, + }; + const { body } = await supertest + .post('/api/ml/annotations') + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(404); + + expect(body.error).to.eql('Not Found'); + expect(body.message).to.eql('Not Found'); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/annotations/index.ts b/x-pack/test/api_integration/apis/ml/annotations/index.ts new file mode 100644 index 0000000000000..7d73ee43d4d99 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/annotations/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('annotations', function () { + loadTestFile(require.resolve('./create_annotations')); + loadTestFile(require.resolve('./get_annotations')); + loadTestFile(require.resolve('./delete_annotations')); + loadTestFile(require.resolve('./update_annotations')); + }); +} diff --git a/x-pack/test/api_integration/apis/ml/annotations/update_annotations.ts b/x-pack/test/api_integration/apis/ml/annotations/update_annotations.ts new file mode 100644 index 0000000000000..ba73617151120 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/annotations/update_annotations.ts @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { ANNOTATION_TYPE } from '../../../../../plugins/ml/common/constants/annotations'; +import { Annotation } from '../../../../../plugins/ml/common/types/annotations'; +import { testSetupJobConfigs, jobIds, testSetupAnnotations } from './common_jobs'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + const commonAnnotationUpdateRequestBody: Partial = { + timestamp: Date.now(), + end_timestamp: Date.now(), + annotation: 'Updated annotation', + type: ANNOTATION_TYPE.ANNOTATION, + event: 'model_change', + detector_index: 2, + partition_field_name: 'airline', + partition_field_value: 'ANA', + }; + + describe('update_annotations', function () { + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await ml.testResources.setKibanaTimeZoneToUTC(); + + // generate one annotation for each job + for (let i = 0; i < testSetupJobConfigs.length; i++) { + const job = testSetupJobConfigs[i]; + const annotationToIndex = testSetupAnnotations[i]; + await ml.api.createAnomalyDetectionJob(job); + await ml.api.indexAnnotation(annotationToIndex); + } + }); + + after(async () => { + await ml.api.cleanMlIndices(); + }); + + it('should correctly update annotation by id', async () => { + const annotationsForJob = await ml.api.getAnnotations(jobIds[0]); + expect(annotationsForJob).to.have.length(1); + + const originalAnnotation = annotationsForJob[0]; + const annotationUpdateRequestBody = { + ...commonAnnotationUpdateRequestBody, + job_id: originalAnnotation._source.job_id, + _id: originalAnnotation._id, + }; + + const { body } = await supertest + .put('/api/ml/annotations/index') + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send(annotationUpdateRequestBody) + .expect(200); + + expect(body._id).to.eql(originalAnnotation._id); + expect(body.result).to.eql('updated'); + + const updatedAnnotation = await ml.api.getAnnotationById(originalAnnotation._id); + + if (updatedAnnotation) { + Object.keys(commonAnnotationUpdateRequestBody).forEach((key) => { + const field = key as keyof Annotation; + expect(updatedAnnotation[field]).to.eql(annotationUpdateRequestBody[field]); + }); + } + }); + + it('should correctly update annotation for user with viewer permission', async () => { + const annotationsForJob = await ml.api.getAnnotations(jobIds[1]); + expect(annotationsForJob).to.have.length(1); + + const originalAnnotation = annotationsForJob[0]; + const annotationUpdateRequestBody = { + ...commonAnnotationUpdateRequestBody, + job_id: originalAnnotation._source.job_id, + _id: originalAnnotation._id, + }; + + const { body } = await supertest + .put('/api/ml/annotations/index') + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .send(annotationUpdateRequestBody) + .expect(200); + + expect(body._id).to.eql(originalAnnotation._id); + expect(body.result).to.eql('updated'); + + const updatedAnnotation = await ml.api.getAnnotationById(originalAnnotation._id); + if (updatedAnnotation) { + Object.keys(commonAnnotationUpdateRequestBody).forEach((key) => { + const field = key as keyof Annotation; + expect(updatedAnnotation[field]).to.eql(annotationUpdateRequestBody[field]); + }); + } + }); + + it('should not update annotation for unauthorized user', async () => { + const annotationsForJob = await ml.api.getAnnotations(jobIds[2]); + expect(annotationsForJob).to.have.length(1); + + const originalAnnotation = annotationsForJob[0]; + + const annotationUpdateRequestBody = { + ...commonAnnotationUpdateRequestBody, + job_id: originalAnnotation._source.job_id, + _id: originalAnnotation._id, + }; + + const { body } = await supertest + .put('/api/ml/annotations/index') + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .send(annotationUpdateRequestBody) + .expect(404); + + expect(body.error).to.eql('Not Found'); + expect(body.message).to.eql('Not Found'); + + const updatedAnnotation = await ml.api.getAnnotationById(originalAnnotation._id); + expect(updatedAnnotation).to.eql(originalAnnotation._source); + }); + + it('should override fields correctly', async () => { + const annotationsForJob = await ml.api.getAnnotations(jobIds[3]); + expect(annotationsForJob).to.have.length(1); + + const originalAnnotation = annotationsForJob[0]; + const annotationUpdateRequestBodyWithMissingFields: Partial = { + timestamp: Date.now(), + end_timestamp: Date.now(), + annotation: 'Updated annotation', + job_id: originalAnnotation._source.job_id, + type: ANNOTATION_TYPE.ANNOTATION, + event: 'model_change', + detector_index: 2, + _id: originalAnnotation._id, + }; + await supertest + .put('/api/ml/annotations/index') + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send(annotationUpdateRequestBodyWithMissingFields) + .expect(200); + + const updatedAnnotation = await ml.api.getAnnotationById(originalAnnotation._id); + if (updatedAnnotation) { + Object.keys(annotationUpdateRequestBodyWithMissingFields).forEach((key) => { + if (key !== '_id') { + const field = key as keyof Annotation; + expect(updatedAnnotation[field]).to.eql( + annotationUpdateRequestBodyWithMissingFields[field] + ); + } + }); + } + // validate missing fields in the annotationUpdateRequestBody + expect(updatedAnnotation?.partition_field_name).to.be(undefined); + expect(updatedAnnotation?.partition_field_value).to.be(undefined); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/index.ts b/x-pack/test/api_integration/apis/ml/index.ts index b29bc47b50394..969f291b0d8b3 100644 --- a/x-pack/test/api_integration/apis/ml/index.ts +++ b/x-pack/test/api_integration/apis/ml/index.ts @@ -60,5 +60,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./data_frame_analytics')); loadTestFile(require.resolve('./filters')); loadTestFile(require.resolve('./calendars')); + loadTestFile(require.resolve('./annotations')); }); } diff --git a/x-pack/test/functional/services/ml/api.ts b/x-pack/test/functional/services/ml/api.ts index 9dfec3a17dec0..401a96c5c11bd 100644 --- a/x-pack/test/functional/services/ml/api.ts +++ b/x-pack/test/functional/services/ml/api.ts @@ -5,13 +5,29 @@ */ import expect from '@kbn/expect'; import { ProvidedType } from '@kbn/test/types/ftr'; +import { IndexDocumentParams } from 'elasticsearch'; import { Calendar, CalendarEvent } from '../../../../plugins/ml/server/models/calendar/index'; +import { Annotation } from '../../../../plugins/ml/common/types/annotations'; import { DataFrameAnalyticsConfig } from '../../../../plugins/ml/public/application/data_frame_analytics/common'; import { FtrProviderContext } from '../../ftr_provider_context'; import { DATAFEED_STATE, JOB_STATE } from '../../../../plugins/ml/common/constants/states'; import { DATA_FRAME_TASK_STATE } from '../../../../plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common'; import { Datafeed, Job } from '../../../../plugins/ml/common/types/anomaly_detection_jobs'; export type MlApi = ProvidedType; +import { + ML_ANNOTATIONS_INDEX_ALIAS_READ, + ML_ANNOTATIONS_INDEX_ALIAS_WRITE, +} from '../../../../plugins/ml/common/constants/index_patterns'; + +interface EsIndexResult { + _index: string; + _id: string; + _version: number; + result: string; + _shards: any; + _seq_no: number; + _primary_term: number; +} export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { const es = getService('legacyEs'); @@ -634,5 +650,77 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { } }); }, + + async getAnnotations(jobId: string) { + log.debug(`Fetching annotations for job '${jobId}'...`); + + const results = await es.search({ + index: ML_ANNOTATIONS_INDEX_ALIAS_READ, + body: { + query: { + match: { + job_id: jobId, + }, + }, + }, + }); + expect(results).to.not.be(undefined); + expect(results).to.have.property('hits'); + return results.hits.hits; + }, + + async getAnnotationById(annotationId: string): Promise { + log.debug(`Fetching annotation '${annotationId}'...`); + + const result = await es.search({ + index: ML_ANNOTATIONS_INDEX_ALIAS_READ, + body: { + size: 1, + query: { + match: { + _id: annotationId, + }, + }, + }, + }); + // @ts-ignore due to outdated type for hits.total + if (result.hits.total.value === 1) { + return result?.hits?.hits[0]?._source as Annotation; + } + return undefined; + }, + + async indexAnnotation(annotationRequestBody: Partial) { + log.debug(`Indexing annotation '${JSON.stringify(annotationRequestBody)}'...`); + // @ts-ignore due to outdated type for IndexDocumentParams.type + const params: IndexDocumentParams> = { + index: ML_ANNOTATIONS_INDEX_ALIAS_WRITE, + body: annotationRequestBody, + refresh: 'wait_for', + }; + const results: EsIndexResult = await es.index(params); + await this.waitForAnnotationToExist(results._id); + return results; + }, + + async waitForAnnotationToExist(annotationId: string, errorMsg?: string) { + await retry.tryForTime(30 * 1000, async () => { + if ((await this.getAnnotationById(annotationId)) !== undefined) { + return true; + } else { + throw new Error(errorMsg ?? `annotation '${annotationId}' should exist`); + } + }); + }, + + async waitForAnnotationNotToExist(annotationId: string, errorMsg?: string) { + await retry.tryForTime(30 * 1000, async () => { + if ((await this.getAnnotationById(annotationId)) === undefined) { + return true; + } else { + throw new Error(errorMsg ?? `annotation '${annotationId}' should not exist`); + } + }); + }, }; } From 1c0b77c494c7d0a85a922f84597a5c3b2ecd4da8 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Fri, 31 Jul 2020 18:59:51 +0200 Subject: [PATCH 17/29] fix: pinned filters not applied (#73825) --- .../lens/public/app_plugin/app.test.tsx | 21 +++++++++++++++++++ x-pack/plugins/lens/public/app_plugin/app.tsx | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index a72f4f429a1be..b30a586487009 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -249,6 +249,27 @@ describe('Lens App', () => { expect(defaultArgs.data.query.filterManager.setAppFilters).toHaveBeenCalledWith([]); }); + it('passes global filters to frame', async () => { + const args = makeDefaultArgs(); + args.editorFrame = frame; + const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; + const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType; + const pinnedFilter = esFilters.buildExistsFilter(pinnedField, indexPattern); + args.data.query.filterManager.getFilters = jest.fn().mockImplementation(() => { + return [pinnedFilter]; + }); + const component = mount(); + component.update(); + expect(frame.mount).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + dateRange: { fromDate: 'now-7d', toDate: 'now' }, + query: { query: '', language: 'kuery' }, + filters: [pinnedFilter], + }) + ); + }); + it('sets breadcrumbs when the document title changes', async () => { const defaultArgs = makeDefaultArgs(); instance = mount(); diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 2a7eaff32fa08..ab4c4820315ac 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -94,7 +94,7 @@ export function App({ toDate: currentRange.to, }, originatingApp, - filters: [], + filters: data.query.filterManager.getFilters(), indicateNoData: false, }; }); From 002e4598a5de5e2e65d99b1f52d069a01c3ffbd9 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Fri, 31 Jul 2020 14:04:31 -0400 Subject: [PATCH 18/29] [Ingest Manager] Revert fleet config concurrency rollout to rate limit (#73940) --- .../ingest_manager/common/types/index.ts | 3 +- x-pack/plugins/ingest_manager/server/index.ts | 3 +- .../agents/checkin/rxjs_utils.test.ts | 45 ----------------- .../services/agents/checkin/rxjs_utils.ts | 50 +++++++++++++------ .../agents/checkin/state_new_actions.ts | 9 ++-- 5 files changed, 43 insertions(+), 67 deletions(-) delete mode 100644 x-pack/plugins/ingest_manager/server/services/agents/checkin/rxjs_utils.test.ts diff --git a/x-pack/plugins/ingest_manager/common/types/index.ts b/x-pack/plugins/ingest_manager/common/types/index.ts index 7acef263f973a..69bcc498c18be 100644 --- a/x-pack/plugins/ingest_manager/common/types/index.ts +++ b/x-pack/plugins/ingest_manager/common/types/index.ts @@ -22,7 +22,8 @@ export interface IngestManagerConfigType { host?: string; ca_sha256?: string; }; - agentConfigRolloutConcurrency: number; + agentConfigRolloutRateLimitIntervalMs: number; + agentConfigRolloutRateLimitRequestPerInterval: number; }; } diff --git a/x-pack/plugins/ingest_manager/server/index.ts b/x-pack/plugins/ingest_manager/server/index.ts index 6f8c4948559d3..e2f659f54d625 100644 --- a/x-pack/plugins/ingest_manager/server/index.ts +++ b/x-pack/plugins/ingest_manager/server/index.ts @@ -35,7 +35,8 @@ export const config = { host: schema.maybe(schema.string()), ca_sha256: schema.maybe(schema.string()), }), - agentConfigRolloutConcurrency: schema.number({ defaultValue: 10 }), + agentConfigRolloutRateLimitIntervalMs: schema.number({ defaultValue: 5000 }), + agentConfigRolloutRateLimitRequestPerInterval: schema.number({ defaultValue: 5 }), }), }), }; diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin/rxjs_utils.test.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin/rxjs_utils.test.ts deleted file mode 100644 index 70207dcf325c4..0000000000000 --- a/x-pack/plugins/ingest_manager/server/services/agents/checkin/rxjs_utils.test.ts +++ /dev/null @@ -1,45 +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 * as Rx from 'rxjs'; -import { share } from 'rxjs/operators'; -import { createSubscriberConcurrencyLimiter } from './rxjs_utils'; - -function createSpyObserver(o: Rx.Observable): [Rx.Subscription, jest.Mock] { - const spy = jest.fn(); - const observer = o.subscribe(spy); - return [observer, spy]; -} - -describe('createSubscriberConcurrencyLimiter', () => { - it('should not publish to more than n concurrent subscriber', async () => { - const subject = new Rx.Subject(); - const sharedObservable = subject.pipe(share()); - - const limiter = createSubscriberConcurrencyLimiter(2); - - const [observer1, spy1] = createSpyObserver(sharedObservable.pipe(limiter())); - const [observer2, spy2] = createSpyObserver(sharedObservable.pipe(limiter())); - const [observer3, spy3] = createSpyObserver(sharedObservable.pipe(limiter())); - const [observer4, spy4] = createSpyObserver(sharedObservable.pipe(limiter())); - subject.next('test1'); - - expect(spy1).toBeCalled(); - expect(spy2).toBeCalled(); - expect(spy3).not.toBeCalled(); - expect(spy4).not.toBeCalled(); - - observer1.unsubscribe(); - expect(spy3).toBeCalled(); - expect(spy4).not.toBeCalled(); - - observer2.unsubscribe(); - expect(spy4).toBeCalled(); - - observer3.unsubscribe(); - observer4.unsubscribe(); - }); -}); diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin/rxjs_utils.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin/rxjs_utils.ts index dc0ed35207e46..dddade6841460 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/checkin/rxjs_utils.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/checkin/rxjs_utils.ts @@ -43,23 +43,37 @@ export const toPromiseAbortable = ( } }); -export function createSubscriberConcurrencyLimiter(maxConcurrency: number) { - let observers: Array<[Rx.Subscriber, any]> = []; - let activeObservers: Array> = []; +export function createRateLimiter( + ratelimitIntervalMs: number, + ratelimitRequestPerInterval: number +) { + function createCurrentInterval() { + return { + startedAt: Rx.asyncScheduler.now(), + numRequests: 0, + }; + } - function processNext() { - if (activeObservers.length >= maxConcurrency) { - return; - } - const observerValuePair = observers.shift(); + let currentInterval: { startedAt: number; numRequests: number } = createCurrentInterval(); + let observers: Array<[Rx.Subscriber, any]> = []; + let timerSubscription: Rx.Subscription | undefined; - if (!observerValuePair) { + function createTimeout() { + if (timerSubscription) { return; } - - const [observer, value] = observerValuePair; - activeObservers.push(observer); - observer.next(value); + timerSubscription = Rx.asyncScheduler.schedule(() => { + timerSubscription = undefined; + currentInterval = createCurrentInterval(); + for (const [waitingObserver, value] of observers) { + if (currentInterval.numRequests >= ratelimitRequestPerInterval) { + createTimeout(); + continue; + } + currentInterval.numRequests++; + waitingObserver.next(value); + } + }, ratelimitIntervalMs); } return function limit(): Rx.MonoTypeOperatorFunction { @@ -67,8 +81,14 @@ export function createSubscriberConcurrencyLimiter(maxConcurrency: number) { new Rx.Observable((observer) => { const subscription = observable.subscribe({ next(value) { + if (currentInterval.numRequests < ratelimitRequestPerInterval) { + currentInterval.numRequests++; + observer.next(value); + return; + } + observers = [...observers, [observer, value]]; - processNext(); + createTimeout(); }, error(err) { observer.error(err); @@ -79,10 +99,8 @@ export function createSubscriberConcurrencyLimiter(maxConcurrency: number) { }); return () => { - activeObservers = activeObservers.filter((o) => o !== observer); observers = observers.filter((o) => o[0] !== observer); subscription.unsubscribe(); - processNext(); }; }); }; diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts index 53270afe453c4..1547b6b5ea053 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts @@ -28,7 +28,7 @@ import * as APIKeysService from '../../api_keys'; import { AGENT_SAVED_OBJECT_TYPE, AGENT_UPDATE_ACTIONS_INTERVAL_MS } from '../../../constants'; import { createAgentAction, getNewActionsSince } from '../actions'; import { appContextService } from '../../app_context'; -import { toPromiseAbortable, AbortError, createSubscriberConcurrencyLimiter } from './rxjs_utils'; +import { toPromiseAbortable, AbortError, createRateLimiter } from './rxjs_utils'; function getInternalUserSOClient() { const fakeRequest = ({ @@ -134,8 +134,9 @@ export function agentCheckinStateNewActionsFactory() { const agentConfigs$ = new Map>(); const newActions$ = createNewActionsSharedObservable(); // Rx operators - const concurrencyLimiter = createSubscriberConcurrencyLimiter( - appContextService.getConfig()?.fleet.agentConfigRolloutConcurrency ?? 10 + const rateLimiter = createRateLimiter( + appContextService.getConfig()?.fleet.agentConfigRolloutRateLimitIntervalMs ?? 5000, + appContextService.getConfig()?.fleet.agentConfigRolloutRateLimitRequestPerInterval ?? 50 ); async function subscribeToNewActions( @@ -158,7 +159,7 @@ export function agentCheckinStateNewActionsFactory() { const stream$ = agentConfig$.pipe( timeout(appContextService.getConfig()?.fleet.pollingRequestTimeout || 0), filter((config) => shouldCreateAgentConfigAction(agent, config)), - concurrencyLimiter(), + rateLimiter(), mergeMap((config) => createAgentActionFromConfig(soClient, agent, config)), merge(newActions$), mergeMap(async (data) => { From 1f2c01531945890a1d944e7d18c16af2d1a29af7 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Fri, 31 Jul 2020 14:05:43 -0400 Subject: [PATCH 19/29] Fix a typo. (#73948) --- x-pack/plugins/ingest_manager/dev_docs/definitions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/ingest_manager/dev_docs/definitions.md b/x-pack/plugins/ingest_manager/dev_docs/definitions.md index a33d95f3afa38..bd20780611055 100644 --- a/x-pack/plugins/ingest_manager/dev_docs/definitions.md +++ b/x-pack/plugins/ingest_manager/dev_docs/definitions.md @@ -10,7 +10,7 @@ This section is to define terms used across ingest management. A package config is a definition on how to collect data from a service, for example `nginx`. A package config contains definitions for one or multiple inputs and each input can contain one or multiple streams. -With the example of the nginx Package Config, it contains to inputs: `logs` and `nginx/metrics`. Logs and metrics are collected +With the example of the nginx Package Config, it contains two inputs: `logs` and `nginx/metrics`. Logs and metrics are collected differently. The `logs` input contains two streams, `access` and `error`, the `nginx/metrics` input contains the stubstatus stream. ## Data Stream From d9efd265c94283c9a021fa91830f2ef21cedb194 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Fri, 31 Jul 2020 13:22:40 -0500 Subject: [PATCH 20/29] [build/sysv] fix missing env variable rename (#73977) --- .../tasks/os_packages/service_templates/sysv/etc/init.d/kibana | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dev/build/tasks/os_packages/service_templates/sysv/etc/init.d/kibana b/src/dev/build/tasks/os_packages/service_templates/sysv/etc/init.d/kibana index 8facbb709cc5c..449fc4e75fce8 100755 --- a/src/dev/build/tasks/os_packages/service_templates/sysv/etc/init.d/kibana +++ b/src/dev/build/tasks/os_packages/service_templates/sysv/etc/init.d/kibana @@ -22,7 +22,7 @@ pidfile="/var/run/kibana/$name.pid" [ -r /etc/default/$name ] && . /etc/default/$name [ -r /etc/sysconfig/$name ] && . /etc/sysconfig/$name -export KIBANA_PATH_CONF +export KBN_PATH_CONF export NODE_OPTIONS [ -z "$nice" ] && nice=0 From 357139d67c476d7dbe28413870c21fb8ff6f1190 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Fri, 31 Jul 2020 14:55:29 -0400 Subject: [PATCH 21/29] [Ingest Manager] Fix limited concurrency helper (#73976) --- .../ingest_manager/server/routes/limited_concurrency.test.ts | 2 +- .../ingest_manager/server/routes/limited_concurrency.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/ingest_manager/server/routes/limited_concurrency.test.ts b/x-pack/plugins/ingest_manager/server/routes/limited_concurrency.test.ts index f84f417ce402d..e5b5a83743287 100644 --- a/x-pack/plugins/ingest_manager/server/routes/limited_concurrency.test.ts +++ b/x-pack/plugins/ingest_manager/server/routes/limited_concurrency.test.ts @@ -39,7 +39,7 @@ describe('registerLimitedConcurrencyRoutes', () => { }); // assertions for calls to .decrease are commented out because it's called on the -// "req.events.aborted$ observable (which) will never emit from a mocked request in a jest unit test environment" +// "req.events.completed$ observable (which) will never emit from a mocked request in a jest unit test environment" // https://github.com/elastic/kibana/pull/72338#issuecomment-661908791 describe('preAuthHandler', () => { test(`ignores routes when !isMatch`, async () => { diff --git a/x-pack/plugins/ingest_manager/server/routes/limited_concurrency.ts b/x-pack/plugins/ingest_manager/server/routes/limited_concurrency.ts index 11fdc944e031d..7ba8e151b726c 100644 --- a/x-pack/plugins/ingest_manager/server/routes/limited_concurrency.ts +++ b/x-pack/plugins/ingest_manager/server/routes/limited_concurrency.ts @@ -66,9 +66,7 @@ export function createLimitedPreAuthHandler({ maxCounter.increase(); - // requests.events.aborted$ has a bug (but has test which explicitly verifies) where it's fired even when the request completes - // https://github.com/elastic/kibana/pull/70495#issuecomment-656288766 - request.events.aborted$.toPromise().then(() => { + request.events.completed$.toPromise().then(() => { maxCounter.decrease(); }); From 17e8d18a40a032802fc1947d18ae4d799ea1925f Mon Sep 17 00:00:00 2001 From: Brandon Morelli Date: Fri, 31 Jul 2020 12:03:09 -0700 Subject: [PATCH 22/29] [APM] docs: Update machine learning integration (#73597) --- docs/apm/images/apm-anomaly-alert.png | Bin 0 -> 62561 bytes docs/apm/machine-learning.asciidoc | 49 +++++++++++++++++++------- 2 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 docs/apm/images/apm-anomaly-alert.png diff --git a/docs/apm/images/apm-anomaly-alert.png b/docs/apm/images/apm-anomaly-alert.png new file mode 100644 index 0000000000000000000000000000000000000000..35ce9a2296c9c965cc383740ff4a5cb9fb10cdbe GIT binary patch literal 62561 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 aXE?dYgZea;GI0d>+`FT5JNK6L^Zx@%rUa+} literal 0 HcmV?d00001 diff --git a/docs/apm/machine-learning.asciidoc b/docs/apm/machine-learning.asciidoc index b203b8668072f..db2a1ef6e2da0 100644 --- a/docs/apm/machine-learning.asciidoc +++ b/docs/apm/machine-learning.asciidoc @@ -1,36 +1,61 @@ [role="xpack"] [[machine-learning-integration]] -=== integration +=== Machine learning integration ++++ Integrate with machine learning ++++ -The Machine Learning integration initiates a new job predefined to calculate anomaly scores on APM transaction durations. -Jobs can be created per transaction type, and are based on the service's average response time. +The Machine learning integration initiates a new job predefined to calculate anomaly scores on APM transaction durations. +With this integration, you can quickly pinpoint anomalous transactions and see the health of +any upstream and downstream services. -After a machine learning job is created, results are shown in two places: +Machine learning jobs are created per environment, and are based on a service's average response time. +Because jobs are created at the environment level, +you can add new services to your existing environments without the need for additional machine learning jobs. -The transaction duration graph will show the expected bounds and add an annotation when the anomaly score is 75 or above. +After a machine learning job is created, results are shown in two places: +* The transaction duration chart will show the expected bounds and add an annotation when the anomaly score is 75 or above. ++ [role="screenshot"] image::apm/images/apm-ml-integration.png[Example view of anomaly scores on response times in the APM app] -Service maps will display a color-coded anomaly indicator based on the detected anomaly score. - +* Service maps will display a color-coded anomaly indicator based on the detected anomaly score. ++ [role="screenshot"] image::apm/images/apm-service-map-anomaly.png[Example view of anomaly scores on service maps in the APM app] [float] [[create-ml-integration]] -=== Create a new machine learning job +=== Enable anomaly detection + +To enable machine learning anomaly detection: + +. From the Services overview, Traces overview, or Service Map tab, +select **Anomaly detection**. + +. Click **Create ML Job**. -To enable machine learning anomaly detection, first choose a service to monitor. -Then, select **Integrations** > **Enable ML anomaly detection** and click **Create job**. +. Machine learning jobs are created at the environment level. +Select all of the service environments that you want to enable anomaly detection in. +Anomalies will surface for all services and transaction types within the selected environments. + +. Click **Create Jobs**. That's it! After a few minutes, the job will begin calculating results; -it might take additional time for results to appear on your graph. -Jobs can be managed in *Machine Learning jobs management*. +it might take additional time for results to appear on your service maps. +Existing jobs can be managed in *Machine Learning jobs management*. APM specific anomaly detection wizards are also available for certain Agents. See the machine learning {ml-docs}/ootb-ml-jobs-apm.html[APM anomaly detection configurations] for more information. + +[float] +[[warning-ml-integration]] +=== Anomaly detection warning + +To make machine learning as easy as possible to set up, +the APM app will warn you when filtered to an environment without a machine learning job. + +[role="screenshot"] +image::apm/images/apm-anomaly-alert.png[Example view of anomaly alert in the APM app] \ No newline at end of file From 2e37239e50a5632798e17a455699e828831d37fd Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Fri, 31 Jul 2020 13:20:11 -0700 Subject: [PATCH 23/29] [Metrics UI] Fix Metrics Explorer TSVB link to use workaround pattern (#73986) * [Metrics UI] Fix Metrics Explorer TSVB link to use workaround pattern * Adding link to TSVB bug to comment --- .../helpers/create_tsvb_link.test.ts | 19 ++++++++++++++++ .../components/helpers/create_tsvb_link.ts | 22 +++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.test.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.test.ts index 04aeba41fa00d..ca4fc0abc37a4 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.test.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.test.ts @@ -157,6 +157,25 @@ describe('createTSVBLink()', () => { }); }); + it('should use the workaround index pattern when there are multiple listed in the source', () => { + const customSource = { + ...source, + metricAlias: 'my-beats-*,metrics-*', + fields: { ...source.fields, timestamp: 'time' }, + }; + const link = createTSVBLink(customSource, options, series, timeRange, chartOptions); + expect(link).toStrictEqual({ + app: 'visualize', + hash: '/create', + search: { + _a: + "(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_min:0,axis_position:left,axis_scale:normal,default_index_pattern:'metric*',filter:(language:kuery,query:'host.name : \"example-01\"'),id:test-id,index_pattern:'metric*',interval:auto,series:!((axis_position:right,chart_type:line,color:#6092C0,fill:0,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}})),show_grid:1,show_legend:1,time_field:time,type:timeseries),title:example-01,type:metrics))", + _g: '(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))', + type: 'metrics', + }, + }); + }); + test('createFilterFromOptions()', () => { const customOptions = { ...options, groupBy: 'host.name' }; const customSeries = { ...series, id: 'test"foo' }; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts index 3afc0d050e736..afddaf6621f10 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts @@ -23,6 +23,14 @@ import { SourceQuery } from '../../../../../graphql/types'; import { createMetricLabel } from './create_metric_label'; import { LinkDescriptor } from '../../../../../hooks/use_link_props'; +/* + We've recently changed the default index pattern in Metrics UI from `metricbeat-*` to + `metrics-*,metricbeat-*`. There is a bug in TSVB when there is an empty index in the pattern + the field dropdowns are not populated correctly. This index pattern is a temporary fix. + See: https://github.com/elastic/kibana/issues/73987 +*/ +const TSVB_WORKAROUND_INDEX_PATTERN = 'metric*'; + export const metricsExplorerMetricToTSVBMetric = (metric: MetricsExplorerOptionsMetric) => { if (metric.aggregation === 'rate') { const metricId = uuid.v1(); @@ -128,6 +136,13 @@ export const createFilterFromOptions = ( return { language: 'kuery', query: filters.join(' and ') }; }; +const createTSVBIndexPattern = (alias: string) => { + if (alias.split(',').length > 1) { + return TSVB_WORKAROUND_INDEX_PATTERN; + } + return alias; +}; + export const createTSVBLink = ( source: SourceQuery.Query['source']['configuration'] | undefined, options: MetricsExplorerOptions, @@ -135,6 +150,9 @@ export const createTSVBLink = ( timeRange: MetricsExplorerTimeOptions, chartOptions: MetricsExplorerChartOptions ): LinkDescriptor => { + const tsvbIndexPattern = createTSVBIndexPattern( + (source && source.metricAlias) || TSVB_WORKAROUND_INDEX_PATTERN + ); const appState = { filters: [], linked: false, @@ -147,8 +165,8 @@ export const createTSVBLink = ( axis_position: 'left', axis_scale: 'normal', id: uuid.v1(), - default_index_pattern: (source && source.metricAlias) || 'metricbeat-*', - index_pattern: (source && source.metricAlias) || 'metricbeat-*', + default_index_pattern: tsvbIndexPattern, + index_pattern: tsvbIndexPattern, interval: 'auto', series: options.metrics.map(mapMetricToSeries(chartOptions)), show_grid: 1, From 2c71a3fba9508c61995870b643c2cdb1da5d14a2 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Fri, 31 Jul 2020 22:17:24 +0100 Subject: [PATCH 24/29] [Security Solution] Fix unexpected redirect (#73969) * fix unexpected redirect * fix types Co-authored-by: Patryk Kopycinski --- .../components/open_timeline/index.test.tsx | 61 ++++++++++++++----- .../open_timeline/use_timeline_types.tsx | 26 +++++--- .../public/timelines/pages/timelines_page.tsx | 2 +- 3 files changed, 62 insertions(+), 27 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx index 6c1c88f511edb..75b6413bf08f9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx @@ -17,11 +17,25 @@ import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../pages/timelines_page'; import { NotePreviews } from './note_previews'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; -import { TimelineTabsStyle } from './types'; import { StatefulOpenTimeline } from '.'; + import { useGetAllTimeline, getAllTimeline } from '../../containers/all'; + +import { useParams } from 'react-router-dom'; +import { TimelineType } from '../../../../common/types/timeline'; + jest.mock('../../../common/lib/kibana'); +jest.mock('../../../common/components/link_to'); + +jest.mock('./helpers', () => { + const originalModule = jest.requireActual('./helpers'); + return { + ...originalModule, + queryTimelineById: jest.fn(), + }; +}); + jest.mock('../../containers/all', () => { const originalModule = jest.requireActual('../../containers/all'); return { @@ -30,19 +44,21 @@ jest.mock('../../containers/all', () => { getAllTimeline: originalModule.getAllTimeline, }; }); -jest.mock('./use_timeline_types', () => { + +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + return { - useTimelineTypes: jest.fn().mockReturnValue({ - timelineType: 'default', - timelineTabs:
, - timelineFilters:
, - }), + ...originalModule, + useParams: jest.fn(), + useHistory: jest.fn().mockReturnValue([]), }; }); describe('StatefulOpenTimeline', () => { const title = 'All Timelines / Open Timelines'; beforeEach(() => { + (useParams as jest.Mock).mockReturnValue({ tabName: TimelineType.default }); ((useGetAllTimeline as unknown) as jest.Mock).mockReturnValue({ fetchAllTimeline: jest.fn(), timelines: getAllTimeline( @@ -433,10 +449,7 @@ describe('StatefulOpenTimeline', () => { }); }); - /** - * enable this test when createtTemplateTimeline is ready - */ - test.skip('it renders the tabs', async () => { + test('it has the expected initial state for openTimeline - templateTimelineFilter', () => { const wrapper = mount( @@ -451,11 +464,27 @@ describe('StatefulOpenTimeline', () => { ); - await waitFor(() => { - expect( - wrapper.find(`[data-test-subj="timeline-${TimelineTabsStyle.tab}"]`).exists() - ).toEqual(true); - }); + expect(wrapper.find('[data-test-subj="open-timeline-subtabs"]').exists()).toEqual(true); + }); + + test('it has the expected initial state for openTimelineModalBody - templateTimelineFilter', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="open-timeline-modal-body-filters"]').exists()).toEqual( + true + ); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx index 7d54bb2209850..55afe845cdfb3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx @@ -7,26 +7,31 @@ import React, { useState, useCallback, useMemo } from 'react'; import { useParams, useHistory } from 'react-router-dom'; import { EuiTabs, EuiTab, EuiSpacer, EuiFilterButton } from '@elastic/eui'; +import { noop } from 'lodash/fp'; import { TimelineTypeLiteralWithNull, TimelineType } from '../../../../common/types/timeline'; import { SecurityPageName } from '../../../app/types'; import { getTimelineTabsUrl, useFormatUrl } from '../../../common/components/link_to'; import * as i18n from './translations'; import { TimelineTabsStyle, TimelineTab } from './types'; -export const useTimelineTypes = ({ - defaultTimelineCount, - templateTimelineCount, -}: { +export interface UseTimelineTypesArgs { defaultTimelineCount?: number | null; templateTimelineCount?: number | null; -}): { +} + +export interface UseTimelineTypesResult { timelineType: TimelineTypeLiteralWithNull; timelineTabs: JSX.Element; timelineFilters: JSX.Element[]; -} => { +} + +export const useTimelineTypes = ({ + defaultTimelineCount, + templateTimelineCount, +}: UseTimelineTypesArgs): UseTimelineTypesResult => { const history = useHistory(); const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.timelines); - const { tabName } = useParams<{ pageName: string; tabName: string }>(); + const { tabName } = useParams<{ pageName: SecurityPageName; tabName: string }>(); const [timelineType, setTimelineTypes] = useState( tabName === TimelineType.default || tabName === TimelineType.template ? tabName : null ); @@ -61,7 +66,7 @@ export const useTimelineTypes = ({ timelineTabsStyle === TimelineTabsStyle.filter ? defaultTimelineCount ?? undefined : undefined, - onClick: goToTimeline, + onClick: timelineTabsStyle === TimelineTabsStyle.tab ? goToTimeline : noop, }, { id: TimelineType.template, @@ -76,7 +81,7 @@ export const useTimelineTypes = ({ timelineTabsStyle === TimelineTabsStyle.filter ? templateTimelineCount ?? undefined : undefined, - onClick: goToTemplateTimeline, + onClick: timelineTabsStyle === TimelineTabsStyle.tab ? goToTemplateTimeline : noop, }, ], [ @@ -106,7 +111,7 @@ export const useTimelineTypes = ({ const timelineTabs = useMemo(() => { return ( <> - + {getFilterOrTabs(TimelineTabsStyle.tab).map((tab: TimelineTab) => ( { return getFilterOrTabs(TimelineTabsStyle.filter).map((tab: TimelineTab) => ( { - const { tabName } = useParams(); + const { tabName } = useParams<{ pageName: SecurityPageName; tabName: string }>(); const [importDataModalToggle, setImportDataModalToggle] = useState(false); const onImportTimelineBtnClick = useCallback(() => { setImportDataModalToggle(true); From 5aca964d689f2fad5ab18c06250d6d2e8432d494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Fri, 31 Jul 2020 23:20:47 +0200 Subject: [PATCH 25/29] [Security Solution] Fix timeline pin event callback (#73981) * [Security Solution] Fix timeline pin event callback * - added tests * - restored the original disabled button behavior Co-authored-by: Andrew Goldstein --- .../components/timeline/body/helpers.test.ts | 69 +++++++++++++++++++ .../components/timeline/body/helpers.ts | 14 ++-- .../components/timeline/pin/index.test.tsx | 69 ++++++++++++++++++- .../components/timeline/pin/index.tsx | 2 +- 4 files changed, 148 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts index c8adaa891610a..f4dc691f3d059 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts @@ -9,6 +9,7 @@ import { Ecs } from '../../../../graphql/types'; import { eventHasNotes, eventIsPinned, + getPinOnClick, getPinTooltip, stringifyEvent, isInvestigateInResolverActionEnabled, @@ -298,4 +299,72 @@ describe('helpers', () => { expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy(); }); }); + + describe('getPinOnClick', () => { + const eventId = 'abcd'; + + test('it invokes `onPinEvent` with the expected eventId when the event is NOT pinned, and allowUnpinning is true', () => { + const isEventPinned = false; // the event is NOT pinned + const allowUnpinning = true; + const onPinEvent = jest.fn(); + + getPinOnClick({ + allowUnpinning, + eventId, + onPinEvent, + onUnPinEvent: jest.fn(), + isEventPinned, + }); + + expect(onPinEvent).toBeCalledWith(eventId); + }); + + test('it does NOT invoke `onPinEvent` when the event is NOT pinned, and allowUnpinning is false', () => { + const isEventPinned = false; // the event is NOT pinned + const allowUnpinning = false; + const onPinEvent = jest.fn(); + + getPinOnClick({ + allowUnpinning, + eventId, + onPinEvent, + onUnPinEvent: jest.fn(), + isEventPinned, + }); + + expect(onPinEvent).not.toBeCalled(); + }); + + test('it invokes `onUnPinEvent` with the expected eventId when the event is pinned, and allowUnpinning is true', () => { + const isEventPinned = true; // the event is pinned + const allowUnpinning = true; + const onUnPinEvent = jest.fn(); + + getPinOnClick({ + allowUnpinning, + eventId, + onPinEvent: jest.fn(), + onUnPinEvent, + isEventPinned, + }); + + expect(onUnPinEvent).toBeCalledWith(eventId); + }); + + test('it does NOT invoke `onUnPinEvent` when the event is pinned, and allowUnpinning is false', () => { + const isEventPinned = true; // the event is pinned + const allowUnpinning = false; + const onUnPinEvent = jest.fn(); + + getPinOnClick({ + allowUnpinning, + eventId, + onPinEvent: jest.fn(), + onUnPinEvent, + isEventPinned, + }); + + expect(onUnPinEvent).not.toBeCalled(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts index 6a5e25632c29b..73b5a58ef7b65 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts @@ -3,7 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { get, isEmpty, noop } from 'lodash/fp'; + +import { get, isEmpty } from 'lodash/fp'; import { Dispatch } from 'redux'; import { Ecs, TimelineItem, TimelineNonEcsData } from '../../../../graphql/types'; @@ -65,11 +66,16 @@ export const getPinOnClick = ({ onPinEvent, onUnPinEvent, isEventPinned, -}: GetPinOnClickParams): (() => void) => { +}: GetPinOnClickParams) => { if (!allowUnpinning) { - return noop; + return; + } + + if (isEventPinned) { + onUnPinEvent(eventId); + } else { + onPinEvent(eventId); } - return isEventPinned ? () => onUnPinEvent(eventId) : () => onPinEvent(eventId); }; /** diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.test.tsx index 657976e2f4787..2ca27ded86c9d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.test.tsx @@ -4,7 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getPinIcon } from './'; +import { mount } from 'enzyme'; +import React from 'react'; + +import { TimelineType } from '../../../../../common/types/timeline'; + +import { getPinIcon, Pin } from './'; + +interface ButtonIcon { + isDisabled: boolean; +} describe('pin', () => { describe('getPinRotation', () => { @@ -16,4 +25,62 @@ describe('pin', () => { expect(getPinIcon(false)).toEqual('pin'); }); }); + + describe('disabled button behavior', () => { + test('the button is enabled when allowUnpinning is true, and timelineType is NOT `template` (the default)', () => { + const allowUnpinning = true; + const wrapper = mount( + + ); + + expect( + (wrapper.find('[data-test-subj="pin"]').first().props() as ButtonIcon).isDisabled + ).toBe(false); + }); + + test('the button is disabled when allowUnpinning is false, and timelineType is NOT `template` (the default)', () => { + const allowUnpinning = false; + const wrapper = mount( + + ); + + expect( + (wrapper.find('[data-test-subj="pin"]').first().props() as ButtonIcon).isDisabled + ).toBe(true); + }); + + test('the button is disabled when allowUnpinning is true, and timelineType is `template`', () => { + const allowUnpinning = true; + const timelineType = TimelineType.template; + const wrapper = mount( + + ); + + expect( + (wrapper.find('[data-test-subj="pin"]').first().props() as ButtonIcon).isDisabled + ).toBe(true); + }); + + test('the button is disabled when allowUnpinning is false, and timelineType is `template`', () => { + const allowUnpinning = false; + const timelineType = TimelineType.template; + const wrapper = mount( + + ); + + expect( + (wrapper.find('[data-test-subj="pin"]').first().props() as ButtonIcon).isDisabled + ).toBe(true); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx index 30fe8ae0ca1f6..27780c7754d00 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx @@ -34,7 +34,7 @@ export const Pin = React.memo( iconSize={iconSize} iconType={getPinIcon(pinned)} onClick={onClick} - isDisabled={isTemplate} + isDisabled={isTemplate || !allowUnpinning} /> ); } From 75eda6690ef786f831bf7c46fc07260ffc0b37ff Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Fri, 31 Jul 2020 15:36:23 -0600 Subject: [PATCH 26/29] [SIEM] Fixes toaster errors when siemDefault index is an empty or empty spaces (#73991) ## Summary Fixes fully this issue: https://github.com/elastic/kibana/issues/49753 If you go to advanced settings and configure siemDefaultIndex to be an empty string or have empty spaces: Screen Shot 2020-07-31 at 12 52 00 PM You shouldn't get any toaster errors when going to any of the pages such as overview, detections, etc... This fixes that and adds both unit and integration tests around those areas. The fix is to add a filter which will filter all the patterns out that are either empty strings or have the _all within them rather than just looking for a single value to exist. ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios --- .../graphql/source_status/resolvers.test.ts | 49 ++++++++ .../server/graphql/source_status/resolvers.ts | 31 +++-- .../lib/index_fields/elasticsearch_adapter.ts | 23 ++-- .../apis/security_solution/sources.ts | 107 +++++++++++++++--- 4 files changed, 168 insertions(+), 42 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/graphql/source_status/resolvers.test.ts diff --git a/x-pack/plugins/security_solution/server/graphql/source_status/resolvers.test.ts b/x-pack/plugins/security_solution/server/graphql/source_status/resolvers.test.ts new file mode 100644 index 0000000000000..1735c6473bb3a --- /dev/null +++ b/x-pack/plugins/security_solution/server/graphql/source_status/resolvers.test.ts @@ -0,0 +1,49 @@ +/* + * 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 { filterIndexes } from './resolvers'; + +describe('resolvers', () => { + test('it should filter single index that has an empty string', () => { + const emptyArray = filterIndexes(['']); + expect(emptyArray).toEqual([]); + }); + + test('it should filter single index that has blanks within it', () => { + const emptyArray = filterIndexes([' ']); + expect(emptyArray).toEqual([]); + }); + + test('it should filter indexes that has an empty string and a valid index', () => { + const emptyArray = filterIndexes(['', 'valid-index']); + expect(emptyArray).toEqual(['valid-index']); + }); + + test('it should filter indexes that have blanks within them and a valid index', () => { + const emptyArray = filterIndexes([' ', 'valid-index']); + expect(emptyArray).toEqual(['valid-index']); + }); + + test('it should filter single index that has _all within it', () => { + const emptyArray = filterIndexes(['_all']); + expect(emptyArray).toEqual([]); + }); + + test('it should filter single index that has _all within it surrounded by spaces', () => { + const emptyArray = filterIndexes([' _all ']); + expect(emptyArray).toEqual([]); + }); + + test('it should filter indexes that _all within them and a valid index', () => { + const emptyArray = filterIndexes(['_all', 'valid-index']); + expect(emptyArray).toEqual(['valid-index']); + }); + + test('it should filter indexes that _all surrounded with spaces within them and a valid index', () => { + const emptyArray = filterIndexes([' _all ', 'valid-index']); + expect(emptyArray).toEqual(['valid-index']); + }); +}); diff --git a/x-pack/plugins/security_solution/server/graphql/source_status/resolvers.ts b/x-pack/plugins/security_solution/server/graphql/source_status/resolvers.ts index 8d55e645d6791..84320b1699531 100644 --- a/x-pack/plugins/security_solution/server/graphql/source_status/resolvers.ts +++ b/x-pack/plugins/security_solution/server/graphql/source_status/resolvers.ts @@ -32,27 +32,34 @@ export const createSourceStatusResolvers = (libs: { }; } => ({ SourceStatus: { - async indicesExist(source, args, { req }) { - if ( - args.defaultIndex.length === 1 && - (args.defaultIndex[0] === '' || args.defaultIndex[0] === '_all') - ) { + async indicesExist(_, args, { req }) { + const indexes = filterIndexes(args.defaultIndex); + if (indexes.length !== 0) { + return libs.sourceStatus.hasIndices(req, indexes); + } else { return false; } - return libs.sourceStatus.hasIndices(req, args.defaultIndex); }, - async indexFields(source, args, { req }) { - if ( - args.defaultIndex.length === 1 && - (args.defaultIndex[0] === '' || args.defaultIndex[0] === '_all') - ) { + async indexFields(_, args, { req }) { + const indexes = filterIndexes(args.defaultIndex); + if (indexes.length !== 0) { + return libs.fields.getFields(req, indexes); + } else { return []; } - return libs.fields.getFields(req, args.defaultIndex); }, }, }); +/** + * Given a set of indexes this will remove anything that is: + * - blank or empty strings are removed as not valid indexes + * - _all is removed as that is not a valid index + * @param indexes Indexes with invalid values removed + */ +export const filterIndexes = (indexes: string[]): string[] => + indexes.filter((index) => index.trim() !== '' && index.trim() !== '_all'); + export const toIFieldSubTypeNonNullableScalar = new GraphQLScalarType({ name: 'IFieldSubType', description: 'Represents value in index pattern field item', diff --git a/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.ts b/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.ts index 944fc588afc8a..bb0a4b9e2ba9b 100644 --- a/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.ts +++ b/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.ts @@ -17,26 +17,21 @@ import { import { FrameworkAdapter, FrameworkRequest } from '../framework'; import { FieldsAdapter, IndexFieldDescriptor } from './types'; -type IndexesAliasIndices = Record; - export class ElasticsearchIndexFieldAdapter implements FieldsAdapter { constructor(private readonly framework: FrameworkAdapter) {} public async getIndexFields(request: FrameworkRequest, indices: string[]): Promise { const indexPatternsService = this.framework.getIndexPatternsService(request); - const indexesAliasIndices: IndexesAliasIndices = indices.reduce( - (accumulator: IndexesAliasIndices, indice: string) => { - const key = getIndexAlias(indices, indice); + const indexesAliasIndices = indices.reduce>((accumulator, indice) => { + const key = getIndexAlias(indices, indice); - if (get(key, accumulator)) { - accumulator[key] = [...accumulator[key], indice]; - } else { - accumulator[key] = [indice]; - } - return accumulator; - }, - {} as IndexesAliasIndices - ); + if (get(key, accumulator)) { + accumulator[key] = [...accumulator[key], indice]; + } else { + accumulator[key] = [indice]; + } + return accumulator; + }, {}); const responsesIndexFields: IndexFieldDescriptor[][] = await Promise.all( Object.values(indexesAliasIndices).map((indicesByGroup) => indexPatternsService.getFieldsForWildcard({ diff --git a/x-pack/test/api_integration/apis/security_solution/sources.ts b/x-pack/test/api_integration/apis/security_solution/sources.ts index a9bbf09a9e6f9..f99dd4c65fc83 100644 --- a/x-pack/test/api_integration/apis/security_solution/sources.ts +++ b/x-pack/test/api_integration/apis/security_solution/sources.ts @@ -18,22 +18,97 @@ export default function ({ getService }: FtrProviderContext) { before(() => esArchiver.load('auditbeat/default')); after(() => esArchiver.unload('auditbeat/default')); - it('Make sure that we get source information when auditbeat indices is there', () => { - return client - .query({ - query: sourceQuery, - variables: { - sourceId: 'default', - defaultIndex: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - docValueFields: [], - }, - }) - .then((resp) => { - const sourceStatus = resp.data.source.status; - // test data in x-pack/test/functional/es_archives/auditbeat_test_data/data.json.gz - expect(sourceStatus.indexFields.length).to.be(397); - expect(sourceStatus.indicesExist).to.be(true); - }); + it('Make sure that we get source information when auditbeat indices is there', async () => { + const resp = await client.query({ + query: sourceQuery, + variables: { + sourceId: 'default', + defaultIndex: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + docValueFields: [], + }, + }); + const sourceStatus = resp.data.source.status; + // test data in x-pack/test/functional/es_archives/auditbeat_test_data/data.json.gz + expect(sourceStatus.indexFields.length).to.be(397); + expect(sourceStatus.indicesExist).to.be(true); + }); + + it('should find indexes as being available when they exist', async () => { + const resp = await client.query({ + query: sourceQuery, + variables: { + sourceId: 'default', + defaultIndex: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + docValueFields: [], + }, + }); + const sourceStatus = resp.data.source.status; + expect(sourceStatus.indicesExist).to.be(true); + }); + + it('should not find indexes as existing when there is an empty array of them', async () => { + const resp = await client.query({ + query: sourceQuery, + variables: { + sourceId: 'default', + defaultIndex: [], + docValueFields: [], + }, + }); + const sourceStatus = resp.data.source.status; + expect(sourceStatus.indicesExist).to.be(false); + }); + + it('should not find indexes as existing when there is a _all within it', async () => { + const resp = await client.query({ + query: sourceQuery, + variables: { + sourceId: 'default', + defaultIndex: ['_all'], + docValueFields: [], + }, + }); + const sourceStatus = resp.data.source.status; + expect(sourceStatus.indicesExist).to.be(false); + }); + + it('should not find indexes as existing when there are empty strings within it', async () => { + const resp = await client.query({ + query: sourceQuery, + variables: { + sourceId: 'default', + defaultIndex: [''], + docValueFields: [], + }, + }); + const sourceStatus = resp.data.source.status; + expect(sourceStatus.indicesExist).to.be(false); + }); + + it('should not find indexes as existing when there are blank spaces within it', async () => { + const resp = await client.query({ + query: sourceQuery, + variables: { + sourceId: 'default', + defaultIndex: [' '], + docValueFields: [], + }, + }); + const sourceStatus = resp.data.source.status; + expect(sourceStatus.indicesExist).to.be(false); + }); + + it('should find indexes when one is an empty index but the others are valid', async () => { + const resp = await client.query({ + query: sourceQuery, + variables: { + sourceId: 'default', + defaultIndex: ['', 'auditbeat-*'], + docValueFields: [], + }, + }); + const sourceStatus = resp.data.source.status; + expect(sourceStatus.indicesExist).to.be(true); }); }); } From 6eca0b47fa1d8c3dbd540f0ded2b3ff5c4d76193 Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Fri, 31 Jul 2020 14:38:39 -0700 Subject: [PATCH 27/29] Closes #73998 by using `canAccessML` in the ML capabilities API to (#73999) enable anomaly detection settings in APM. --- x-pack/plugins/apm/public/components/app/Home/index.tsx | 4 ++-- x-pack/plugins/apm/public/components/app/Settings/index.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/Home/index.tsx b/x-pack/plugins/apm/public/components/app/Home/index.tsx index c6c0861c26a34..b2f15dbb11341 100644 --- a/x-pack/plugins/apm/public/components/app/Home/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Home/index.tsx @@ -84,7 +84,7 @@ interface Props { export function Home({ tab }: Props) { const { config, core } = useApmPluginContext(); - const isMLEnabled = !!core.application.capabilities.ml; + const canAccessML = !!core.application.capabilities.ml?.canAccessML; const homeTabs = getHomeTabs(config); const selectedTab = homeTabs.find( (homeTab) => homeTab.name === tab @@ -106,7 +106,7 @@ export function Home({ tab }: Props) { - {isMLEnabled && ( + {canAccessML && ( diff --git a/x-pack/plugins/apm/public/components/app/Settings/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/index.tsx index 1471bc345d850..cb4726244e50c 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/index.tsx @@ -20,7 +20,7 @@ import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; export function Settings(props: { children: ReactNode }) { const plugin = useApmPluginContext(); - const isMLEnabled = !!plugin.core.application.capabilities.ml; + const canAccessML = !!plugin.core.application.capabilities.ml?.canAccessML; const { search, pathname } = useLocation(); return ( <> @@ -51,7 +51,7 @@ export function Settings(props: { children: ReactNode }) { '/settings/agent-configuration' ), }, - ...(isMLEnabled + ...(canAccessML ? [ { name: i18n.translate( From 53b1875093a5355ba693634093b68f055dfb6f65 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 31 Jul 2020 17:50:38 -0400 Subject: [PATCH 28/29] Tweak injected metadata (#73990) Removes unnecessary fields from injected metadata for clients. --- .../rendering_service.test.ts.snap | 270 +++--------------- .../rendering/rendering_service.test.ts | 11 +- .../server/rendering/rendering_service.tsx | 5 +- src/core/server/rendering/types.ts | 7 +- 4 files changed, 55 insertions(+), 238 deletions(-) diff --git a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap index 95230b52c5c03..eab29731ea524 100644 --- a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap +++ b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap @@ -10,37 +10,18 @@ Object { "warnLegacyBrowsers": true, }, "env": Object { - "binDir": Any, - "cliArgs": Object { - "basePath": false, - "dev": true, - "open": false, - "optimize": false, - "oss": false, - "quiet": false, - "repl": false, - "runExamples": false, - "silent": false, - "watch": false, - }, - "configDir": Any, - "configs": Array [], - "homeDir": Any, - "isDevClusterMaster": false, - "logDir": Any, "mode": Object { - "dev": true, - "name": "development", - "prod": false, + "dev": Any, + "name": Any, + "prod": Any, }, "packageInfo": Object { "branch": Any, "buildNum": Any, "buildSha": Any, - "dist": false, + "dist": Any, "version": Any, }, - "pluginSearchPaths": Any, }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", @@ -83,37 +64,18 @@ Object { "warnLegacyBrowsers": true, }, "env": Object { - "binDir": Any, - "cliArgs": Object { - "basePath": false, - "dev": true, - "open": false, - "optimize": false, - "oss": false, - "quiet": false, - "repl": false, - "runExamples": false, - "silent": false, - "watch": false, - }, - "configDir": Any, - "configs": Array [], - "homeDir": Any, - "isDevClusterMaster": false, - "logDir": Any, "mode": Object { - "dev": true, - "name": "development", - "prod": false, + "dev": Any, + "name": Any, + "prod": Any, }, "packageInfo": Object { "branch": Any, "buildNum": Any, "buildSha": Any, - "dist": false, + "dist": Any, "version": Any, }, - "pluginSearchPaths": Any, }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", @@ -156,37 +118,18 @@ Object { "warnLegacyBrowsers": true, }, "env": Object { - "binDir": Any, - "cliArgs": Object { - "basePath": false, - "dev": true, - "open": false, - "optimize": false, - "oss": false, - "quiet": false, - "repl": false, - "runExamples": false, - "silent": false, - "watch": false, - }, - "configDir": Any, - "configs": Array [], - "homeDir": Any, - "isDevClusterMaster": false, - "logDir": Any, "mode": Object { - "dev": true, - "name": "development", - "prod": false, + "dev": Any, + "name": Any, + "prod": Any, }, "packageInfo": Object { "branch": Any, "buildNum": Any, "buildSha": Any, - "dist": false, + "dist": Any, "version": Any, }, - "pluginSearchPaths": Any, }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", @@ -233,37 +176,18 @@ Object { "warnLegacyBrowsers": true, }, "env": Object { - "binDir": Any, - "cliArgs": Object { - "basePath": false, - "dev": true, - "open": false, - "optimize": false, - "oss": false, - "quiet": false, - "repl": false, - "runExamples": false, - "silent": false, - "watch": false, - }, - "configDir": Any, - "configs": Array [], - "homeDir": Any, - "isDevClusterMaster": false, - "logDir": Any, "mode": Object { - "dev": true, - "name": "development", - "prod": false, + "dev": Any, + "name": Any, + "prod": Any, }, "packageInfo": Object { "branch": Any, "buildNum": Any, "buildSha": Any, - "dist": false, + "dist": Any, "version": Any, }, - "pluginSearchPaths": Any, }, "i18n": Object { "translationsUrl": "/translations/en.json", @@ -306,37 +230,18 @@ Object { "warnLegacyBrowsers": true, }, "env": Object { - "binDir": Any, - "cliArgs": Object { - "basePath": false, - "dev": true, - "open": false, - "optimize": false, - "oss": false, - "quiet": false, - "repl": false, - "runExamples": false, - "silent": false, - "watch": false, - }, - "configDir": Any, - "configs": Array [], - "homeDir": Any, - "isDevClusterMaster": false, - "logDir": Any, "mode": Object { - "dev": true, - "name": "development", - "prod": false, + "dev": Any, + "name": Any, + "prod": Any, }, "packageInfo": Object { "branch": Any, "buildNum": Any, "buildSha": Any, - "dist": false, + "dist": Any, "version": Any, }, - "pluginSearchPaths": Any, }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", @@ -379,37 +284,18 @@ Object { "warnLegacyBrowsers": true, }, "env": Object { - "binDir": Any, - "cliArgs": Object { - "basePath": false, - "dev": true, - "open": false, - "optimize": false, - "oss": false, - "quiet": false, - "repl": false, - "runExamples": false, - "silent": false, - "watch": false, - }, - "configDir": Any, - "configs": Array [], - "homeDir": Any, - "isDevClusterMaster": false, - "logDir": Any, "mode": Object { - "dev": true, - "name": "development", - "prod": false, + "dev": Any, + "name": Any, + "prod": Any, }, "packageInfo": Object { "branch": Any, "buildNum": Any, "buildSha": Any, - "dist": false, + "dist": Any, "version": Any, }, - "pluginSearchPaths": Any, }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", @@ -452,37 +338,18 @@ Object { "warnLegacyBrowsers": true, }, "env": Object { - "binDir": Any, - "cliArgs": Object { - "basePath": false, - "dev": true, - "open": false, - "optimize": false, - "oss": false, - "quiet": false, - "repl": false, - "runExamples": false, - "silent": false, - "watch": false, - }, - "configDir": Any, - "configs": Array [], - "homeDir": Any, - "isDevClusterMaster": false, - "logDir": Any, "mode": Object { - "dev": true, - "name": "development", - "prod": false, + "dev": Any, + "name": Any, + "prod": Any, }, "packageInfo": Object { "branch": Any, "buildNum": Any, "buildSha": Any, - "dist": false, + "dist": Any, "version": Any, }, - "pluginSearchPaths": Any, }, "i18n": Object { "translationsUrl": "/translations/en.json", @@ -525,37 +392,18 @@ Object { "warnLegacyBrowsers": true, }, "env": Object { - "binDir": Any, - "cliArgs": Object { - "basePath": false, - "dev": true, - "open": false, - "optimize": false, - "oss": false, - "quiet": false, - "repl": false, - "runExamples": false, - "silent": false, - "watch": false, - }, - "configDir": Any, - "configs": Array [], - "homeDir": Any, - "isDevClusterMaster": false, - "logDir": Any, "mode": Object { - "dev": true, - "name": "development", - "prod": false, + "dev": Any, + "name": Any, + "prod": Any, }, "packageInfo": Object { "branch": Any, "buildNum": Any, "buildSha": Any, - "dist": false, + "dist": Any, "version": Any, }, - "pluginSearchPaths": Any, }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", @@ -600,37 +448,18 @@ Object { "warnLegacyBrowsers": true, }, "env": Object { - "binDir": Any, - "cliArgs": Object { - "basePath": false, - "dev": true, - "open": false, - "optimize": false, - "oss": false, - "quiet": false, - "repl": false, - "runExamples": false, - "silent": false, - "watch": false, - }, - "configDir": Any, - "configs": Array [], - "homeDir": Any, - "isDevClusterMaster": false, - "logDir": Any, "mode": Object { - "dev": true, - "name": "development", - "prod": false, + "dev": Any, + "name": Any, + "prod": Any, }, "packageInfo": Object { "branch": Any, "buildNum": Any, "buildSha": Any, - "dist": false, + "dist": Any, "version": Any, }, - "pluginSearchPaths": Any, }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", @@ -673,37 +502,18 @@ Object { "warnLegacyBrowsers": true, }, "env": Object { - "binDir": Any, - "cliArgs": Object { - "basePath": false, - "dev": true, - "open": false, - "optimize": false, - "oss": false, - "quiet": false, - "repl": false, - "runExamples": false, - "silent": false, - "watch": false, - }, - "configDir": Any, - "configs": Array [], - "homeDir": Any, - "isDevClusterMaster": false, - "logDir": Any, "mode": Object { - "dev": true, - "name": "development", - "prod": false, + "dev": Any, + "name": Any, + "prod": Any, }, "packageInfo": Object { "branch": Any, "buildNum": Any, "buildSha": Any, - "dist": false, + "dist": Any, "version": Any, }, - "pluginSearchPaths": Any, }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", diff --git a/src/core/server/rendering/rendering_service.test.ts b/src/core/server/rendering/rendering_service.test.ts index d1c527aca4dba..7caf4af850c10 100644 --- a/src/core/server/rendering/rendering_service.test.ts +++ b/src/core/server/rendering/rendering_service.test.ts @@ -30,17 +30,18 @@ const INJECTED_METADATA = { branch: expect.any(String), buildNumber: expect.any(Number), env: { - binDir: expect.any(String), - configDir: expect.any(String), - homeDir: expect.any(String), - logDir: expect.any(String), + mode: { + name: expect.any(String), + dev: expect.any(Boolean), + prod: expect.any(Boolean), + }, packageInfo: { branch: expect.any(String), buildNum: expect.any(Number), buildSha: expect.any(String), + dist: expect.any(Boolean), version: expect.any(String), }, - pluginSearchPaths: expect.any(Array), }, legacyMetadata: { branch: expect.any(String), diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx index 8f87d62496891..f49952ec713fb 100644 --- a/src/core/server/rendering/rendering_service.tsx +++ b/src/core/server/rendering/rendering_service.tsx @@ -55,7 +55,10 @@ export class RenderingService implements CoreService Date: Fri, 31 Jul 2020 18:39:59 -0400 Subject: [PATCH 29/29] [SECURITY_SOLUTION][ENDPOINT] Fix host list Configuration Status cell link loosing list page/size state (#73989) --- .../security_solution/public/management/common/routing.ts | 3 ++- .../public/management/pages/endpoint_hosts/view/index.tsx | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index 3636358ebe842..eeb1533f57a67 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -54,7 +54,8 @@ export const getHostListPath = ( }; export const getHostDetailsPath = ( - props: { name: 'hostDetails' | 'hostPolicyResponse' } & HostDetailsUrlProps, + props: { name: 'hostDetails' | 'hostPolicyResponse' } & HostIndexUIQueryParams & + HostDetailsUrlProps, search?: string ) => { const { name, ...queryParams } = props; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 58442ab417b60..f91bba3e3125a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -263,6 +263,7 @@ export const HostList = () => { render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied'], item: HostInfo) => { const toRoutePath = getHostDetailsPath({ name: 'hostPolicyResponse', + ...queryParams, selected_host: item.metadata.host.id, }); const toRouteUrl = formatUrl(toRoutePath);