diff --git a/.eslintrc.js b/.eslintrc.js index 56c06902e062b5..f1e0b7d9353e8b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -112,7 +112,6 @@ module.exports = { files: ['x-pack/plugins/lens/**/*.{js,ts,tsx}'], rules: { 'react-hooks/exhaustive-deps': 'off', - 'react-hooks/rules-of-hooks': 'off', }, }, { diff --git a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap index 1bc85fa110ca0b..698c124d2d8057 100644 --- a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -301,7 +301,7 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` >
@@ -995,7 +995,7 @@ exports[`DashboardEmptyScreen renders correctly without visualize paragraph 1`] >
diff --git a/src/plugins/dashboard/public/application/dashboard_empty_screen.tsx b/src/plugins/dashboard/public/application/dashboard_empty_screen.tsx index 8bf205b8cb5070..955d5244ce1904 100644 --- a/src/plugins/dashboard/public/application/dashboard_empty_screen.tsx +++ b/src/plugins/dashboard/public/application/dashboard_empty_screen.tsx @@ -50,8 +50,8 @@ export function DashboardEmptyScreen({ }: DashboardEmptyScreenProps) { const IS_DARK_THEME = uiSettings.get('theme:darkMode'); const emptyStateGraphicURL = IS_DARK_THEME - ? '/plugins/kibana/home/assets/welcome_graphic_dark_2x.png' - : '/plugins/kibana/home/assets/welcome_graphic_light_2x.png'; + ? '/plugins/home/assets/welcome_graphic_dark_2x.png' + : '/plugins/home/assets/welcome_graphic_light_2x.png'; const linkToVisualizeParagraph = (

} description={ diff --git a/src/legacy/core_plugins/kibana/public/home/assets/illustration_elastic_heart.png b/src/plugins/home/public/assets/illustration_elastic_heart.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/assets/illustration_elastic_heart.png rename to src/plugins/home/public/assets/illustration_elastic_heart.png diff --git a/src/legacy/core_plugins/kibana/public/home/sample_data_resources/ecommerce/dashboard.png b/src/plugins/home/public/assets/sample_data_resources/ecommerce/dashboard.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/sample_data_resources/ecommerce/dashboard.png rename to src/plugins/home/public/assets/sample_data_resources/ecommerce/dashboard.png diff --git a/src/legacy/core_plugins/kibana/public/home/sample_data_resources/ecommerce/dashboard_dark.png b/src/plugins/home/public/assets/sample_data_resources/ecommerce/dashboard_dark.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/sample_data_resources/ecommerce/dashboard_dark.png rename to src/plugins/home/public/assets/sample_data_resources/ecommerce/dashboard_dark.png diff --git a/src/legacy/core_plugins/kibana/public/home/sample_data_resources/flights/dashboard.png b/src/plugins/home/public/assets/sample_data_resources/flights/dashboard.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/sample_data_resources/flights/dashboard.png rename to src/plugins/home/public/assets/sample_data_resources/flights/dashboard.png diff --git a/src/legacy/core_plugins/kibana/public/home/sample_data_resources/flights/dashboard_dark.png b/src/plugins/home/public/assets/sample_data_resources/flights/dashboard_dark.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/sample_data_resources/flights/dashboard_dark.png rename to src/plugins/home/public/assets/sample_data_resources/flights/dashboard_dark.png diff --git a/src/legacy/core_plugins/kibana/public/home/sample_data_resources/logs/dashboard.png b/src/plugins/home/public/assets/sample_data_resources/logs/dashboard.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/sample_data_resources/logs/dashboard.png rename to src/plugins/home/public/assets/sample_data_resources/logs/dashboard.png diff --git a/src/legacy/core_plugins/kibana/public/home/sample_data_resources/logs/dashboard_dark.png b/src/plugins/home/public/assets/sample_data_resources/logs/dashboard_dark.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/sample_data_resources/logs/dashboard_dark.png rename to src/plugins/home/public/assets/sample_data_resources/logs/dashboard_dark.png diff --git a/src/legacy/core_plugins/kibana/public/home/assets/welcome_graphic_dark_2x.png b/src/plugins/home/public/assets/welcome_graphic_dark_2x.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/assets/welcome_graphic_dark_2x.png rename to src/plugins/home/public/assets/welcome_graphic_dark_2x.png diff --git a/src/legacy/core_plugins/kibana/public/home/assets/welcome_graphic_light_2x.png b/src/plugins/home/public/assets/welcome_graphic_light_2x.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/assets/welcome_graphic_light_2x.png rename to src/plugins/home/public/assets/welcome_graphic_light_2x.png diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts index 3e16187c443432..b0cc2e2db3cc92 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts @@ -36,8 +36,8 @@ export const ecommerceSpecProvider = function(): SampleDatasetSchema { id: 'ecommerce', name: ecommerceName, description: ecommerceDescription, - previewImagePath: '/plugins/kibana/home/sample_data_resources/ecommerce/dashboard.png', - darkPreviewImagePath: '/plugins/kibana/home/sample_data_resources/ecommerce/dashboard_dark.png', + previewImagePath: '/plugins/home/assets/sample_data_resources/ecommerce/dashboard.png', + darkPreviewImagePath: '/plugins/home/assets/sample_data_resources/ecommerce/dashboard_dark.png', overviewDashboard: '722b74f0-b882-11e8-a6d9-e546fe2bba5f', appLinks: initialAppLinks, defaultIndex: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', diff --git a/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts b/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts index d63ea8f7fb4930..fc3cb6094b5eaa 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts @@ -36,8 +36,8 @@ export const flightsSpecProvider = function(): SampleDatasetSchema { id: 'flights', name: flightsName, description: flightsDescription, - previewImagePath: '/plugins/kibana/home/sample_data_resources/flights/dashboard.png', - darkPreviewImagePath: '/plugins/kibana/home/sample_data_resources/flights/dashboard_dark.png', + previewImagePath: '/plugins/home/assets/sample_data_resources/flights/dashboard.png', + darkPreviewImagePath: '/plugins/home/assets/sample_data_resources/flights/dashboard_dark.png', overviewDashboard: '7adfa750-4c81-11e8-b3d7-01146121b73d', appLinks: initialAppLinks, defaultIndex: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', diff --git a/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts b/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts index bb6e2982f59a08..d8f205dff24e8b 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts @@ -36,8 +36,8 @@ export const logsSpecProvider = function(): SampleDatasetSchema { id: 'logs', name: logsName, description: logsDescription, - previewImagePath: '/plugins/kibana/home/sample_data_resources/logs/dashboard.png', - darkPreviewImagePath: '/plugins/kibana/home/sample_data_resources/logs/dashboard_dark.png', + previewImagePath: '/plugins/home/assets/sample_data_resources/logs/dashboard.png', + darkPreviewImagePath: '/plugins/home/assets/sample_data_resources/logs/dashboard_dark.png', overviewDashboard: 'edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b', appLinks: initialAppLinks, defaultIndex: '90943e30-9a47-11e8-b64d-95841ca0b247', diff --git a/test/common/services/elasticsearch.ts b/test/common/services/elasticsearch.ts index 63c4bfeeb4ce77..0436dc901292d5 100644 --- a/test/common/services/elasticsearch.ts +++ b/test/common/services/elasticsearch.ts @@ -18,8 +18,9 @@ */ import { format as formatUrl } from 'url'; - +import fs from 'fs'; import { Client } from '@elastic/elasticsearch'; +import { CA_CERT_PATH } from '@kbn/dev-utils'; import { FtrProviderContext } from '../ftr_provider_context'; @@ -27,6 +28,9 @@ export function ElasticsearchProvider({ getService }: FtrProviderContext) { const config = getService('config'); return new Client({ + ssl: { + ca: fs.readFileSync(CA_CERT_PATH, 'utf-8'), + }, nodes: [formatUrl(config.get('servers.elasticsearch'))], requestTimeout: config.get('timeouts.esRequestTimeout'), }); diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/index.ts b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/index.ts deleted file mode 100644 index 99f54277be5d2e..00000000000000 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Legacy } from 'kibana'; - -// eslint-disable-next-line import/no-default-export -export default function(kibana: any) { - return new kibana.Plugin({ - require: ['kibana'], - uiExports: { - app: { - title: 'Embeddable Explorer', - order: 1, - main: 'plugins/kbn_tp_embeddable_explorer/np_ready/public/legacy', - }, - }, - init(server: Legacy.Server) { - server.injectUiAppVars('kbn_tp_embeddable_explorer', async () => - server.getInjectedUiAppVars('kibana') - ); - }, - }); -} diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/kibana.json b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/kibana.json new file mode 100644 index 00000000000000..6c8d51ccb8651f --- /dev/null +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/kibana.json @@ -0,0 +1,16 @@ +{ + "id": "kbn_tp_embeddable_explorer", + "version": "0.0.1", + "kibanaVersion": "kibana", + "requiredPlugins": [ + "visTypeMarkdown", + "visTypeVislib", + "data", + "embeddable", + "uiActions", + "inspector", + "discover" + ], + "server": false, + "ui": true +} diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/app.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/app.tsx similarity index 100% rename from test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/app.tsx rename to test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/app.tsx diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/dashboard_container_example.tsx similarity index 98% rename from test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx rename to test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/dashboard_container_example.tsx index 16c2840d6a32e8..e56b82378ddf7c 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/dashboard_container_example.tsx @@ -24,7 +24,7 @@ import { DASHBOARD_CONTAINER_TYPE, DashboardContainer, DashboardContainerInput, -} from '../../../../../../../../src/plugins/dashboard/public'; +} from '../../../../../../src/plugins/dashboard/public'; import { dashboardInput } from './dashboard_input'; diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_input.ts b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/dashboard_input.ts similarity index 96% rename from test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_input.ts rename to test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/dashboard_input.ts index 37ef8cad948cb7..6f4e1f052f5e09 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_input.ts +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/dashboard_input.ts @@ -18,7 +18,7 @@ */ import { ViewMode, CONTACT_CARD_EMBEDDABLE, HELLO_WORLD_EMBEDDABLE } from '../embeddable_api'; -import { DashboardContainerInput } from '../../../../../../../../src/plugins/dashboard/public'; +import { DashboardContainerInput } from '../../../../../../src/plugins/dashboard/public'; export const dashboardInput: DashboardContainerInput = { panels: { diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/index.ts b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/index.ts similarity index 100% rename from test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/index.ts rename to test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/index.ts diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/embeddable_api.ts b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/embeddable_api.ts similarity index 74% rename from test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/embeddable_api.ts rename to test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/embeddable_api.ts index dd25bebf899200..9f6597fefa1e40 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/embeddable_api.ts +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/embeddable_api.ts @@ -17,6 +17,9 @@ * under the License. */ -export * from '../../../../../../../src/plugins/embeddable/public'; -export * from '../../../../../../../src/plugins/embeddable/public/lib/test_samples'; -export { HELLO_WORLD_EMBEDDABLE } from '../../../../../../../examples/embeddable_examples/public'; +export * from '../../../../../src/plugins/embeddable/public'; +export * from '../../../../../src/plugins/embeddable/public/lib/test_samples'; +export { + HELLO_WORLD_EMBEDDABLE, + HelloWorldEmbeddableFactory, +} from '../../../../../examples/embeddable_examples/public'; diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/index.ts b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/index.ts similarity index 100% rename from test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/index.ts rename to test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/index.ts diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/initialize.ts b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/initialize.ts deleted file mode 100644 index a4bc3cf17026c3..00000000000000 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/initialize.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import './np_ready/public/legacy'; diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/kibana.json b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/kibana.json deleted file mode 100644 index d0d0784eae8d33..00000000000000 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/kibana.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "kbn_tp_embeddable_explorer", - "version": "kibana", - "requiredPlugins": [ - "embeddable", - "inspector" - ], - "server": false, - "ui": true -} diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/index.html b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/index.html deleted file mode 100644 index a242631e1638f3..00000000000000 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/index.html +++ /dev/null @@ -1,3 +0,0 @@ - -

ANGULAR STUFF!
- diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/legacy.ts b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/legacy.ts deleted file mode 100644 index 6d125bc3002e00..00000000000000 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/legacy.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -/* eslint-disable @kbn/eslint/no-restricted-paths */ -import 'ui/autoload/all'; - -import 'uiExports/interpreter'; -import 'uiExports/embeddableFactories'; -import 'uiExports/embeddableActions'; -import 'uiExports/contextMenuActions'; -import 'uiExports/devTools'; -import 'uiExports/docViews'; -import 'uiExports/embeddableActions'; -import 'uiExports/fieldFormatEditors'; -import 'uiExports/fieldFormats'; -import 'uiExports/home'; -import 'uiExports/indexManagement'; -import 'uiExports/inspectorViews'; -import 'uiExports/savedObjectTypes'; -import 'uiExports/search'; -import 'uiExports/shareContextMenuExtensions'; -import 'uiExports/visTypes'; -import 'uiExports/visualize'; - -import { npSetup, npStart } from 'ui/new_platform'; -import { ExitFullScreenButton } from 'ui/exit_full_screen'; -import uiRoutes from 'ui/routes'; -// @ts-ignore -import { uiModules } from 'ui/modules'; -/* eslint-enable @kbn/eslint/no-restricted-paths */ - -import template from './index.html'; - -import { plugin } from '.'; - -const pluginInstance = plugin({} as any); - -export const setup = pluginInstance.setup(npSetup.core, { - embeddable: npSetup.plugins.embeddable, - inspector: npSetup.plugins.inspector, - __LEGACY: { - ExitFullScreenButton, - }, -}); - -let rendered = false; -const onRenderCompleteListeners: Array<() => void> = []; - -uiRoutes.enable(); -uiRoutes.defaults(/\embeddable_explorer/, {}); -uiRoutes.when('/', { - template, - controller($scope) { - $scope.$$postDigest(() => { - rendered = true; - onRenderCompleteListeners.forEach(listener => listener()); - }); - }, -}); - -export const start = pluginInstance.start(npStart.core, { - embeddable: npStart.plugins.embeddable, - inspector: npStart.plugins.inspector, - uiActions: npStart.plugins.uiActions, - __LEGACY: { - ExitFullScreenButton, - onRenderComplete: (renderCompleteListener: () => void) => { - if (rendered) { - renderCompleteListener(); - } else { - onRenderCompleteListeners.push(renderCompleteListener); - } - }, - }, -}); diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx deleted file mode 100644 index b47e84216dd16e..00000000000000 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import React from 'react'; -import ReactDOM from 'react-dom'; -import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/public'; -import { createHelloWorldAction } from '../../../../../../../src/plugins/ui_actions/public/tests/test_samples'; - -import { - Start as InspectorStartContract, - Setup as InspectorSetupContract, -} from '../../../../../../../src/plugins/inspector/public'; - -import { CONTEXT_MENU_TRIGGER } from './embeddable_api'; - -const REACT_ROOT_ID = 'embeddableExplorerRoot'; - -import { SayHelloAction, createSendMessageAction } from './embeddable_api'; -import { App } from './app'; -import { - EmbeddableStart, - EmbeddableSetup, -} from '.../../../../../../../src/plugins/embeddable/public'; - -export interface SetupDependencies { - embeddable: EmbeddableSetup; - inspector: InspectorSetupContract; - __LEGACY: { - ExitFullScreenButton: React.ComponentType; - }; -} - -interface StartDependencies { - embeddable: EmbeddableStart; - uiActions: UiActionsStart; - inspector: InspectorStartContract; - __LEGACY: { - ExitFullScreenButton: React.ComponentType; - onRenderComplete: (onRenderComplete: () => void) => void; - }; -} - -export type EmbeddableExplorerSetup = void; -export type EmbeddableExplorerStart = void; - -export class EmbeddableExplorerPublicPlugin - implements - Plugin { - public setup(core: CoreSetup, setupDeps: SetupDependencies): EmbeddableExplorerSetup {} - - public start(core: CoreStart, plugins: StartDependencies): EmbeddableExplorerStart { - const helloWorldAction = createHelloWorldAction(core.overlays); - const sayHelloAction = new SayHelloAction(alert); - const sendMessageAction = createSendMessageAction(core.overlays); - - plugins.uiActions.registerAction(sayHelloAction); - plugins.uiActions.registerAction(sendMessageAction); - - plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, helloWorldAction); - - plugins.__LEGACY.onRenderComplete(() => { - const root = document.getElementById(REACT_ROOT_ID); - ReactDOM.render(, root); - }); - } - - public stop() {} -} diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/plugin.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/plugin.tsx new file mode 100644 index 00000000000000..f99d89ca630bbc --- /dev/null +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/plugin.tsx @@ -0,0 +1,98 @@ +/* + * 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 React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { CoreSetup, Plugin, AppMountParameters } from 'kibana/public'; +import { UiActionsStart, UiActionsSetup } from '../../../../../src/plugins/ui_actions/public'; +import { createHelloWorldAction } from '../../../../../src/plugins/ui_actions/public/tests/test_samples'; + +import { + Start as InspectorStartContract, + Setup as InspectorSetupContract, +} from '../../../../../src/plugins/inspector/public'; + +import { App } from './app'; +import { + CONTEXT_MENU_TRIGGER, + CONTACT_CARD_EMBEDDABLE, + HELLO_WORLD_EMBEDDABLE, + HelloWorldEmbeddableFactory, + ContactCardEmbeddableFactory, + SayHelloAction, + createSendMessageAction, +} from './embeddable_api'; +import { + EmbeddableStart, + EmbeddableSetup, +} from '.../../../../../../../src/plugins/embeddable/public'; + +export interface SetupDependencies { + embeddable: EmbeddableSetup; + inspector: InspectorSetupContract; + uiActions: UiActionsSetup; +} + +interface StartDependencies { + embeddable: EmbeddableStart; + uiActions: UiActionsStart; + inspector: InspectorStartContract; +} + +export type EmbeddableExplorerSetup = void; +export type EmbeddableExplorerStart = void; + +export class EmbeddableExplorerPublicPlugin + implements + Plugin { + public setup(core: CoreSetup, setupDeps: SetupDependencies): EmbeddableExplorerSetup { + const helloWorldAction = createHelloWorldAction({} as any); + const sayHelloAction = new SayHelloAction(alert); + const sendMessageAction = createSendMessageAction({} as any); + + setupDeps.uiActions.registerAction(helloWorldAction); + setupDeps.uiActions.registerAction(sayHelloAction); + setupDeps.uiActions.registerAction(sendMessageAction); + + setupDeps.uiActions.attachAction(CONTEXT_MENU_TRIGGER, helloWorldAction.id); + + setupDeps.embeddable.registerEmbeddableFactory( + HELLO_WORLD_EMBEDDABLE, + new HelloWorldEmbeddableFactory() + ); + + setupDeps.embeddable.registerEmbeddableFactory( + CONTACT_CARD_EMBEDDABLE, + new ContactCardEmbeddableFactory((() => null) as any, {} as any) + ); + + core.application.register({ + id: 'EmbeddableExplorer', + title: 'Embeddable Explorer', + async mount(params: AppMountParameters) { + const startPlugins = (await core.getStartServices())[1] as StartDependencies; + render(, params.element); + + return () => unmountComponentAtNode(params.element); + }, + }); + } + + public start() {} + public stop() {} +} diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx index 867ead688d23d0..4d14226777a0b8 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx @@ -32,9 +32,9 @@ export interface ActionWizardProps { /** * Action factory selected changed - * null - means user click "change" and removed action factory selection + * empty - means user click "change" and removed action factory selection */ - onActionFactoryChange: (actionFactory: ActionFactory | null) => void; + onActionFactoryChange: (actionFactory?: ActionFactory) => void; /** * current config for currently selected action factory @@ -71,7 +71,7 @@ export const ActionWizard: React.FC = ({ actionFactory={currentActionFactory} showDeselect={actionFactories.length > 1} onDeselect={() => { - onActionFactoryChange(null); + onActionFactoryChange(undefined); }} context={context} config={config} diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx index c3e749f163c949..692e86b53f09d7 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx @@ -167,7 +167,7 @@ export function Demo({ actionFactories }: { actionFactories: Array({}); - function changeActionFactory(newActionFactory: ActionFactory | null) { + function changeActionFactory(newActionFactory?: ActionFactory) { if (!newActionFactory) { // removing action factory return setState({}); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx index 6749b41e81fc70..52c53f32ff09b4 100644 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx @@ -173,6 +173,42 @@ test('Create only mode', async () => { expect(await mockDynamicActionManager.state.get().events.length).toBe(1); }); +test('After switching between action factories state is restored', async () => { + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + + // change to dashboard + fireEvent.click(screen.getByText(/change/i)); + fireEvent.click(screen.getByText(/Go to Dashboard/i)); + + // change back to url + fireEvent.click(screen.getByText(/change/i)); + fireEvent.click(screen.getByText(/Go to URL/i)); + + expect(screen.getByLabelText(/url/i)).toHaveValue('https://elastic.co'); + expect(screen.getByLabelText(/name/i)).toHaveValue('test'); + + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + await wait(() => expect(notifications.toasts.addSuccess).toBeCalled()); + expect(await (mockDynamicActionManager.state.get().events[0].action.config as any).url).toBe( + 'https://elastic.co' + ); +}); + test.todo("Error when can't fetch drilldown list"); test("Error when can't save drilldown changes", async () => { diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx index 8541aae06ff0c7..1f775a5ff103f1 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx @@ -41,6 +41,72 @@ export interface FlyoutDrilldownWizardProps void; + setActionConfig: (actionConfig: object) => void; + setActionFactory: (actionFactory?: ActionFactory) => void; + } +] { + const [wizardConfig, setWizardConfig] = useState( + () => + initialDrilldownWizardConfig ?? { + name: '', + } + ); + const [actionConfigCache, setActionConfigCache] = useState>( + initialDrilldownWizardConfig?.actionFactory + ? { + [initialDrilldownWizardConfig.actionFactory + .id]: initialDrilldownWizardConfig.actionConfig!, + } + : {} + ); + + return [ + wizardConfig, + { + setName: (name: string) => { + setWizardConfig({ + ...wizardConfig, + name, + }); + }, + setActionConfig: (actionConfig: object) => { + setWizardConfig({ + ...wizardConfig, + actionConfig, + }); + }, + setActionFactory: (actionFactory?: ActionFactory) => { + if (actionFactory) { + setWizardConfig({ + ...wizardConfig, + actionFactory, + actionConfig: actionConfigCache[actionFactory.id] ?? actionFactory.createConfig(), + }); + } else { + if (wizardConfig.actionFactory?.id) { + setActionConfigCache({ + ...actionConfigCache, + [wizardConfig.actionFactory.id]: wizardConfig.actionConfig!, + }); + } + + setWizardConfig({ + ...wizardConfig, + actionFactory: undefined, + actionConfig: undefined, + }); + } + }, + }, + ]; +} + export function FlyoutDrilldownWizard({ onClose, onBack, @@ -53,11 +119,8 @@ export function FlyoutDrilldownWizard) { - const [wizardConfig, setWizardConfig] = useState( - () => - initialDrilldownWizardConfig ?? { - name: '', - } + const [wizardConfig, { setActionFactory, setActionConfig, setName }] = useWizardConfigState( + initialDrilldownWizardConfig ); const isActionValid = ( @@ -95,35 +158,11 @@ export function FlyoutDrilldownWizard { - setWizardConfig({ - ...wizardConfig, - name: newName, - }); - }} + onNameChange={setName} actionConfig={wizardConfig.actionConfig} - onActionConfigChange={newActionConfig => { - setWizardConfig({ - ...wizardConfig, - actionConfig: newActionConfig, - }); - }} + onActionConfigChange={setActionConfig} currentActionFactory={wizardConfig.actionFactory} - onActionFactoryChange={actionFactory => { - if (!actionFactory) { - setWizardConfig({ - ...wizardConfig, - actionFactory: undefined, - actionConfig: undefined, - }); - } else { - setWizardConfig({ - ...wizardConfig, - actionFactory, - actionConfig: actionFactory.createConfig(), - }); - } - }} + onActionFactoryChange={setActionFactory} actionFactories={drilldownActionFactories} actionFactoryContext={actionFactoryContext!} /> diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx index 93b3710bf6cc66..3bed81a9719216 100644 --- a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx @@ -19,7 +19,7 @@ export interface FormDrilldownWizardProps { onNameChange?: (name: string) => void; currentActionFactory?: ActionFactory; - onActionFactoryChange?: (actionFactory: ActionFactory | null) => void; + onActionFactoryChange?: (actionFactory?: ActionFactory) => void; actionFactoryContext: object; actionConfig?: object; diff --git a/x-pack/plugins/graph/public/angular/templates/index.html b/x-pack/plugins/graph/public/angular/templates/index.html index 8555658596179a..939d92518e271c 100644 --- a/x-pack/plugins/graph/public/angular/templates/index.html +++ b/x-pack/plugins/graph/public/angular/templates/index.html @@ -122,8 +122,8 @@ diff --git a/x-pack/plugins/index_management/__jest__/components/index_table.test.js b/x-pack/plugins/index_management/__jest__/components/index_table.test.js index 15c3ef0b845624..84fbc04aa5a313 100644 --- a/x-pack/plugins/index_management/__jest__/components/index_table.test.js +++ b/x-pack/plugins/index_management/__jest__/components/index_table.test.js @@ -8,6 +8,15 @@ import React from 'react'; import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { MemoryRouter } from 'react-router-dom'; + +/** + * The below import is required to avoid a console error warn from brace package + * console.warn ../node_modules/brace/index.js:3999 + Could not load worker ReferenceError: Worker is not defined + at createWorker (//node_modules/brace/index.js:17992:5) + */ +import * as stubWebWorker from '../../../../test_utils/stub_web_worker'; // eslint-disable-line no-unused-vars + import { AppWithoutRouter } from '../../public/application/app'; import { AppContextProvider } from '../../public/application/app_context'; import { Provider } from 'react-redux'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/index.ts new file mode 100644 index 00000000000000..eac68770d3de23 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/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 { defaultShapeParameters } from './shape_datatype.test'; +export { defaultTextParameters } from './text_datatype.test'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/shape_datatype.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/shape_datatype.test.tsx new file mode 100644 index 00000000000000..19bf6973472ff3 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/shape_datatype.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act } from 'react-dom/test-utils'; + +import { componentHelpers, MappingsEditorTestBed } from '../helpers'; + +const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor; +const onChangeHandler = jest.fn(); +const getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + +// Parameters automatically added to the shape datatype when saved (with the default values) +export const defaultShapeParameters = { + type: 'shape', + coerce: false, + ignore_malformed: false, + ignore_z_value: true, +}; + +describe('Mappings editor: shape datatype', () => { + let testBed: MappingsEditorTestBed; + + /** + * Variable to store the mappings data forwarded to the consumer component + */ + let data: any; + + test('initial view and default parameters values', async () => { + const defaultMappings = { + _meta: {}, + _source: {}, + properties: { + myField: { + type: 'shape', + }, + }, + }; + + const updatedMappings = { ...defaultMappings }; + + await act(async () => { + testBed = await setup({ value: defaultMappings, onChange: onChangeHandler }); + }); + + const { + exists, + waitFor, + waitForFn, + actions: { startEditField, updateFieldAndCloseFlyout }, + } = testBed; + + // Open the flyout to edit the field + await act(async () => { + startEditField('myField'); + }); + + await waitFor('mappingsEditorFieldEdit'); + + // Save the field and close the flyout + await act(async () => { + await updateFieldAndCloseFlyout(); + }); + + await waitForFn( + async () => exists('mappingsEditorFieldEdit') === false, + 'Error waiting for the details flyout to close' + ); + + // It should have the default parameters values added + updatedMappings.properties.myField = { + type: 'shape', + ...defaultShapeParameters, + }; + + ({ data } = await getMappingsEditorData()); + expect(data).toEqual(updatedMappings); + }); +}); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx new file mode 100644 index 00000000000000..2bfaa884a01329 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx @@ -0,0 +1,459 @@ +/* + * 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 { act } from 'react-dom/test-utils'; + +import { componentHelpers, MappingsEditorTestBed } from '../helpers'; +import { getFieldConfig } from '../../../lib'; + +const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor; +const onChangeHandler = jest.fn(); +const getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + +// Parameters automatically added to the text datatype when saved (with the default values) +export const defaultTextParameters = { + type: 'text', + eager_global_ordinals: false, + fielddata: false, + index: true, + index_options: 'positions', + index_phrases: false, + norms: true, + store: false, +}; + +describe('Mappings editor: text datatype', () => { + let testBed: MappingsEditorTestBed; + + /** + * Variable to store the mappings data forwarded to the consumer component + */ + let data: any; + + afterEach(() => { + onChangeHandler.mockReset(); + }); + + test('initial view and default parameters values', async () => { + const defaultMappings = { + _meta: {}, + _source: {}, + properties: { + myField: { + type: 'text', + }, + }, + }; + + const updatedMappings = { ...defaultMappings }; + + await act(async () => { + testBed = await setup({ value: defaultMappings, onChange: onChangeHandler }); + }); + + const { + exists, + waitFor, + waitForFn, + actions: { startEditField, getToggleValue, updateFieldAndCloseFlyout }, + } = testBed; + + // Open the flyout to edit the field + await act(async () => { + startEditField('myField'); + }); + + await waitFor('mappingsEditorFieldEdit'); + + // It should have searchable ("index" param) active by default + const indexFieldConfig = getFieldConfig('index'); + expect(getToggleValue('indexParameter.formRowToggle')).toBe(indexFieldConfig.defaultValue); + + // Save the field and close the flyout + await act(async () => { + updateFieldAndCloseFlyout(); + }); + + await waitForFn( + async () => exists('mappingsEditorFieldEdit') === false, + 'Error waiting for the details flyout to close' + ); + + // It should have the default parameters values added + updatedMappings.properties.myField = { + type: 'text', + ...defaultTextParameters, + }; + + ({ data } = await getMappingsEditorData()); + expect(data).toEqual(updatedMappings); + }, 30000); + + test('analyzer parameter: default values', async () => { + const defaultMappings = { + _meta: {}, + _source: {}, + properties: { + myField: { + type: 'text', + // Should have 2 dropdown selects: + // The first one set to 'language' and the second one set to 'french + search_quote_analyzer: 'french', + }, + }, + }; + + testBed = await setup({ value: defaultMappings, onChange: onChangeHandler }); + + const { + find, + exists, + waitFor, + waitForFn, + form: { selectCheckBox, setSelectValue }, + actions: { + startEditField, + getCheckboxValue, + showAdvancedSettings, + updateFieldAndCloseFlyout, + }, + } = testBed; + const fieldToEdit = 'myField'; + + // Start edit and immediately save to have all the default values + await act(async () => { + startEditField(fieldToEdit); + }); + await waitFor('mappingsEditorFieldEdit'); + await showAdvancedSettings(); + + await act(async () => { + updateFieldAndCloseFlyout(); + }); + + await waitForFn( + async () => exists('mappingsEditorFieldEdit') === false, + 'Error waiting for the details flyout to close' + ); + + ({ data } = await getMappingsEditorData()); + + let updatedMappings: any = { + ...defaultMappings, + properties: { + myField: { + ...defaultMappings.properties.myField, + ...defaultTextParameters, + }, + }, + }; + expect(data).toEqual(updatedMappings); + + // Re-open the edit panel + await act(async () => { + startEditField('myField'); + }); + await waitFor('mappingsEditorFieldEdit'); + await showAdvancedSettings(); + + // When no analyzer is defined, defaults to "Index default" + let indexAnalyzerValue = find('indexAnalyzer.select').props().value; + expect(indexAnalyzerValue).toEqual('index_default'); + + const searchQuoteAnalyzerSelects = find('searchQuoteAnalyzer.select'); + + expect(searchQuoteAnalyzerSelects.length).toBe(2); + expect(searchQuoteAnalyzerSelects.at(0).props().value).toBe('language'); + expect(searchQuoteAnalyzerSelects.at(1).props().value).toBe( + defaultMappings.properties.myField.search_quote_analyzer + ); + + // When no "search_analyzer" is defined, the checkBox should be checked + let isUseSameAnalyzerForSearchChecked = getCheckboxValue( + 'useSameAnalyzerForSearchCheckBox.input' + ); + expect(isUseSameAnalyzerForSearchChecked).toBe(true); + + // And the search analyzer select should not exist + expect(exists('searchAnalyzer')).toBe(false); + + // Uncheck the "Use same analyzer for search" checkbox and wait for the search analyzer select + await act(async () => { + selectCheckBox('useSameAnalyzerForSearchCheckBox.input', false); + }); + + await waitFor('searchAnalyzer'); + + let searchAnalyzerValue = find('searchAnalyzer.select').props().value; + expect(searchAnalyzerValue).toEqual('index_default'); + + await act(async () => { + // Change the value of the 3 analyzers + setSelectValue('indexAnalyzer.select', 'standard'); + setSelectValue('searchAnalyzer.select', 'simple'); + setSelectValue(find('searchQuoteAnalyzer.select').at(0), 'whitespace'); + }); + + // Make sure the second dropdown select has been removed + await waitForFn( + async () => find('searchQuoteAnalyzer.select').length === 1, + 'Error waiting for the second dropdown select of search quote analyzer to be removed' + ); + + await act(async () => { + // Save & close + updateFieldAndCloseFlyout(); + }); + + await waitForFn( + async () => exists('mappingsEditorFieldEdit') === false, + 'Error waiting for the details flyout to close' + ); + + updatedMappings = { + ...updatedMappings, + properties: { + myField: { + ...updatedMappings.properties.myField, + analyzer: 'standard', + search_analyzer: 'simple', + search_quote_analyzer: 'whitespace', + }, + }, + }; + + ({ data } = await getMappingsEditorData()); + expect(data).toEqual(updatedMappings); + + // Re-open the flyout and make sure the select have the correct updated value + await act(async () => { + startEditField('myField'); + }); + await waitFor('mappingsEditorFieldEdit'); + await showAdvancedSettings(); + + isUseSameAnalyzerForSearchChecked = getCheckboxValue('useSameAnalyzerForSearchCheckBox.input'); + expect(isUseSameAnalyzerForSearchChecked).toBe(false); + + indexAnalyzerValue = find('indexAnalyzer.select').props().value; + searchAnalyzerValue = find('searchAnalyzer.select').props().value; + const searchQuoteAnalyzerValue = find('searchQuoteAnalyzer.select').props().value; + + expect(indexAnalyzerValue).toBe('standard'); + expect(searchAnalyzerValue).toBe('simple'); + expect(searchQuoteAnalyzerValue).toBe('whitespace'); + }, 30000); + + test('analyzer parameter: custom analyzer (external plugin)', async () => { + const defaultMappings = { + _meta: {}, + _source: {}, + properties: { + myField: { + type: 'text', + analyzer: 'myCustomIndexAnalyzer', + search_analyzer: 'myCustomSearchAnalyzer', + search_quote_analyzer: 'myCustomSearchQuoteAnalyzer', + }, + }, + }; + + let updatedMappings: any = { + ...defaultMappings, + properties: { + myField: { + ...defaultMappings.properties.myField, + ...defaultTextParameters, + }, + }, + }; + + await act(async () => { + testBed = await setup({ value: defaultMappings, onChange: onChangeHandler }); + }); + + const { + find, + exists, + waitFor, + waitForFn, + component, + form: { setInputValue, setSelectValue }, + actions: { startEditField, showAdvancedSettings, updateFieldAndCloseFlyout }, + } = testBed; + const fieldToEdit = 'myField'; + + await act(async () => { + startEditField(fieldToEdit); + }); + + await waitFor('mappingsEditorFieldEdit'); + await showAdvancedSettings(); + + expect(exists('indexAnalyzer-custom')).toBe(true); + expect(exists('searchAnalyzer-custom')).toBe(true); + expect(exists('searchQuoteAnalyzer-custom')).toBe(true); + + const indexAnalyzerValue = find('indexAnalyzer-custom.input').props().value; + const searchAnalyzerValue = find('searchAnalyzer-custom.input').props().value; + const searchQuoteAnalyzerValue = find('searchQuoteAnalyzer-custom.input').props().value; + + expect(indexAnalyzerValue).toBe(defaultMappings.properties.myField.analyzer); + expect(searchAnalyzerValue).toBe(defaultMappings.properties.myField.search_analyzer); + expect(searchQuoteAnalyzerValue).toBe(defaultMappings.properties.myField.search_quote_analyzer); + + const updatedIndexAnalyzer = 'newCustomIndexAnalyzer'; + const updatedSearchAnalyzer = 'whitespace'; + + await act(async () => { + // Change the index analyzer to another custom one + setInputValue('indexAnalyzer-custom.input', updatedIndexAnalyzer); + + // Change the search analyzer to a built-in analyzer + find('searchAnalyzer-toggleCustomButton').simulate('click'); + component.update(); + }); + + await waitFor('searchAnalyzer'); + + await act(async () => { + setSelectValue('searchAnalyzer.select', updatedSearchAnalyzer); + + // Change the searchQuote to use built-in analyzer + // By default it means using the "index default" + find('searchQuoteAnalyzer-toggleCustomButton').simulate('click'); + component.update(); + }); + + await waitFor('searchQuoteAnalyzer'); + + await act(async () => { + // Save & close + updateFieldAndCloseFlyout(); + }); + + await waitForFn( + async () => exists('mappingsEditorFieldEdit') === false, + 'Error waiting for the details flyout to close' + ); + + ({ data } = await getMappingsEditorData()); + + updatedMappings = { + ...updatedMappings, + properties: { + myField: { + ...updatedMappings.properties.myField, + analyzer: updatedIndexAnalyzer, + search_analyzer: updatedSearchAnalyzer, + search_quote_analyzer: undefined, // Index default means not declaring the analyzer + }, + }, + }; + + expect(data).toEqual(updatedMappings); + }, 30000); + + test('analyzer parameter: custom analyzer (from index settings)', async () => { + const indexSettings = { + analysis: { + analyzer: { + customAnalyzer_1: {}, + customAnalyzer_2: {}, + customAnalyzer_3: {}, + }, + }, + }; + + const customAnalyzers = Object.keys(indexSettings.analysis.analyzer); + + const defaultMappings = { + _meta: {}, + _source: {}, + properties: { + myField: { + type: 'text', + analyzer: customAnalyzers[0], + }, + }, + }; + + let updatedMappings: any = { + ...defaultMappings, + properties: { + myField: { + ...defaultMappings.properties.myField, + ...defaultTextParameters, + }, + }, + }; + + testBed = await setup({ + value: defaultMappings, + onChange: onChangeHandler, + indexSettings, + }); + + const { + find, + exists, + waitFor, + waitForFn, + form: { setSelectValue }, + actions: { startEditField, showAdvancedSettings, updateFieldAndCloseFlyout }, + } = testBed; + const fieldToEdit = 'myField'; + + await act(async () => { + startEditField(fieldToEdit); + }); + await waitFor('mappingsEditorFieldEdit'); + await showAdvancedSettings(); + + // It should have 2 selects + const indexAnalyzerSelects = find('indexAnalyzer.select'); + + expect(indexAnalyzerSelects.length).toBe(2); + expect(indexAnalyzerSelects.at(0).props().value).toBe('custom'); + expect(indexAnalyzerSelects.at(1).props().value).toBe( + defaultMappings.properties.myField.analyzer + ); + + // Access the list of option of the second dropdown select + const subSelectOptions = indexAnalyzerSelects + .at(1) + .find('option') + .map(wrapper => wrapper.text()); + + expect(subSelectOptions).toEqual(customAnalyzers); + + await act(async () => { + // Change the custom analyzer dropdown to another one from the index settings + setSelectValue(find('indexAnalyzer.select').at(1), customAnalyzers[2]); + + // Save & close + updateFieldAndCloseFlyout(); + }); + + await waitForFn( + async () => exists('mappingsEditorFieldEdit') === false, + 'Error waiting for the details flyout to close' + ); + + ({ data } = await getMappingsEditorData()); + + updatedMappings = { + ...updatedMappings, + properties: { + myField: { + ...updatedMappings.properties.myField, + analyzer: customAnalyzers[2], + }, + }, + }; + + expect(data).toEqual(updatedMappings); + }, 30000); +}); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx new file mode 100644 index 00000000000000..4af5f82d851e38 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx @@ -0,0 +1,127 @@ +/* + * 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 { act } from 'react-dom/test-utils'; + +import { componentHelpers, MappingsEditorTestBed } from './helpers'; +import { defaultTextParameters, defaultShapeParameters } from './datatypes'; +const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor; +const onChangeHandler = jest.fn(); +const getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + +describe('Mappings editor: edit field', () => { + let testBed: MappingsEditorTestBed; + + afterEach(() => { + onChangeHandler.mockReset(); + }); + + test('should open a flyout with the correct field to edit', async () => { + const defaultMappings = { + properties: { + user: { + type: 'object', + properties: { + address: { + type: 'object', + properties: { + street: { type: 'text' }, + }, + }, + }, + }, + }, + }; + + await act(async () => { + testBed = await setup({ value: defaultMappings, onChange: onChangeHandler }); + // Make sure all the fields are expanded and present in the DOM + await testBed.actions.expandAllFieldsAndReturnMetadata(); + }); + + const { + find, + waitFor, + actions: { startEditField }, + } = testBed; + // Open the flyout to edit the field + await act(async () => { + startEditField('user.address.street'); + }); + + await waitFor('mappingsEditorFieldEdit'); + + // It should have the correct title + expect(find('mappingsEditorFieldEdit.flyoutTitle').text()).toEqual(`Edit field 'street'`); + + // It should have the correct field path + expect(find('mappingsEditorFieldEdit.fieldPath').text()).toEqual('user > address > street'); + + // The advanced settings should be hidden initially + expect(find('mappingsEditorFieldEdit.advancedSettings').props().style.display).toEqual('none'); + }); + + test('should update form parameters when changing the field datatype', async () => { + const defaultMappings = { + _meta: {}, + _source: {}, + properties: { + myField: { + ...defaultTextParameters, + }, + }, + }; + + await act(async () => { + testBed = await setup({ value: defaultMappings, onChange: onChangeHandler }); + }); + + const { + find, + exists, + waitFor, + waitForFn, + component, + actions: { startEditField, updateFieldAndCloseFlyout }, + } = testBed; + + // Open the flyout, change the field type and save it + await act(async () => { + startEditField('myField'); + }); + + await waitFor('mappingsEditorFieldEdit'); + + await act(async () => { + // Change the field type + find('mappingsEditorFieldEdit.fieldType').simulate('change', [ + { label: 'Shape', value: defaultShapeParameters.type }, + ]); + component.update(); + }); + + await act(async () => { + await updateFieldAndCloseFlyout(); + }); + + await waitForFn( + async () => exists('mappingsEditorFieldEdit') === false, + 'Error waiting for the details flyout to close' + ); + + const { data } = await getMappingsEditorData(); + + const updatedMappings = { + ...defaultMappings, + properties: { + myField: { + ...defaultShapeParameters, + }, + }, + }; + + expect(data).toEqual(updatedMappings); + }, 15000); +}); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/index.ts index fa6bee56349e95..afdc039ae77d2a 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/index.ts @@ -3,7 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { setup as mappingsEditorSetup, MappingsEditorTestBed } from './mappings_editor.helpers'; +import { + setup as mappingsEditorSetup, + MappingsEditorTestBed, + DomFields, + getMappingsEditorDataFactory, +} from './mappings_editor.helpers'; export { nextTick, @@ -13,7 +18,7 @@ export { } from '../../../../../../../../../test_utils'; export const componentHelpers = { - mappingsEditor: { setup: mappingsEditorSetup }, + mappingsEditor: { setup: mappingsEditorSetup, getMappingsEditorDataFactory }, }; -export { MappingsEditorTestBed }; +export { MappingsEditorTestBed, DomFields }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx index c8c8ef8bfe9b3d..58242ec35018c8 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { ReactWrapper } from 'enzyme'; + import { registerTestBed, TestBed, nextTick } from '../../../../../../../../../test_utils'; +import { getChildFieldsName } from '../../../lib'; import { MappingsEditor } from '../../../mappings_editor'; jest.mock('@elastic/eui', () => ({ @@ -14,6 +18,7 @@ jest.mock('@elastic/eui', () => ({ EuiComboBox: (props: any) => ( { props.onChange([syntheticEvent['0']]); }} @@ -29,14 +34,121 @@ jest.mock('@elastic/eui', () => ({ }} /> ), + // Mocking EuiSuperSelect to be able to easily change its value + // with a `myWrapper.simulate('change', { target: { value: 'someValue' } })` + EuiSuperSelect: (props: any) => ( + { + props.onChange(e.target.value); + }} + /> + ), })); +export interface DomFields { + [key: string]: { + type: string; + properties?: DomFields; + fields?: DomFields; + }; +} + const createActions = (testBed: TestBed) => { - const { find, waitFor, form, component } = testBed; + const { find, exists, waitFor, waitForFn, form, component } = testBed; + + const getFieldInfo = (testSubjectField: string): { name: string; type: string } => { + const name = find(`${testSubjectField}-fieldName` as TestSubjects).text(); + const type = find(`${testSubjectField}-datatype` as TestSubjects).props()['data-type-value']; + return { name, type }; + }; + + const expandField = async ( + field: ReactWrapper + ): Promise<{ hasChildren: boolean; testSubjectField: string }> => { + /** + * Field list item have 2 test subject assigned to them: + * data-test-subj="fieldsListItem " + * + * We read the second one as it is unique. + */ + const testSubjectField = (field.props() as any)['data-test-subj'] + .split(' ') + .filter((subj: string) => subj !== 'fieldsListItem')[0] as string; + + const expandButton = find(`${testSubjectField}.toggleExpandButton` as TestSubjects); + + // No expand button, so this field is not expanded + if (expandButton.length === 0) { + return { hasChildren: false, testSubjectField }; + } + + const isExpanded = (expandButton.props()['aria-label'] as string).includes('Collapse'); + + if (!isExpanded) { + expandButton.simulate('click'); + } + + // Wait for the children FieldList to be in the DOM + await waitFor(`${testSubjectField}.fieldsList` as TestSubjects); + + return { hasChildren: true, testSubjectField }; + }; + + /** + * Expand all the children of a field and return a metadata object of the fields found in the DOM. + * + * @param fieldName The field under wich we want to expand all the children. + * If no fieldName is provided, we expand all the **root** level fields. + */ + const expandAllFieldsAndReturnMetadata = async ( + fieldName?: string, + domTreeMetadata: DomFields = {} + ): Promise => { + const fields = find( + fieldName ? (`${fieldName}.fieldsList.fieldsListItem` as TestSubjects) : 'fieldsListItem' + ).map(wrapper => wrapper); // convert to Array for our for of loop below + + for (const field of fields) { + const { hasChildren, testSubjectField } = await expandField(field); + + // Read the info from the DOM about that field and add it to our domFieldMeta + const { name, type } = getFieldInfo(testSubjectField); + domTreeMetadata[name] = { + type, + }; + + if (hasChildren) { + // Update our metadata object + const childFieldName = getChildFieldsName(type as any)!; + domTreeMetadata[name][childFieldName] = {}; + + // Expand its children + await expandAllFieldsAndReturnMetadata( + testSubjectField, + domTreeMetadata[name][childFieldName] + ); + } + } + + return domTreeMetadata; + }; + + // Get a nested field in the rendered DOM tree + const getFieldAt = (path: string) => { + const testSubjectField = `${path.split('.').join('')}Field`; + return find(testSubjectField as TestSubjects); + }; const addField = async (name: string, type: string) => { const currentCount = find('fieldsListItem').length; + if (!exists('createFieldForm')) { + find('addFieldButton').simulate('click'); + await waitFor('createFieldForm'); + } + form.setInputValue('nameParameterInput', name); find('createFieldForm.fieldType').simulate('change', [ { @@ -54,6 +166,36 @@ const createActions = (testBed: TestBed) => { await waitFor('fieldsListItem', currentCount + 1); }; + const startEditField = (path: string) => { + const field = getFieldAt(path); + find('editFieldButton', field).simulate('click'); + component.update(); + }; + + const updateFieldAndCloseFlyout = () => { + find('mappingsEditorFieldEdit.editFieldUpdateButton').simulate('click'); + component.update(); + }; + + const showAdvancedSettings = async () => { + const checkIsVisible = async () => + find('mappingsEditorFieldEdit.advancedSettings').props().style.display === 'block'; + + if (await checkIsVisible()) { + // Already opened, nothing else to do + return; + } + + await act(async () => { + find('mappingsEditorFieldEdit.toggleAdvancedSetting').simulate('click'); + }); + + await waitForFn( + checkIsVisible, + 'Error waiting for the advanced settings CSS style.display to be "block"' + ); + }; + const selectTab = async (tab: 'fields' | 'templates' | 'advanced') => { const index = ['fields', 'templates', 'advanced'].indexOf(tab); const tabIdToContentMap: { [key: string]: TestSubjects } = { @@ -87,11 +229,33 @@ const createActions = (testBed: TestBed) => { return value; }; + const getComboBoxValue = (testSubject: TestSubjects) => { + const value = find(testSubject).props()['data-currentvalue']; + if (value === undefined) { + return []; + } + return value.map(({ label }: any) => label); + }; + + const getToggleValue = (testSubject: TestSubjects): boolean => + find(testSubject).props()['aria-checked']; + + const getCheckboxValue = (testSubject: TestSubjects): boolean => + find(testSubject).props().checked; + return { selectTab, + getFieldAt, addField, + expandAllFieldsAndReturnMetadata, + startEditField, + updateFieldAndCloseFlyout, + showAdvancedSettings, updateJsonEditor, getJsonEditorValue, + getComboBoxValue, + getToggleValue, + getCheckboxValue, }; }; @@ -109,6 +273,33 @@ export const setup = async (props: any = { onUpdate() {} }): Promise) => { + /** + * Helper to access the latest data sent to the onChange handler back to the consumer of the . + * Read the latest call with its argument passed and build the mappings object from it. + */ + return async () => { + const mockCalls = onChangeHandler.mock.calls; + + if (mockCalls.length === 0) { + throw new Error( + `Can't access data forwarded as the onChange() prop handler hasn't been called.` + ); + } + + const [arg] = mockCalls[mockCalls.length - 1]; + const { isValid, validate, getData } = arg; + + const isMappingsValid = isValid === undefined ? await act(validate) : isValid; + const data = getData(isMappingsValid); + + return { + isValid: isMappingsValid, + data, + }; + }; +}; + export type MappingsEditorTestBed = TestBed & { actions: ReturnType; }; @@ -116,7 +307,9 @@ export type MappingsEditorTestBed = TestBed & { export type TestSubjects = | 'formTab' | 'mappingsEditor' + | 'fieldsList' | 'fieldsListItem' + | 'fieldsListItem.fieldName' | 'fieldName' | 'mappingTypesDetectedCallout' | 'documentFields' @@ -126,7 +319,38 @@ export type TestSubjects = | 'advancedConfiguration.numericDetection.input' | 'advancedConfiguration.dynamicMappingsToggle' | 'advancedConfiguration.dynamicMappingsToggle.input' + | 'advancedConfiguration.metaField' + | 'advancedConfiguration.routingRequiredToggle.input' + | 'sourceField.includesField' + | 'sourceField.excludesField' | 'dynamicTemplatesEditor' | 'nameParameterInput' + | 'addFieldButton' + | 'editFieldButton' + | 'toggleExpandButton' + | 'createFieldForm' | 'createFieldForm.fieldType' - | 'createFieldForm.addButton'; + | 'createFieldForm.addButton' + | 'mappingsEditorFieldEdit' + | 'mappingsEditorFieldEdit.fieldType' + | 'mappingsEditorFieldEdit.editFieldUpdateButton' + | 'mappingsEditorFieldEdit.flyoutTitle' + | 'mappingsEditorFieldEdit.documentationLink' + | 'mappingsEditorFieldEdit.fieldPath' + | 'mappingsEditorFieldEdit.advancedSettings' + | 'mappingsEditorFieldEdit.toggleAdvancedSetting' + | 'indexParameter.formRowToggle' + | 'indexAnalyzer.select' + | 'searchAnalyzer' + | 'searchAnalyzer.select' + | 'searchQuoteAnalyzer' + | 'searchQuoteAnalyzer.select' + | 'indexAnalyzer-custom' + | 'indexAnalyzer-custom.input' + | 'searchAnalyzer-toggleCustomButton' + | 'searchAnalyzer-custom' + | 'searchAnalyzer-custom.input' + | 'searchQuoteAnalyzer-custom' + | 'searchQuoteAnalyzer-toggleCustomButton' + | 'searchQuoteAnalyzer-custom.input' + | 'useSameAnalyzerForSearchCheckBox.input'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mapped_fields.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mapped_fields.test.tsx new file mode 100644 index 00000000000000..8989e85d9f188d --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mapped_fields.test.tsx @@ -0,0 +1,104 @@ +/* + * 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 { act } from 'react-dom/test-utils'; + +import { componentHelpers, MappingsEditorTestBed, DomFields, nextTick } from './helpers'; + +const { setup } = componentHelpers.mappingsEditor; +const onChangeHandler = jest.fn(); + +describe('Mappings editor: mapped fields', () => { + afterEach(() => { + onChangeHandler.mockReset(); + }); + + describe('', () => { + let testBed: MappingsEditorTestBed; + const defaultMappings = { + properties: { + myField: { + type: 'text', + fields: { + raw: { + type: 'keyword', + }, + simpleAnalyzer: { + type: 'text', + }, + }, + }, + myObject: { + type: 'object', + properties: { + deeplyNested: { + type: 'object', + properties: { + title: { + type: 'text', + fields: { + raw: { type: 'keyword' }, + }, + }, + }, + }, + }, + }, + }, + }; + + test('should correctly represent the fields in the DOM tree', async () => { + await act(async () => { + testBed = await setup({ + value: defaultMappings, + onChange: onChangeHandler, + }); + }); + + const { + actions: { expandAllFieldsAndReturnMetadata }, + } = testBed; + + let domTreeMetadata: DomFields = {}; + await act(async () => { + domTreeMetadata = await expandAllFieldsAndReturnMetadata(); + }); + + expect(domTreeMetadata).toEqual(defaultMappings.properties); + }); + + test('should allow to be controlled by parent component and update on prop change', async () => { + await act(async () => { + testBed = await setup({ + value: defaultMappings, + onChange: onChangeHandler, + }); + }); + + const { + component, + setProps, + actions: { expandAllFieldsAndReturnMetadata }, + } = testBed; + + const newMappings = { properties: { hello: { type: 'text' } } }; + let domTreeMetadata: DomFields = {}; + + await act(async () => { + // Change the `value` prop of our + setProps({ value: newMappings }); + + // Don't ask me why but the 3 following lines are all required + component.update(); + await nextTick(); + component.update(); + + domTreeMetadata = await expandAllFieldsAndReturnMetadata(); + }); + + expect(domTreeMetadata).toEqual(newMappings.properties); + }); + }); +}); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx index 0cf5bf3f4453ff..f516dfdb372ce7 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx @@ -5,15 +5,55 @@ */ import { act } from 'react-dom/test-utils'; -import { componentHelpers, MappingsEditorTestBed, nextTick, getRandomString } from './helpers'; +import { componentHelpers, MappingsEditorTestBed, nextTick } from './helpers'; -const { setup } = componentHelpers.mappingsEditor; -const mockOnUpdate = () => undefined; +const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor; +const onChangeHandler = jest.fn(); +const getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + +describe('Mappings editor: core', () => { + /** + * Variable to store the mappings data forwarded to the consumer component + */ + let data: any; + + afterEach(() => { + onChangeHandler.mockReset(); + }); + + test('default behaviour', async () => { + const defaultMappings = { + properties: { + user: { + // No type defined for user + properties: { + name: { type: 'text' }, + }, + }, + }, + }; + + await setup({ value: defaultMappings, onChange: onChangeHandler }); + + const expectedMappings = { + _meta: {}, // Was not defined so an empty object is returned + _source: {}, // Was not defined so an empty object is returned + ...defaultMappings, + properties: { + user: { + type: 'object', // Was not defined so it defaults to "object" type + ...defaultMappings.properties.user, + }, + }, + }; + + ({ data } = await getMappingsEditorData()); + expect(data).toEqual(expectedMappings); + }); -describe('', () => { describe('multiple mappings detection', () => { test('should show a warning when multiple mappings are detected', async () => { - const defaultValue = { + const value = { type1: { properties: { name1: { @@ -29,7 +69,7 @@ describe('', () => { }, }, }; - const testBed = await setup({ onUpdate: mockOnUpdate, defaultValue }); + const testBed = await setup({ onChange: onChangeHandler, value }); const { exists } = testBed; expect(exists('mappingsEditor')).toBe(true); @@ -38,14 +78,14 @@ describe('', () => { }); test('should not show a warning when mappings a single-type', async () => { - const defaultValue = { + const value = { properties: { name1: { type: 'keyword', }, }, }; - const testBed = await setup({ onUpdate: mockOnUpdate, defaultValue }); + const testBed = await setup({ onChange: onChangeHandler, value }); const { exists } = testBed; expect(exists('mappingsEditor')).toBe(true); @@ -62,12 +102,12 @@ describe('', () => { let testBed: MappingsEditorTestBed; beforeEach(async () => { - testBed = await setup({ defaultValue: defaultMappings, onUpdate() {} }); + testBed = await setup({ value: defaultMappings, onChange: onChangeHandler }); }); test('should keep the changes when switching tabs', async () => { const { - actions: { addField, selectTab, updateJsonEditor, getJsonEditorValue }, + actions: { addField, selectTab, updateJsonEditor, getJsonEditorValue, getToggleValue }, component, find, exists, @@ -79,7 +119,7 @@ describe('', () => { // ------------------------------------- expect(find('fieldsListItem').length).toEqual(0); // Check that we start with an empty list - const newField = { name: getRandomString(), type: 'text' }; + const newField = { name: 'John', type: 'text' }; await act(async () => { await addField(newField.name, newField.type); }); @@ -101,7 +141,6 @@ describe('', () => { // Update the dynamic templates editor value const updatedValueTemplates = [{ after: 'bar' }]; - await act(async () => { await updateJsonEditor('dynamicTemplatesEditor', updatedValueTemplates); await nextTick(); @@ -118,9 +157,9 @@ describe('', () => { await selectTab('advanced'); }); - let isDynamicMappingsEnabled = find( + let isDynamicMappingsEnabled = getToggleValue( 'advancedConfiguration.dynamicMappingsToggle.input' - ).props()['aria-checked']; + ); expect(isDynamicMappingsEnabled).toBe(true); let isNumericDetectionVisible = exists('advancedConfiguration.numericDetection'); @@ -134,9 +173,9 @@ describe('', () => { await nextTick(); }); - isDynamicMappingsEnabled = find('advancedConfiguration.dynamicMappingsToggle.input').props()[ - 'aria-checked' - ]; + isDynamicMappingsEnabled = getToggleValue( + 'advancedConfiguration.dynamicMappingsToggle.input' + ); expect(isDynamicMappingsEnabled).toBe(false); isNumericDetectionVisible = exists('advancedConfiguration.numericDetection'); @@ -166,12 +205,185 @@ describe('', () => { await selectTab('advanced'); }); - isDynamicMappingsEnabled = find('advancedConfiguration.dynamicMappingsToggle.input').props()[ - 'aria-checked' - ]; + isDynamicMappingsEnabled = getToggleValue( + 'advancedConfiguration.dynamicMappingsToggle.input' + ); expect(isDynamicMappingsEnabled).toBe(false); isNumericDetectionVisible = exists('advancedConfiguration.numericDetection'); expect(isNumericDetectionVisible).toBe(false); }); }); + + describe('component props', () => { + /** + * Note: the "indexSettings" prop will be tested along with the "analyzer" parameter on a text datatype field, + * as it is the only place where it is consumed by the mappings editor. + * + * The test that covers it is text_datatype.test.tsx: "analyzer parameter: custom analyzer (from index settings)" + */ + const defaultMappings: any = { + dynamic: true, + numeric_detection: false, + date_detection: true, + properties: { + title: { type: 'text' }, + address: { + type: 'object', + properties: { + street: { type: 'text' }, + city: { type: 'text' }, + }, + }, + }, + dynamic_templates: [{ initial: 'value' }], + _source: { + enabled: true, + includes: ['field1', 'field2'], + excludes: ['field3'], + }, + _meta: { + some: 'metaData', + }, + _routing: { + required: false, + }, + }; + + let testBed: MappingsEditorTestBed; + + beforeEach(async () => { + testBed = await setup({ value: defaultMappings, onChange: onChangeHandler }); + }); + + test('props.value => should prepopulate the editor data', async () => { + const { + actions: { selectTab, getJsonEditorValue, getComboBoxValue, getToggleValue }, + find, + } = testBed; + + /** + * Mapped fields + */ + // Test that root-level mappings "properties" are rendered as root-level "DOM tree items" + const fields = find('fieldsListItem.fieldName').map(item => item.text()); + expect(fields).toEqual(Object.keys(defaultMappings.properties).sort()); + + /** + * Dynamic templates + */ + await act(async () => { + await selectTab('templates'); + }); + + // Test that dynamic templates JSON is rendered in the templates editor + const templatesValue = getJsonEditorValue('dynamicTemplatesEditor'); + expect(templatesValue).toEqual(defaultMappings.dynamic_templates); + + /** + * Advanced settings + */ + await act(async () => { + await selectTab('advanced'); + }); + + const isDynamicMappingsEnabled = getToggleValue( + 'advancedConfiguration.dynamicMappingsToggle.input' + ); + expect(isDynamicMappingsEnabled).toBe(defaultMappings.dynamic); + + const isNumericDetectionEnabled = getToggleValue( + 'advancedConfiguration.numericDetection.input' + ); + expect(isNumericDetectionEnabled).toBe(defaultMappings.numeric_detection); + + expect(getComboBoxValue('sourceField.includesField')).toEqual( + defaultMappings._source.includes + ); + expect(getComboBoxValue('sourceField.excludesField')).toEqual( + defaultMappings._source.excludes + ); + + const metaFieldValue = getJsonEditorValue('advancedConfiguration.metaField'); + expect(metaFieldValue).toEqual(defaultMappings._meta); + + const isRoutingRequired = getToggleValue('advancedConfiguration.routingRequiredToggle.input'); + expect(isRoutingRequired).toBe(defaultMappings._routing.required); + }); + + test('props.onChange() => should forward the changes to the consumer component', async () => { + let updatedMappings = { ...defaultMappings }; + + const { + actions: { addField, selectTab, updateJsonEditor }, + component, + form, + } = testBed; + + /** + * Mapped fields + */ + const newField = { name: 'someNewField', type: 'text' }; + updatedMappings = { + ...updatedMappings, + properties: { + ...updatedMappings.properties, + [newField.name]: { type: 'text' }, + }, + }; + + await act(async () => { + await addField(newField.name, newField.type); + }); + + ({ data } = await getMappingsEditorData()); + expect(data).toEqual(updatedMappings); + + /** + * Dynamic templates + */ + await act(async () => { + await selectTab('templates'); + }); + + const updatedTemplatesValue = [{ someTemplateProp: 'updated' }]; + updatedMappings = { + ...updatedMappings, + dynamic_templates: updatedTemplatesValue, + }; + + await act(async () => { + await updateJsonEditor('dynamicTemplatesEditor', updatedTemplatesValue); + await nextTick(); + component.update(); + }); + + ({ data } = await getMappingsEditorData()); + expect(data).toEqual(updatedMappings); + + /** + * Advanced settings + */ + await act(async () => { + await selectTab('advanced'); + }); + + // Disbable dynamic mappings + await act(async () => { + form.toggleEuiSwitch('advancedConfiguration.dynamicMappingsToggle.input'); + }); + + ({ data } = await getMappingsEditorData()); + + // When we disable dynamic mappings, we set it to "false" and remove date and numeric detections + updatedMappings = { + ...updatedMappings, + dynamic: false, + date_detection: undefined, + dynamic_date_formats: undefined, + numeric_detection: undefined, + }; + + expect(data).toEqual(updatedMappings); + }); + }); }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx index 6b33d4450c3ae2..c84756cab8e886 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx @@ -7,6 +7,7 @@ import React, { useEffect, useRef } from 'react'; import { EuiSpacer } from '@elastic/eui'; import { useForm, Form, SerializerFunc } from '../../shared_imports'; +import { GenericObject } from '../../types'; import { Types, useDispatch } from '../../mappings_state'; import { DynamicMappingSection } from './dynamic_mapping_section'; import { SourceFieldSection } from './source_field_section'; @@ -17,10 +18,10 @@ import { configurationFormSchema } from './configuration_form_schema'; type MappingsConfiguration = Types['MappingsConfiguration']; interface Props { - defaultValue?: MappingsConfiguration; + value?: MappingsConfiguration; } -const stringifyJson = (json: { [key: string]: any }) => +const stringifyJson = (json: GenericObject) => Object.keys(json).length ? JSON.stringify(json, null, 2) : '{\n\n}'; const formSerializer: SerializerFunc = formData => { @@ -57,7 +58,7 @@ const formSerializer: SerializerFunc = formData => { }; }; -const formDeserializer = (formData: { [key: string]: any }) => { +const formDeserializer = (formData: GenericObject) => { const { dynamic, numeric_detection, @@ -86,14 +87,14 @@ const formDeserializer = (formData: { [key: string]: any }) => { }; }; -export const ConfigurationForm = React.memo(({ defaultValue }: Props) => { +export const ConfigurationForm = React.memo(({ value }: Props) => { const didMountRef = useRef(false); const { form } = useForm({ schema: configurationFormSchema, serializer: formSerializer, deserializer: formDeserializer, - defaultValue, + defaultValue: value, }); const dispatch = useDispatch(); @@ -114,14 +115,14 @@ export const ConfigurationForm = React.memo(({ defaultValue }: Props) => { useEffect(() => { if (didMountRef.current) { - // If the defaultValue has changed (it probably means that we have loaded a new JSON) + // If the value has changed (it probably means that we have loaded a new JSON) // we need to reset the form to update the fields values. form.reset({ resetValues: true }); } else { // Avoid reseting the form on component mount. didMountRef.current = true; } - }, [defaultValue]); // eslint-disable-line react-hooks/exhaustive-deps + }, [value]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { return () => { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/dynamic_mapping_section/dynamic_mapping_section.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/dynamic_mapping_section/dynamic_mapping_section.tsx index cb9b464d270ce3..c1a2b195a3f576 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/dynamic_mapping_section/dynamic_mapping_section.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/dynamic_mapping_section/dynamic_mapping_section.tsx @@ -67,6 +67,7 @@ export const DynamicMappingSection = () => ( return ( <> @@ -87,6 +88,7 @@ export const DynamicMappingSection = () => ( } else { return ( diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/meta_field_section/meta_field_section.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/meta_field_section/meta_field_section.tsx index 68b76a1203ad52..7185016029e00e 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/meta_field_section/meta_field_section.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/meta_field_section/meta_field_section.tsx @@ -46,6 +46,7 @@ export const MetaFieldSection = () => ( 'aria-label': i18n.translate('xpack.idxMgmt.mappingsEditor.metaFieldEditorAriaLabel', { defaultMessage: '_meta field data editor', }), + 'data-test-subj': 'metaField', }, }} /> diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/routing_section.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/routing_section.tsx index 7f434d6f834b2b..f06b292bc33c8e 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/routing_section.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/routing_section.tsx @@ -35,7 +35,11 @@ export const RoutingSection = () => { /> } > - + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/source_field_section.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/source_field_section.tsx index f79741d9a1a9f1..4278598dfc7c16 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/source_field_section.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/source_field_section.tsx @@ -65,7 +65,7 @@ export const SourceFieldSection = () => { ); const renderFormFields = () => ( - <> +
{({ label, helpText, value, setValue }) => ( @@ -89,6 +89,7 @@ export const SourceFieldSection = () => { setValue([...(value as ComboBoxOption[]), newOption]); }} fullWidth + data-test-subj="includesField" /> )} @@ -119,11 +120,12 @@ export const SourceFieldSection = () => { setValue([...(value as ComboBoxOption[]), newOption]); }} fullWidth + data-test-subj="excludesField" /> )} - +
); return ( diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter.tsx index a97e3b227311c7..569af5d21cdb09 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter.tsx @@ -25,6 +25,7 @@ interface Props { label?: string; config?: FieldConfig; allowsIndexDefaultOption?: boolean; + 'data-test-subj'?: string; } const ANALYZER_OPTIONS = PARAMETERS_OPTIONS.analyzer!; @@ -68,6 +69,7 @@ export const AnalyzerParameter = ({ label, config, allowsIndexDefaultOption = true, + 'data-test-subj': dataTestSubj, }: Props) => { const indexSettings = useIndexSettings(); const customAnalyzers = getCustomAnalyzers(indexSettings); @@ -131,6 +133,11 @@ export const AnalyzerParameter = ({ !isDefaultValueInOptions && !isDefaultValueInSubOptions ); + const [selectsDefaultValue, setSelectsDefaultValue] = useState({ + main: mainValue, + sub: subValue, + }); + const fieldConfig = config ? config : getFieldConfig('analyzer'); const fieldConfigWithLabel = label !== undefined ? { ...fieldConfig, label } : fieldConfig; @@ -142,6 +149,7 @@ export const AnalyzerParameter = ({ } field.reset({ resetValue: false }); + setSelectsDefaultValue({ main: undefined, sub: undefined }); setIsCustom(!isCustom); }; @@ -154,6 +162,7 @@ export const AnalyzerParameter = ({ size="xs" onClick={toggleCustom(field)} className="mappingsEditor__selectWithCustom__button" + data-test-subj={`${dataTestSubj}-toggleCustomButton`} > {isCustom ? i18n.translate('xpack.idxMgmt.mappingsEditor.predefinedButtonLabel', { @@ -169,17 +178,18 @@ export const AnalyzerParameter = ({ // around the field. - + ) : ( )} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter_selects.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter_selects.tsx index a91231352c1684..a44fd2257f52b2 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter_selects.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter_selects.tsx @@ -36,6 +36,7 @@ interface Props { config: FieldConfig; options: Options; mapOptionsToSubOptions: MapOptionsToSubOptions; + 'data-test-subj'?: string; } export const AnalyzerParameterSelects = ({ @@ -45,6 +46,7 @@ export const AnalyzerParameterSelects = ({ config, options, mapOptionsToSubOptions, + 'data-test-subj': dataTestSubj, }: Props) => { const { form } = useForm({ defaultValue: { main: mainDefaultValue, sub: subDefaultValue } }); @@ -76,11 +78,16 @@ export const AnalyzerParameterSelects = ({ const isSuperSelect = areOptionsSuperSelect(opts); return isSuperSelect ? ( - + ) : ( ); }; @@ -102,9 +109,9 @@ export const AnalyzerParameterSelects = ({ diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzers_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzers_parameter.tsx index 0cf22946bf60a6..f99aa4d1eca9a5 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzers_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzers_parameter.tsx @@ -34,6 +34,7 @@ export const AnalyzersParameter = ({ field, withSearchQuoteAnalyzer = false }: P href: documentationService.getAnalyzerLink(), }} withToggle={false} + data-test-subj="analyzerParameters" > {({ useSameAnalyzerForSearch }) => { @@ -50,6 +51,7 @@ export const AnalyzersParameter = ({ field, withSearchQuoteAnalyzer = false }: P path="analyzer" label={label} defaultValue={field.source.analyzer as string} + data-test-subj="indexAnalyzer" /> ); }} @@ -60,6 +62,9 @@ export const AnalyzersParameter = ({ field, withSearchQuoteAnalyzer = false }: P @@ -94,6 +100,7 @@ export const AnalyzersParameter = ({ field, withSearchQuoteAnalyzer = false }: P path="search_quote_analyzer" defaultValue={field.source.search_quote_analyzer as string} config={getFieldConfig('search_quote_analyzer')} + data-test-subj="searchQuoteAnalyzer" /> )} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index_parameter.tsx index fec8e49a1991ca..3e91e97eef618a 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index_parameter.tsx @@ -39,6 +39,7 @@ export const IndexParameter = ({ href: documentationService.getIndexLink(), }} formFieldPath="index" + data-test-subj="indexParameter" > {/* index_options */} {hasIndexOptions ? ( diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/advanced_parameters_section.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/advanced_parameters_section.tsx index 03c774227924ea..2046675881c29f 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/advanced_parameters_section.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/advanced_parameters_section.tsx @@ -23,7 +23,7 @@ export const AdvancedParametersSection = ({ children }: Props) => {
- + {isVisible ? i18n.translate('xpack.idxMgmt.mappingsEditor.advancedSettings.hideButtonLabel', { defaultMessage: 'Hide advanced settings', @@ -33,7 +33,7 @@ export const AdvancedParametersSection = ({ children }: Props) => { })} -
+
{/* We ned to wrap the children inside a "div" to have our css :first-child rule */}
{children}
diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx index 489424a07e04d8..854270f313e59c 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx @@ -96,7 +96,7 @@ export const EditField = React.memo(({ form, field, allFields, exitEdit }: Props
{/* Title */} -

+

{isMultiField ? i18n.translate( 'xpack.idxMgmt.mappingsEditor.editMultiFieldTitle', @@ -127,6 +127,7 @@ export const EditField = React.memo(({ form, field, allFields, exitEdit }: Props href={linkDocumentation} target="_blank" iconType="help" + data-test-subj="documentationLink" > {i18n.translate( 'xpack.idxMgmt.mappingsEditor.editField.typeDocumentation', @@ -146,7 +147,7 @@ export const EditField = React.memo(({ form, field, allFields, exitEdit }: Props {/* Field path */} - + {field.path.join(' > ')} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx index 97a7d205c13553..1c079c8d5cf879 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx @@ -42,6 +42,7 @@ interface Props { children?: React.ReactNode | ChildrenFunc; withToggle?: boolean; configPath?: ParameterName; + 'data-test-subj'?: string; } export const EditFieldFormRow = React.memo( @@ -54,6 +55,7 @@ export const EditFieldFormRow = React.memo( children, withToggle = true, configPath, + 'data-test-subj': dataTestSubj, }: Props) => { const form = useFormContext(); @@ -87,7 +89,7 @@ export const EditFieldFormRow = React.memo( label={title} checked={isContentVisible} onChange={onToggle} - data-test-subj="input" + data-test-subj="formRowToggle" showLabel={false} /> ) : ( @@ -99,7 +101,17 @@ export const EditFieldFormRow = React.memo( }} > {field => { - return ; + return ( + + ); }} ); @@ -165,7 +177,7 @@ export const EditFieldFormRow = React.memo( ); return ( - + {toggle} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list.tsx index 6df86d561a532d..c0d922e0d1d373 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list.tsx @@ -18,7 +18,7 @@ export const FieldsList = React.memo(function FieldsListComponent({ fields, tree return null; } return ( -
    +
      {fields.map((field, index) => (
      {source.name} - + {isMultiField ? i18n.translate('xpack.idxMgmt.mappingsEditor.multiFieldBadgeLabel', { defaultMessage: '{dataType} multi-field', values: { - dataType: TYPE_DEFINITION[source.type].label, + dataType: getTypeLabelFromType(source.type), }, }) : getTypeLabelFromType(source.type)} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx index 3c4d6b08ebe449..f4aa17bf6fed92 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx @@ -16,7 +16,7 @@ import { documentationService } from '../../../../services/documentation'; type MappingsTemplates = Types['MappingsTemplates']; interface Props { - defaultValue?: MappingsTemplates; + value?: MappingsTemplates; } const stringifyJson = (json: { [key: string]: any }) => @@ -50,14 +50,14 @@ const formDeserializer = (formData: { [key: string]: any }) => { }; }; -export const TemplatesForm = React.memo(({ defaultValue }: Props) => { +export const TemplatesForm = React.memo(({ value }: Props) => { const didMountRef = useRef(false); const { form } = useForm({ schema: templatesFormSchema, serializer: formSerializer, deserializer: formDeserializer, - defaultValue, + defaultValue: value, }); const dispatch = useDispatch(); @@ -73,14 +73,14 @@ export const TemplatesForm = React.memo(({ defaultValue }: Props) => { useEffect(() => { if (didMountRef.current) { - // If the defaultValue has changed (it probably means that we have loaded a new JSON) + // If the value has changed (it probably means that we have loaded a new JSON) // we need to reset the form to update the fields values. form.reset({ resetValues: true }); } else { // Avoid reseting the form on component mount. didMountRef.current = true; } - }, [defaultValue]); // eslint-disable-line react-hooks/exhaustive-deps + }, [value]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { return () => { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts index 0431ea472643b7..4b610ff0b401df 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts @@ -6,7 +6,7 @@ jest.mock('../constants', () => ({ MAIN_DATA_TYPE_DEFINITION: {} })); -import { isStateValid } from './utils'; +import { isStateValid, stripUndefinedValues } from './utils'; describe('utils', () => { describe('isStateValid()', () => { @@ -62,4 +62,49 @@ describe('utils', () => { expect(isStateValid(components)).toBe(false); }); }); + + describe('stripUndefinedValues()', () => { + test('should remove all undefined value recursively', () => { + const myDate = new Date(); + + const dataIN = { + someString: 'world', + someNumber: 123, + someBoolean: true, + someArray: [1, 2, 3], + someEmptyObject: {}, + someDate: myDate, + falsey1: 0, + falsey2: '', + stripThis: undefined, + nested: { + value: 'bar', + stripThis: undefined, + deepNested: { + value: 'baz', + stripThis: undefined, + }, + }, + }; + + const dataOUT = { + someString: 'world', + someNumber: 123, + someBoolean: true, + someArray: [1, 2, 3], + someEmptyObject: {}, + someDate: myDate, + falsey1: 0, + falsey2: '', + nested: { + value: 'bar', + deepNested: { + value: 'baz', + }, + }, + }; + + expect(stripUndefinedValues(dataIN)).toEqual(dataOUT); + }); + }); }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts index cece26618ced87..306e0448df379b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts @@ -17,6 +17,7 @@ import { ChildFieldName, ParameterName, ComboBoxOption, + GenericObject, } from '../types'; import { @@ -32,11 +33,9 @@ import { State } from '../reducer'; import { FieldConfig } from '../shared_imports'; import { TreeItem } from '../components/tree'; -export const getUniqueId = () => { - return uuid.v4(); -}; +export const getUniqueId = () => uuid.v4(); -const getChildFieldsName = (dataType: DataType): ChildFieldName | undefined => { +export const getChildFieldsName = (dataType: DataType): ChildFieldName | undefined => { if (dataType === 'text' || dataType === 'keyword') { return 'fields'; } else if (dataType === 'object' || dataType === 'nested') { @@ -508,3 +507,39 @@ export const isStateValid = (state: State): boolean | undefined => return isValid && value.isValid; }, true as undefined | boolean); + +/** + * This helper removes all the keys on an object with an "undefined" value. + * To avoid sending updates from the mappings editor with this type of object: + * + *``` + * { + * "dyamic": undefined, + * "date_detection": undefined, + * "dynamic": undefined, + * "dynamic_date_formats": undefined, + * "dynamic_templates": undefined, + * "numeric_detection": undefined, + * "properties": { + * "title": { "type": "text" } + * } + * } + *``` + * + * @param obj The object to retrieve the undefined values from + * @param recursive A flag to strip recursively into children objects + */ +export const stripUndefinedValues = (obj: GenericObject, recursive = true): T => + Object.entries(obj).reduce((acc, [key, value]) => { + if (value === undefined) { + return acc; + } + + if (Array.isArray(value) || value instanceof Date || value === null) { + return { ...acc, [key]: value }; + } + + return recursive && typeof value === 'object' + ? { ...acc, [key]: stripUndefinedValues(value, recursive) } + : { ...acc, [key]: value }; + }, {} as T); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx index 316fee55526a3b..46dc1176f62b4b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx @@ -21,18 +21,18 @@ import { MappingsState, Props as MappingsStateProps, Types } from './mappings_st import { IndexSettingsProvider } from './index_settings_context'; interface Props { - onUpdate: MappingsStateProps['onUpdate']; - defaultValue?: { [key: string]: any }; + onChange: MappingsStateProps['onChange']; + value?: { [key: string]: any }; indexSettings?: IndexSettings; } type TabName = 'fields' | 'advanced' | 'templates'; -export const MappingsEditor = React.memo(({ onUpdate, defaultValue, indexSettings }: Props) => { +export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Props) => { const [selectedTab, selectTab] = useState('fields'); const { parsedDefaultValue, multipleMappingsDeclared } = useMemo(() => { - const mappingsDefinition = extractMappingsDefinition(defaultValue); + const mappingsDefinition = extractMappingsDefinition(value); if (mappingsDefinition === null) { return { multipleMappingsDeclared: true }; @@ -67,18 +67,18 @@ export const MappingsEditor = React.memo(({ onUpdate, defaultValue, indexSetting }; return { parsedDefaultValue: parsed, multipleMappingsDeclared: false }; - }, [defaultValue]); + }, [value]); useEffect(() => { if (multipleMappingsDeclared) { // We set the data getter here as the user won't be able to make any changes - onUpdate({ - getData: () => defaultValue! as Types['Mappings'], + onChange({ + getData: () => value! as Types['Mappings'], validate: () => Promise.resolve(true), isValid: true, }); } - }, [multipleMappingsDeclared, onUpdate, defaultValue]); + }, [multipleMappingsDeclared, onChange, value]); const changeTab = async (tab: TabName, state: State) => { if (selectedTab === 'advanced') { @@ -108,12 +108,12 @@ export const MappingsEditor = React.memo(({ onUpdate, defaultValue, indexSetting ) : ( - + {({ state }) => { const tabToContentMap = { fields: , - templates: , - advanced: , + templates: , + advanced: , }; return ( diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx index a9d26b953b96e0..280ea5c3dd28ce 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx @@ -16,7 +16,7 @@ import { Dispatch, } from './reducer'; import { Field } from './types'; -import { normalize, deNormalize } from './lib'; +import { normalize, deNormalize, stripUndefinedValues } from './lib'; type Mappings = MappingsTemplates & MappingsConfiguration & { @@ -43,36 +43,34 @@ const DispatchContext = createContext(undefined); export interface Props { children: (params: { state: State }) => React.ReactNode; - defaultValue: { + value: { templates: MappingsTemplates; configuration: MappingsConfiguration; fields: { [key: string]: Field }; }; - onUpdate: OnUpdateHandler; + onChange: OnUpdateHandler; } -export const MappingsState = React.memo(({ children, onUpdate, defaultValue }: Props) => { +export const MappingsState = React.memo(({ children, onChange, value }: Props) => { const didMountRef = useRef(false); - const parsedFieldsDefaultValue = useMemo(() => normalize(defaultValue.fields), [ - defaultValue.fields, - ]); + const parsedFieldsDefaultValue = useMemo(() => normalize(value.fields), [value.fields]); const initialState: State = { isValid: undefined, configuration: { - defaultValue: defaultValue.configuration, + defaultValue: value.configuration, data: { - raw: defaultValue.configuration, - format: () => defaultValue.configuration, + raw: value.configuration, + format: () => value.configuration, }, validate: () => Promise.resolve(true), }, templates: { - defaultValue: defaultValue.templates, + defaultValue: value.templates, data: { - raw: defaultValue.templates, - format: () => defaultValue.templates, + raw: value.templates, + format: () => value.templates, }, validate: () => Promise.resolve(true), }, @@ -105,7 +103,7 @@ export const MappingsState = React.memo(({ children, onUpdate, defaultValue }: P const bypassFieldFormValidation = state.documentFields.status === 'creatingField' && emptyNameValue; - onUpdate({ + onChange({ // Output a mappings object from the user's input. getData: (isValid: boolean) => { let nextState = state; @@ -135,8 +133,10 @@ export const MappingsState = React.memo(({ children, onUpdate, defaultValue }: P const templatesData = nextState.templates.data.format(); return { - ...configurationData, - ...templatesData, + ...stripUndefinedValues({ + ...configurationData, + ...templatesData, + }), properties: fields, }; }, @@ -169,26 +169,26 @@ export const MappingsState = React.memo(({ children, onUpdate, defaultValue }: P }, isValid: state.isValid, }); - }, [state, onUpdate]); + }, [state, onChange]); useEffect(() => { /** - * If the defaultValue has changed that probably means that we have loaded + * If the value has changed that probably means that we have loaded * new data from JSON. We need to update our state with the new mappings. */ if (didMountRef.current) { dispatch({ type: 'editor.replaceMappings', value: { - configuration: defaultValue.configuration, - templates: defaultValue.templates, + configuration: value.configuration, + templates: value.templates, fields: parsedFieldsDefaultValue, }, }); } else { didMountRef.current = true; } - }, [defaultValue, parsedFieldsDefaultValue]); + }, [value, parsedFieldsDefaultValue]); return ( diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx index cf9b57dcbcb14d..d74dd435ecdae0 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx @@ -101,8 +101,8 @@ export const StepMappings: React.FunctionComponent = ({ {/* Mappings code editor */} diff --git a/x-pack/plugins/infra/public/components/autocomplete_field/autocomplete_field.tsx b/x-pack/plugins/infra/public/components/autocomplete_field/autocomplete_field.tsx index 2abef7d71e65aa..6bbd67ce932c69 100644 --- a/x-pack/plugins/infra/public/components/autocomplete_field/autocomplete_field.tsx +++ b/x-pack/plugins/infra/public/components/autocomplete_field/autocomplete_field.tsx @@ -26,6 +26,7 @@ interface AutocompleteFieldProps { placeholder?: string; suggestions: QuerySuggestion[]; value: string; + disabled?: boolean; autoFocus?: boolean; 'aria-label'?: string; } @@ -55,6 +56,7 @@ export class AutocompleteField extends React.Component< isValid, placeholder, value, + disabled, 'aria-label': ariaLabel, } = this.props; const { areSuggestionsVisible, selectedIndex } = this.state; @@ -64,6 +66,7 @@ export class AutocompleteField extends React.Component< { return ( - - - - - + + + + + + + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page.tsx index 5ff5cd4db7168f..16751fabd6e964 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page.tsx @@ -4,18 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; - import { ColumnarPage } from '../../../components/page'; import { LogEntryRatePageContent } from './page_content'; import { LogEntryRatePageProviders } from './page_providers'; export const LogEntryRatePage = () => { return ( - - - - - + + + + + + + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/page.tsx b/x-pack/plugins/infra/public/pages/logs/page.tsx index 08049183d0a186..018f89fbb23c43 100644 --- a/x-pack/plugins/infra/public/pages/logs/page.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page.tsx @@ -4,16 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; - import { LogsPageContent } from './page_content'; import { LogsPageProviders } from './page_providers'; -export const LogsPage: React.FunctionComponent = ({ match }) => { +export const LogsPage: React.FunctionComponent = () => { return ( - - - + + + + + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx b/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx index 88b1441f0ba7c1..363b1b76271041 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx @@ -7,6 +7,7 @@ import { EuiButton, EuiCallOut, + EuiErrorBoundary, EuiFlexGroup, EuiFlexItem, EuiPanel, @@ -74,7 +75,7 @@ export const LogsSettingsPage = () => { } return ( - <> + { - + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page.tsx index 712d625052140a..bc25d7c49b1297 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { ColumnarPage } from '../../../components/page'; @@ -15,11 +16,13 @@ export const StreamPage = () => { useTrackPageview({ app: 'infra_logs', path: 'stream' }); useTrackPageview({ app: 'infra_logs', path: 'stream', delay: 15000 }); return ( - - - - - - + + + + + + + + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx index 9667272eb24179..88e6ea8be4325e 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx @@ -64,6 +64,7 @@ export const LogsToolbar = () => { isLoadingSuggestions={isLoadingSuggestions} isValid={isFilterQueryDraftValid} loadSuggestions={loadSuggestions} + disabled={isStreaming} onChange={(expression: string) => { setSurroundingLogsId(null); setLogFilterQueryDraft(expression); diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index dbf71665ea869a..91362d9098e344 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { Route, RouteComponentProps, Switch } from 'react-router-dom'; -import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { EuiErrorBoundary, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { DocumentTitle } from '../../components/document_title'; import { HelpCenterContent } from '../../components/help_center_content'; import { RoutedTabs } from '../../components/navigation/routed_tabs'; @@ -36,103 +36,105 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { const uiCapabilities = useKibana().services.application?.capabilities; return ( - - - - - - - - + + + + + + + -
      - - - - - - - - - - - + - - - ( - - {({ configuration, createDerivedIndexPattern }) => ( - - - {configuration ? ( - - ) : ( - - )} - - )} - - )} +
      - - - - - - - + + + + + + + + + + + + + + + ( + + {({ configuration, createDerivedIndexPattern }) => ( + + + {configuration ? ( + + ) : ( + + )} + + )} + + )} + /> + + + + + + + + ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx index 3a2c33d1c824c5..ebb8243369b3c4 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiButton, EuiErrorBoundary, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useContext } from 'react'; @@ -41,65 +41,70 @@ export const SnapshotPage = () => { }); return ( - - - i18n.translate('xpack.infra.infrastructureSnapshotPage.documentTitle', { - defaultMessage: '{previousTitle} | Inventory', - values: { - previousTitle, - }, - }) - } - /> - {isLoading ? ( - - ) : metricIndicesExist ? ( - <> - - - - ) : hasFailedLoadingSource ? ( - - ) : ( - - - - {i18n.translate('xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel', { - defaultMessage: 'View setup instructions', - })} - - - {uiCapabilities?.infrastructure?.configureSource ? ( + + + + i18n.translate('xpack.infra.infrastructureSnapshotPage.documentTitle', { + defaultMessage: '{previousTitle} | Inventory', + values: { + previousTitle, + }, + }) + } + /> + {isLoading ? ( + + ) : metricIndicesExist ? ( + <> + + + + ) : hasFailedLoadingSource ? ( + + ) : ( + - - {i18n.translate('xpack.infra.configureSourceActionLabel', { - defaultMessage: 'Change source configuration', - })} - + {i18n.translate( + 'xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel', + { + defaultMessage: 'View setup instructions', + } + )} + - ) : null} - - } - data-test-subj="noMetricsIndicesPrompt" - /> - )} - + {uiCapabilities?.infrastructure?.configureSource ? ( + + + {i18n.translate('xpack.infra.configureSourceActionLabel', { + defaultMessage: 'Change source configuration', + })} + + + ) : null} + + } + data-test-subj="noMetricsIndicesPrompt" + /> + )} + + ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/page_providers.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/page_providers.tsx index 597977d9d2735d..dcd1c1d949971d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/page_providers.tsx @@ -4,17 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; - import { Source } from '../../../containers/source'; import { MetricsTimeProvider } from './hooks/use_metrics_time'; export const withMetricPageProviders = (Component: React.ComponentType) => ( props: T ) => ( - - - - - + + + + + + + ); diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx index a213671e9436eb..8b703b1177c8c8 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/index.tsx @@ -4,17 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiErrorBoundary } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; - import React from 'react'; import { IIndexPattern } from 'src/plugins/data/public'; +import { useTrackPageview } from '../../../../../observability/public'; +import { SourceQuery } from '../../../../common/graphql/types'; import { DocumentTitle } from '../../../components/document_title'; +import { NoData } from '../../../components/empty_states'; import { MetricsExplorerCharts } from './components/charts'; import { MetricsExplorerToolbar } from './components/toolbar'; -import { SourceQuery } from '../../../../common/graphql/types'; -import { NoData } from '../../../components/empty_states'; import { useMetricsExplorerState } from './hooks/use_metric_explorer_state'; -import { useTrackPageview } from '../../../../../observability/public'; interface MetricsExplorerPageProps { source: SourceQuery.Query['source']['configuration']; @@ -45,7 +45,7 @@ export const MetricsExplorerPage = ({ source, derivedIndexPattern }: MetricsExpl useTrackPageview({ app: 'infra_metrics', path: 'metrics_explorer', delay: 15000 }); return ( - + i18n.translate('xpack.infra.infrastructureMetricsExplorerPage.documentTitle', { @@ -95,6 +95,6 @@ export const MetricsExplorerPage = ({ source, derivedIndexPattern }: MetricsExpl onTimeChange={handleTimeChange} /> )} - + ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/settings.tsx b/x-pack/plugins/infra/public/pages/metrics/settings.tsx index 9414eb7d3e5640..7d4f35b19da7de 100644 --- a/x-pack/plugins/infra/public/pages/metrics/settings.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/settings.tsx @@ -4,16 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; -import { SourceConfigurationSettings } from '../../components/source_configuration/source_configuration_settings'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { SourceConfigurationSettings } from '../../components/source_configuration/source_configuration_settings'; export const MetricsSettingsPage = () => { const uiCapabilities = useKibana().services.application?.capabilities; return ( - + + + ); }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index f7be82dd34ba39..81476e8fa37089 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -43,6 +43,12 @@ export function LayerPanel( } ) { const dragDropContext = useContext(DragContext); + const [popoverState, setPopoverState] = useState({ + isOpen: false, + openId: null, + addingToGroupId: null, + }); + const { framePublicAPI, layerId, activeVisualization, isOnlyLayer, onRemoveLayer } = props; const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId]; if (!datasourcePublicAPI) { @@ -74,12 +80,6 @@ export function LayerPanel( dateRange: props.framePublicAPI.dateRange, }; - const [popoverState, setPopoverState] = useState({ - isOpen: false, - openId: null, - addingToGroupId: null, - }); - const { groups } = activeVisualization.getConfiguration(layerVisualizationConfigProps); const isEmptyLayer = !groups.some(d => d.accessors.length > 0); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx index 1f741ca37934fc..e246d8e27a7089 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx @@ -122,6 +122,16 @@ export function InnerWorkspacePanel({ framePublicAPI.filters, ]); + useEffect(() => { + // reset expression error if component attempts to run it again + if (expression && localState.expressionBuildError) { + setLocalState(s => ({ + ...s, + expressionBuildError: undefined, + })); + } + }, [expression]); + function onDrop() { if (suggestionForDraggedField) { trackUiEvent('drop_onto_workspace'); @@ -174,16 +184,6 @@ export function InnerWorkspacePanel({ } function renderVisualization() { - useEffect(() => { - // reset expression error if component attempts to run it again - if (expression && localState.expressionBuildError) { - setLocalState(s => ({ - ...s, - expressionBuildError: undefined, - })); - } - }, [expression]); - if (expression === null) { return renderEmptyWorkspace(); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index c396f0efee42ed..5e3b32f6961e68 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -258,7 +258,17 @@ describe('IndexPattern Data Panel', () => { it('should render a warning if there are no index patterns', () => { const wrapper = shallowWithIntl( - + {} }} + changeIndexPattern={jest.fn()} + /> ); expect(wrapper.find('[data-test-subj="indexPattern-no-indexpatterns"]')).toHaveLength(1); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 79dcdafd916b4c..b013f2b9d22a67 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -144,21 +144,49 @@ export function IndexPatternDataPanel({ indexPatternList.map(x => `${x.title}:${x.timeFieldName}`).join(','), ]} /> - + + {Object.keys(indexPatterns).length === 0 ? ( + + + +

      + +

      +
      +
      +
      + ) : ( + + )} ); } @@ -194,35 +222,6 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ onChangeIndexPattern: (newId: string) => void; existingFields: IndexPatternPrivateState['existingFields']; }) { - if (Object.keys(indexPatterns).length === 0) { - return ( - - - -

      - -

      -
      -
      -
      - ); - } - const [localState, setLocalState] = useState({ nameFilter: '', typeFilter: [], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx index 04e13fead6fca0..7e2af6a19b0413 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx @@ -127,7 +127,7 @@ export function BucketNestingEditor({ defaultMessage: 'Entire data set', }), }, - ...aggColumns, + ...aggColumns.map(({ value, text }) => ({ value, text })), ]} value={prevColumn} onChange={e => setColumns(nestColumn(layer.columnOrder, e.target.value, columnId))} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx index c4d2a6f8780c6a..5f0fa95ad0022a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx @@ -251,22 +251,6 @@ function FieldItemPopoverContents(props: State & FieldItemProps) { const IS_DARK_THEME = core.uiSettings.get('theme:darkMode'); const chartTheme = IS_DARK_THEME ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme; - - if (props.isLoading) { - return ; - } else if ( - (!props.histogram || props.histogram.buckets.length === 0) && - (!props.topValues || props.topValues.buckets.length === 0) - ) { - return ( - - {i18n.translate('xpack.lens.indexPattern.fieldStatsNoData', { - defaultMessage: 'No data to display.', - })} - - ); - } - let histogramDefault = !!props.histogram; const totalValuesCount = @@ -309,6 +293,21 @@ function FieldItemPopoverContents(props: State & FieldItemProps) { let title = <>; + if (props.isLoading) { + return ; + } else if ( + (!props.histogram || props.histogram.buckets.length === 0) && + (!props.topValues || props.topValues.buckets.length === 0) + ) { + return ( + + {i18n.translate('xpack.lens.indexPattern.fieldStatsNoData', { + defaultMessage: 'No data to display.', + })} + + ); + } + if (histogram && histogram.buckets.length && topValues && topValues.buckets.length) { title = ( 1 && !ALLOW_CELL_RANGE_SELECTION) { @@ -169,12 +170,7 @@ export class Explorer extends React.Component { }; componentDidMount() { - limit$ - .pipe( - takeUntil(this._unsubscribeAll), - map(d => d.val) - ) - .subscribe(explorerService.setSwimlaneLimit); + limit$.pipe(takeUntil(this._unsubscribeAll)).subscribe(explorerService.setSwimlaneLimit); // Required to redraw the time series chart when the container is resized. this.resizeChecker = new ResizeChecker(this.resizeRef.current); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js index 03426869b0ccfc..2b577c978eb139 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js @@ -17,8 +17,6 @@ import d3 from 'd3'; import $ from 'jquery'; import moment from 'moment'; -// don't use something like plugins/ml/../common -// because it won't work with the jest tests import { formatHumanReadableDateTime } from '../../util/date_utils'; import { formatValue } from '../../formatters/format_value'; import { getSeverityColor, getSeverityWithLow } from '../../../../common/util/anomaly_utils'; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js index 82041af39ca15e..531a24493c9610 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js @@ -17,8 +17,6 @@ import d3 from 'd3'; import $ from 'jquery'; import moment from 'moment'; -// don't use something like plugins/ml/../common -// because it won't work with the jest tests import { formatHumanReadableDateTime } from '../../util/date_utils'; import { formatValue } from '../../formatters/format_value'; import { diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx index bf1a3b424edb91..8a8a826e1831f3 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx @@ -14,8 +14,6 @@ import _ from 'lodash'; import d3 from 'd3'; import moment from 'moment'; -// don't use something like plugins/ml/../common -// because it won't work with the jest tests import { i18n } from '@kbn/i18n'; import { Subscription } from 'rxjs'; import { TooltipValue } from '@elastic/charts'; diff --git a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx b/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx index 657f1c6c7af2ed..cf65419e4bd801 100644 --- a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx +++ b/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx @@ -9,8 +9,6 @@ import { act } from 'react-dom/test-utils'; import { shallow } from 'enzyme'; import { SelectLimit } from './select_limit'; -jest.useFakeTimers(); - describe('SelectLimit', () => { test('creates correct initial selected value', () => { const wrapper = shallow(); diff --git a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx b/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx index 383d07eb7a9f60..03e3273b808327 100644 --- a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx +++ b/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx @@ -9,7 +9,7 @@ */ import React from 'react'; import useObservable from 'react-use/lib/useObservable'; -import { Subject } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { EuiSelect } from '@elastic/eui'; @@ -20,13 +20,13 @@ const euiOptions = limitOptions.map(limit => ({ text: `${limit}`, })); -export const limit$ = new Subject(); export const defaultLimit = limitOptions[1]; +export const limit$ = new BehaviorSubject(defaultLimit); export const useSwimlaneLimit = (): [number, (newLimit: number) => void] => { const limit = useObservable(limit$, defaultLimit); - return [limit, (newLimit: number) => limit$.next(newLimit)]; + return [limit!, (newLimit: number) => limit$.next(newLimit)]; }; export const SelectLimit = () => { diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js index 64f20667931184..eded8460d2205f 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js @@ -15,8 +15,6 @@ import React, { Component } from 'react'; import { EuiButton, EuiToolTip } from '@elastic/eui'; -// don't use something like plugins/ml/../common -// because it won't work with the jest tests import { FORECAST_REQUEST_STATE, JOB_STATE } from '../../../../../common/constants/states'; import { MESSAGE_LEVEL } from '../../../../../common/constants/message_levels'; import { isJobVersionGte } from '../../../../../common/util/job_utils'; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/run_controls.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/run_controls.js index 7dd06268f7f8dd..3208697073b8e4 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/run_controls.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/run_controls.js @@ -23,8 +23,6 @@ import { EuiToolTip, } from '@elastic/eui'; -// don't use something like plugins/ml/../common -// because it won't work with the jest tests import { JOB_STATE } from '../../../../../common/constants/states'; import { FORECAST_DURATION_MAX_DAYS } from './forecasting_modal'; import { ForecastProgress } from './forecast_progress'; diff --git a/x-pack/plugins/ml/public/index.ts b/x-pack/plugins/ml/public/index.ts index c23d042822816d..a9ffb1a5bf5792 100755 --- a/x-pack/plugins/ml/public/index.ts +++ b/x-pack/plugins/ml/public/index.ts @@ -13,7 +13,6 @@ import { MlSetupDependencies, MlStartDependencies, } from './plugin'; -import { getMetricChangeDescription } from './application/formatters/metric_change_description'; export const plugin: PluginInitializer< MlPluginSetup, @@ -22,4 +21,5 @@ export const plugin: PluginInitializer< MlStartDependencies > = () => new MlPlugin(); -export { MlPluginSetup, MlPluginStart, getMetricChangeDescription }; +export { MlPluginSetup, MlPluginStart }; +export * from './shared'; diff --git a/x-pack/plugins/ml/public/shared.ts b/x-pack/plugins/ml/public/shared.ts new file mode 100644 index 00000000000000..6821cb7ef0f945 --- /dev/null +++ b/x-pack/plugins/ml/public/shared.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. + */ + +export * from '../common/constants/anomalies'; + +export * from '../common/types/data_recognizer'; +export * from '../common/types/capabilities'; +export * from '../common/types/anomalies'; +export * from '../common/types/modules'; +export * from '../common/types/audit_message'; + +export * from '../common/util/anomaly_utils'; +export * from '../common/util/errors'; +export * from '../common/util/validators'; + +export * from './application/formatters/metric_change_description'; + +export * from './application/components/data_grid'; +export * from './application/data_frame_analytics/common'; diff --git a/x-pack/plugins/ml/server/index.ts b/x-pack/plugins/ml/server/index.ts index 175c20bf49c947..4c27854ec719bd 100644 --- a/x-pack/plugins/ml/server/index.ts +++ b/x-pack/plugins/ml/server/index.ts @@ -7,5 +7,6 @@ import { PluginInitializerContext } from 'kibana/server'; import { MlServerPlugin } from './plugin'; export { MlPluginSetup, MlPluginStart } from './plugin'; +export * from './shared'; export const plugin = (ctx: PluginInitializerContext) => new MlServerPlugin(ctx); diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/__tests__/bucket_span_estimator.js b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts similarity index 56% rename from x-pack/plugins/ml/server/models/bucket_span_estimator/__tests__/bucket_span_estimator.js rename to x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts index a0dacc38e58352..f5daadfe86be0c 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/__tests__/bucket_span_estimator.js +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { estimateBucketSpanFactory } from '../bucket_span_estimator'; +import { APICaller } from 'kibana/server'; + +import { ES_AGGREGATION } from '../../../common/constants/aggregation_types'; + +import { estimateBucketSpanFactory, BucketSpanEstimatorData } from './bucket_span_estimator'; // Mock callWithRequest with the ability to simulate returning different // permission settings. On each call using `ml.privilegeCheck` we retrieve @@ -14,7 +17,7 @@ import { estimateBucketSpanFactory } from '../bucket_span_estimator'; // sufficient permissions should be returned, the second time insufficient // permissions. const permissions = [false, true]; -const callWithRequest = method => { +const callWithRequest: APICaller = (method: string) => { return new Promise(resolve => { if (method === 'ml.privilegeCheck') { resolve({ @@ -28,34 +31,19 @@ const callWithRequest = method => { return; } resolve({}); - }); + }) as Promise; }; -const callWithInternalUser = () => { +const callWithInternalUser: APICaller = () => { return new Promise(resolve => { resolve({}); - }); + }) as Promise; }; -// mock xpack_main plugin -function mockXpackMainPluginFactory(isEnabled = false, licenseType = 'platinum') { - return { - info: { - isAvailable: () => true, - feature: () => ({ - isEnabled: () => isEnabled, - }), - license: { - getType: () => licenseType, - }, - }, - }; -} - // mock configuration to be passed to the estimator -const formConfig = { - aggTypes: ['count'], - duration: {}, +const formConfig: BucketSpanEstimatorData = { + aggTypes: [ES_AGGREGATION.COUNT], + duration: { start: 0, end: 1 }, fields: [null], index: '', query: { @@ -64,13 +52,15 @@ const formConfig = { must_not: [], }, }, + splitField: undefined, + timeField: undefined, }; describe('ML - BucketSpanEstimator', () => { it('call factory', () => { expect(function() { - estimateBucketSpanFactory(callWithRequest, callWithInternalUser); - }).to.not.throwError('Not initialized.'); + estimateBucketSpanFactory(callWithRequest, callWithInternalUser, false); + }).not.toThrow('Not initialized.'); }); it('call factory and estimator with security disabled', done => { @@ -78,44 +68,29 @@ describe('ML - BucketSpanEstimator', () => { const estimateBucketSpan = estimateBucketSpanFactory( callWithRequest, callWithInternalUser, - mockXpackMainPluginFactory() + true ); estimateBucketSpan(formConfig).catch(catchData => { - expect(catchData).to.be('Unable to retrieve cluster setting search.max_buckets'); + expect(catchData).toBe('Unable to retrieve cluster setting search.max_buckets'); done(); }); - }).to.not.throwError('Not initialized.'); + }).not.toThrow('Not initialized.'); }); - it('call factory and estimator with security enabled and sufficient permissions.', done => { + it('call factory and estimator with security enabled.', done => { expect(function() { const estimateBucketSpan = estimateBucketSpanFactory( callWithRequest, callWithInternalUser, - mockXpackMainPluginFactory(true) + false ); estimateBucketSpan(formConfig).catch(catchData => { - expect(catchData).to.be('Unable to retrieve cluster setting search.max_buckets'); + expect(catchData).toBe('Unable to retrieve cluster setting search.max_buckets'); done(); }); - }).to.not.throwError('Not initialized.'); - }); - - it('call factory and estimator with security enabled and insufficient permissions.', done => { - expect(function() { - const estimateBucketSpan = estimateBucketSpanFactory( - callWithRequest, - callWithInternalUser, - mockXpackMainPluginFactory(true) - ); - - estimateBucketSpan(formConfig).catch(catchData => { - expect(catchData).to.be('Insufficient permissions to call bucket span estimation.'); - done(); - }); - }).to.not.throwError('Not initialized.'); + }).not.toThrow('Not initialized.'); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_farequote_cardinality.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_farequote_cardinality.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_farequote_cardinality.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_farequote_cardinality.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_farequote_search_response.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_farequote_search_response.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_farequote_search_response.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_farequote_search_response.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_field_caps.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_field_caps.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_field_caps.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_field_caps.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_it_search_response.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_it_search_response.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_it_search_response.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_it_search_response.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_field.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_field.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_field.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_field.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_field_nested.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_field_nested.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_field_nested.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_field_nested.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_range.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_range.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_range.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_range.json diff --git a/x-pack/plugins/ml/server/models/job_validation/job_validation.d.ts b/x-pack/plugins/ml/server/models/job_validation/job_validation.d.ts index 33f5d5ec95fad5..6a9a7a0c13395c 100644 --- a/x-pack/plugins/ml/server/models/job_validation/job_validation.d.ts +++ b/x-pack/plugins/ml/server/models/job_validation/job_validation.d.ts @@ -6,14 +6,19 @@ import { APICaller } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; + +import { DeepPartial } from '../../../common/types/common'; + import { validateJobSchema } from '../../routes/schemas/job_validation_schema'; -type ValidateJobPayload = TypeOf; +import { ValidationMessage } from './messages'; + +export type ValidateJobPayload = TypeOf; export function validateJob( callAsCurrentUser: APICaller, - payload: ValidateJobPayload, - kbnVersion: string, - callAsInternalUser: APICaller, - isSecurityDisabled: boolean -): string[]; + payload?: DeepPartial, + kbnVersion?: string, + callAsInternalUser?: APICaller, + isSecurityDisabled?: boolean +): Promise; diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/job_validation.js b/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts similarity index 80% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/job_validation.js rename to x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts index 726a8e8d8db853..9851f80a42d5bb 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/job_validation.js +++ b/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts @@ -4,16 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { validateJob } from '../job_validation'; +import { APICaller } from 'kibana/server'; + +import { validateJob } from './job_validation'; // mock callWithRequest -const callWithRequest = () => { +const callWithRequest: APICaller = (method: string) => { return new Promise(resolve => { + if (method === 'fieldCaps') { + resolve({ fields: [] }); + return; + } resolve({}); - }); + }) as Promise; }; +// Note: The tests cast `payload` as any +// so we can simulate possible runtime payloads +// that don't satisfy the TypeScript specs. describe('ML - validateJob', () => { it('calling factory without payload throws an error', done => { validateJob(callWithRequest).then( @@ -61,7 +69,7 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_empty', 'detectors_empty', 'bucket_span_empty', @@ -70,10 +78,14 @@ describe('ML - validateJob', () => { }); }); - const jobIdTests = (testIds, messageId) => { + const jobIdTests = (testIds: string[], messageId: string) => { const promises = testIds.map(id => { - const payload = { job: { analysis_config: { detectors: [] } } }; - payload.job.job_id = id; + const payload = { + job: { + analysis_config: { detectors: [] }, + job_id: id, + }, + }; return validateJob(callWithRequest, payload).catch(() => { new Error('Promise should not fail for jobIdTests.'); }); @@ -81,19 +93,21 @@ describe('ML - validateJob', () => { return Promise.all(promises).then(testResults => { testResults.forEach(messages => { - const ids = messages.map(m => m.id); - expect(ids.includes(messageId)).to.equal(true); + expect(Array.isArray(messages)).toBe(true); + if (Array.isArray(messages)) { + const ids = messages.map(m => m.id); + expect(ids.includes(messageId)).toBe(true); + } }); }); }; - const jobGroupIdTest = (testIds, messageId) => { - const payload = { job: { analysis_config: { detectors: [] } } }; - payload.job.groups = testIds; + const jobGroupIdTest = (testIds: string[], messageId: string) => { + const payload = { job: { analysis_config: { detectors: [] }, groups: testIds } }; return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids.includes(messageId)).to.equal(true); + expect(ids.includes(messageId)).toBe(true); }); }; @@ -126,10 +140,9 @@ describe('ML - validateJob', () => { return jobGroupIdTest(validTestIds, 'job_group_id_valid'); }); - const bucketSpanFormatTests = (testFormats, messageId) => { + const bucketSpanFormatTests = (testFormats: string[], messageId: string) => { const promises = testFormats.map(format => { - const payload = { job: { analysis_config: { detectors: [] } } }; - payload.job.analysis_config.bucket_span = format; + const payload = { job: { analysis_config: { bucket_span: format, detectors: [] } } }; return validateJob(callWithRequest, payload).catch(() => { new Error('Promise should not fail for bucketSpanFormatTests.'); }); @@ -137,8 +150,11 @@ describe('ML - validateJob', () => { return Promise.all(promises).then(testResults => { testResults.forEach(messages => { - const ids = messages.map(m => m.id); - expect(ids.includes(messageId)).to.equal(true); + expect(Array.isArray(messages)).toBe(true); + if (Array.isArray(messages)) { + const ids = messages.map(m => m.id); + expect(ids.includes(messageId)).toBe(true); + } }); }); }; @@ -152,7 +168,7 @@ describe('ML - validateJob', () => { }); it('at least one detector function is empty', () => { - const payload = { job: { analysis_config: { detectors: [] } } }; + const payload = { job: { analysis_config: { detectors: [] as Array<{ function?: string }> } } }; payload.job.analysis_config.detectors.push({ function: 'count', }); @@ -165,19 +181,19 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids.includes('detectors_function_empty')).to.equal(true); + expect(ids.includes('detectors_function_empty')).toBe(true); }); }); it('detector function is not empty', () => { - const payload = { job: { analysis_config: { detectors: [] } } }; + const payload = { job: { analysis_config: { detectors: [] as Array<{ function?: string }> } } }; payload.job.analysis_config.detectors.push({ function: 'count', }); return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids.includes('detectors_function_not_empty')).to.equal(true); + expect(ids.includes('detectors_function_not_empty')).toBe(true); }); }); @@ -189,7 +205,7 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids.includes('index_fields_invalid')).to.equal(true); + expect(ids.includes('index_fields_invalid')).toBe(true); }); }); @@ -201,11 +217,11 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids.includes('index_fields_valid')).to.equal(true); + expect(ids.includes('index_fields_valid')).toBe(true); }); }); - const getBasicPayload = () => ({ + const getBasicPayload = (): any => ({ job: { job_id: 'test', analysis_config: { @@ -214,7 +230,7 @@ describe('ML - validateJob', () => { { function: 'count', }, - ], + ] as Array<{ function: string; by_field_name?: string; partition_field_name?: string }>, influencers: [], }, data_description: { time_field: '@timestamp' }, @@ -224,7 +240,7 @@ describe('ML - validateJob', () => { }); it('throws an error because job.analysis_config.influencers is not an Array', done => { - const payload = getBasicPayload(); + const payload = getBasicPayload() as any; delete payload.job.analysis_config.influencers; validateJob(callWithRequest, payload).then( @@ -237,11 +253,11 @@ describe('ML - validateJob', () => { }); it('detect duplicate detectors', () => { - const payload = getBasicPayload(); + const payload = getBasicPayload() as any; payload.job.analysis_config.detectors.push({ function: 'count' }); return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'detectors_duplicates', @@ -253,7 +269,7 @@ describe('ML - validateJob', () => { }); it('dedupe duplicate messages', () => { - const payload = getBasicPayload(); + const payload = getBasicPayload() as any; // in this test setup, the following configuration passes // the duplicate detectors check, but would return the same // 'field_not_aggregatable' message for both detectors. @@ -264,7 +280,7 @@ describe('ML - validateJob', () => { ]; return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'index_fields_valid', @@ -278,7 +294,7 @@ describe('ML - validateJob', () => { const payload = getBasicPayload(); return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'index_fields_valid', @@ -288,7 +304,7 @@ describe('ML - validateJob', () => { }); it('categorization job using mlcategory passes aggregatable field check', () => { - const payload = { + const payload: any = { job: { job_id: 'categorization_test', analysis_config: { @@ -310,7 +326,7 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'index_fields_valid', @@ -322,7 +338,7 @@ describe('ML - validateJob', () => { }); it('non-existent field reported as non aggregatable', () => { - const payload = { + const payload: any = { job: { job_id: 'categorization_test', analysis_config: { @@ -343,7 +359,7 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'index_fields_valid', @@ -354,7 +370,7 @@ describe('ML - validateJob', () => { }); it('script field not reported as non aggregatable', () => { - const payload = { + const payload: any = { job: { job_id: 'categorization_test', analysis_config: { @@ -385,7 +401,7 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'index_fields_valid', @@ -399,19 +415,19 @@ describe('ML - validateJob', () => { // the following two tests validate the correct template rendering of // urls in messages with {{version}} in them to be replaced with the // specified version. (defaulting to 'current') - const docsTestPayload = getBasicPayload(); + const docsTestPayload = getBasicPayload() as any; docsTestPayload.job.analysis_config.detectors = [{ function: 'count', by_field_name: 'airline' }]; it('creates a docs url pointing to the current docs version', () => { return validateJob(callWithRequest, docsTestPayload).then(messages => { const message = messages[messages.findIndex(m => m.id === 'field_not_aggregatable')]; - expect(message.url.search('/current/')).not.to.be(-1); + expect(message.url.search('/current/')).not.toBe(-1); }); }); it('creates a docs url pointing to the master docs version', () => { return validateJob(callWithRequest, docsTestPayload, 'master').then(messages => { const message = messages[messages.findIndex(m => m.id === 'field_not_aggregatable')]; - expect(message.url.search('/master/')).not.to.be(-1); + expect(message.url.search('/master/')).not.toBe(-1); }); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/messages.d.ts b/x-pack/plugins/ml/server/models/job_validation/messages.d.ts new file mode 100644 index 00000000000000..772d78b4187dd0 --- /dev/null +++ b/x-pack/plugins/ml/server/models/job_validation/messages.d.ts @@ -0,0 +1,10 @@ +/* + * 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 interface ValidationMessage { + id: string; + url: string; +} diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_bucket_span.js b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts similarity index 81% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/validate_bucket_span.js rename to x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts index 3dc2bee1e8705f..4001697d743200 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_bucket_span.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts @@ -4,22 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { validateBucketSpan } from '../validate_bucket_span'; -import { SKIP_BUCKET_SPAN_ESTIMATION } from '../../../../common/constants/validation'; +import { SKIP_BUCKET_SPAN_ESTIMATION } from '../../../common/constants/validation'; + +import { ValidationMessage } from './messages'; +// @ts-ignore +import { validateBucketSpan } from './validate_bucket_span'; // farequote2017 snapshot snapshot mock search response // it returns a mock for the response of PolledDataChecker's search request // to get an aggregation of non_empty_buckets with an interval of 1m. // this allows us to test bucket span estimation. -import mockFareQuoteSearchResponse from './mock_farequote_search_response'; +import mockFareQuoteSearchResponse from './__mocks__/mock_farequote_search_response.json'; // it_ops_app_logs 2017 snapshot mock search response // sparse data with a low number of buckets -import mockItSearchResponse from './mock_it_search_response'; +import mockItSearchResponse from './__mocks__/mock_it_search_response.json'; // mock callWithRequestFactory -const callWithRequestFactory = mockSearchResponse => { +const callWithRequestFactory = (mockSearchResponse: any) => { return () => { return new Promise(resolve => { resolve(mockSearchResponse); @@ -86,17 +88,17 @@ describe('ML - validateBucketSpan', () => { }; return validateBucketSpan(callWithRequestFactory(mockFareQuoteSearchResponse), job).then( - messages => { + (messages: ValidationMessage[]) => { const ids = messages.map(m => m.id); - expect(ids).to.eql([]); + expect(ids).toStrictEqual([]); } ); }); - const getJobConfig = bucketSpan => ({ + const getJobConfig = (bucketSpan: string) => ({ analysis_config: { bucket_span: bucketSpan, - detectors: [], + detectors: [] as Array<{ function?: string }>, influencers: [], }, data_description: { time_field: '@timestamp' }, @@ -111,9 +113,9 @@ describe('ML - validateBucketSpan', () => { callWithRequestFactory(mockFareQuoteSearchResponse), job, duration - ).then(messages => { + ).then((messages: ValidationMessage[]) => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_bucket_span']); + expect(ids).toStrictEqual(['success_bucket_span']); }); }); @@ -125,9 +127,9 @@ describe('ML - validateBucketSpan', () => { callWithRequestFactory(mockFareQuoteSearchResponse), job, duration - ).then(messages => { + ).then((messages: ValidationMessage[]) => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['bucket_span_high']); + expect(ids).toStrictEqual(['bucket_span_high']); }); }); @@ -135,14 +137,18 @@ describe('ML - validateBucketSpan', () => { return; } - const testBucketSpan = (bucketSpan, mockSearchResponse, test) => { + const testBucketSpan = ( + bucketSpan: string, + mockSearchResponse: any, + test: (ids: string[]) => void + ) => { const job = getJobConfig(bucketSpan); job.analysis_config.detectors.push({ function: 'count', }); return validateBucketSpan(callWithRequestFactory(mockSearchResponse), job, {}).then( - messages => { + (messages: ValidationMessage[]) => { const ids = messages.map(m => m.id); test(ids); } @@ -151,13 +157,13 @@ describe('ML - validateBucketSpan', () => { it('farequote count detector, bucket span estimation matches 15m', () => { return testBucketSpan('15m', mockFareQuoteSearchResponse, ids => { - expect(ids).to.eql(['success_bucket_span']); + expect(ids).toStrictEqual(['success_bucket_span']); }); }); it('farequote count detector, bucket span estimation does not match 1m', () => { return testBucketSpan('1m', mockFareQuoteSearchResponse, ids => { - expect(ids).to.eql(['bucket_span_estimation_mismatch']); + expect(ids).toStrictEqual(['bucket_span_estimation_mismatch']); }); }); @@ -167,7 +173,7 @@ describe('ML - validateBucketSpan', () => { // should result in a lower bucket span estimation. it('it_ops_app_logs count detector, bucket span estimation matches 6h', () => { return testBucketSpan('6h', mockItSearchResponse, ids => { - expect(ids).to.eql(['success_bucket_span']); + expect(ids).toStrictEqual(['success_bucket_span']); }); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.d.ts b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.d.ts index 22d2fec0beddc4..2fad1252e64465 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.d.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.d.ts @@ -7,4 +7,7 @@ import { APICaller } from 'kibana/server'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; -export function validateCardinality(callAsCurrentUser: APICaller, job: CombinedJob): any[]; +export function validateCardinality( + callAsCurrentUser: APICaller, + job?: CombinedJob +): Promise; diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_cardinality.js b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts similarity index 69% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/validate_cardinality.js rename to x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts index 9617982a66b0e5..e5111629f1182a 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_cardinality.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts @@ -5,11 +5,15 @@ */ import _ from 'lodash'; -import expect from '@kbn/expect'; -import { validateCardinality } from '../validate_cardinality'; -import mockFareQuoteCardinality from './mock_farequote_cardinality'; -import mockFieldCaps from './mock_field_caps'; +import { APICaller } from 'kibana/server'; + +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; + +import mockFareQuoteCardinality from './__mocks__/mock_farequote_cardinality.json'; +import mockFieldCaps from './__mocks__/mock_field_caps.json'; + +import { validateCardinality } from './validate_cardinality'; const mockResponses = { search: mockFareQuoteCardinality, @@ -17,8 +21,8 @@ const mockResponses = { }; // mock callWithRequestFactory -const callWithRequestFactory = (responses, fail = false) => { - return requestName => { +const callWithRequestFactory = (responses: Record, fail = false): APICaller => { + return (requestName: string) => { return new Promise((resolve, reject) => { const response = responses[requestName]; if (fail) { @@ -26,7 +30,7 @@ const callWithRequestFactory = (responses, fail = false) => { } else { resolve(response); } - }); + }) as Promise; }; }; @@ -39,21 +43,23 @@ describe('ML - validateCardinality', () => { }); it('called with non-valid job argument #1, missing analysis_config', done => { - validateCardinality(callWithRequestFactory(mockResponses), {}).then( + validateCardinality(callWithRequestFactory(mockResponses), {} as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); }); it('called with non-valid job argument #2, missing datafeed_config', done => { - validateCardinality(callWithRequestFactory(mockResponses), { analysis_config: {} }).then( + validateCardinality(callWithRequestFactory(mockResponses), { + analysis_config: {}, + } as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); }); it('called with non-valid job argument #3, missing datafeed_config.indices', done => { - const job = { analysis_config: {}, datafeed_config: {} }; + const job = { analysis_config: {}, datafeed_config: {} } as CombinedJob; validateCardinality(callWithRequestFactory(mockResponses), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() @@ -61,7 +67,10 @@ describe('ML - validateCardinality', () => { }); it('called with non-valid job argument #4, missing data_description', done => { - const job = { analysis_config: {}, datafeed_config: { indices: [] } }; + const job = ({ + analysis_config: {}, + datafeed_config: { indices: [] }, + } as unknown) as CombinedJob; validateCardinality(callWithRequestFactory(mockResponses), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() @@ -69,7 +78,11 @@ describe('ML - validateCardinality', () => { }); it('called with non-valid job argument #5, missing data_description.time_field', done => { - const job = { analysis_config: {}, data_description: {}, datafeed_config: { indices: [] } }; + const job = ({ + analysis_config: {}, + data_description: {}, + datafeed_config: { indices: [] }, + } as unknown) as CombinedJob; validateCardinality(callWithRequestFactory(mockResponses), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() @@ -77,11 +90,11 @@ describe('ML - validateCardinality', () => { }); it('called with non-valid job argument #6, missing analysis_config.influencers', done => { - const job = { + const job = ({ analysis_config: {}, datafeed_config: { indices: [] }, data_description: { time_field: '@timestamp' }, - }; + } as unknown) as CombinedJob; validateCardinality(callWithRequestFactory(mockResponses), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() @@ -89,21 +102,21 @@ describe('ML - validateCardinality', () => { }); it('minimum job configuration to pass cardinality check code', () => { - const job = { + const job = ({ analysis_config: { detectors: [], influencers: [] }, data_description: { time_field: '@timestamp' }, datafeed_config: { indices: [], }, - }; + } as unknown) as CombinedJob; return validateCardinality(callWithRequestFactory(mockResponses), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([]); + expect(ids).toStrictEqual([]); }); }); - const getJobConfig = fieldName => ({ + const getJobConfig = (fieldName: string) => ({ analysis_config: { detectors: [ { @@ -119,11 +132,18 @@ describe('ML - validateCardinality', () => { }, }); - const testCardinality = (fieldName, cardinality, test) => { + const testCardinality = ( + fieldName: string, + cardinality: number, + test: (ids: string[]) => void + ) => { const job = getJobConfig(fieldName); const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; - return validateCardinality(callWithRequestFactory(mockCardinality), job, {}).then(messages => { + return validateCardinality( + callWithRequestFactory(mockCardinality), + (job as unknown) as CombinedJob + ).then(messages => { const ids = messages.map(m => m.id); test(ids); }); @@ -132,26 +152,34 @@ describe('ML - validateCardinality', () => { it(`field '_source' not aggregatable`, () => { const job = getJobConfig('partition_field_name'); job.analysis_config.detectors[0].partition_field_name = '_source'; - return validateCardinality(callWithRequestFactory(mockResponses), job).then(messages => { + return validateCardinality( + callWithRequestFactory(mockResponses), + (job as unknown) as CombinedJob + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['field_not_aggregatable']); + expect(ids).toStrictEqual(['field_not_aggregatable']); }); }); it(`field 'airline' aggregatable`, () => { const job = getJobConfig('partition_field_name'); - return validateCardinality(callWithRequestFactory(mockResponses), job).then(messages => { + return validateCardinality( + callWithRequestFactory(mockResponses), + (job as unknown) as CombinedJob + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_cardinality']); + expect(ids).toStrictEqual(['success_cardinality']); }); }); it('field not aggregatable', () => { const job = getJobConfig('partition_field_name'); - return validateCardinality(callWithRequestFactory({}), job).then(messages => { - const ids = messages.map(m => m.id); - expect(ids).to.eql(['field_not_aggregatable']); - }); + return validateCardinality(callWithRequestFactory({}), (job as unknown) as CombinedJob).then( + messages => { + const ids = messages.map(m => m.id); + expect(ids).toStrictEqual(['field_not_aggregatable']); + } + ); }); it('fields not aggregatable', () => { @@ -160,107 +188,110 @@ describe('ML - validateCardinality', () => { function: 'count', partition_field_name: 'airline', }); - return validateCardinality(callWithRequestFactory({}, true), job).then(messages => { + return validateCardinality( + callWithRequestFactory({}, true), + (job as unknown) as CombinedJob + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['fields_not_aggregatable']); + expect(ids).toStrictEqual(['fields_not_aggregatable']); }); }); it('valid partition field cardinality', () => { return testCardinality('partition_field_name', 50, ids => { - expect(ids).to.eql(['success_cardinality']); + expect(ids).toStrictEqual(['success_cardinality']); }); }); it('too high partition field cardinality', () => { return testCardinality('partition_field_name', 1001, ids => { - expect(ids).to.eql(['cardinality_partition_field']); + expect(ids).toStrictEqual(['cardinality_partition_field']); }); }); it('valid by field cardinality', () => { return testCardinality('by_field_name', 50, ids => { - expect(ids).to.eql(['success_cardinality']); + expect(ids).toStrictEqual(['success_cardinality']); }); }); it('too high by field cardinality', () => { return testCardinality('by_field_name', 1001, ids => { - expect(ids).to.eql(['cardinality_by_field']); + expect(ids).toStrictEqual(['cardinality_by_field']); }); }); it('valid over field cardinality', () => { return testCardinality('over_field_name', 50, ids => { - expect(ids).to.eql(['success_cardinality']); + expect(ids).toStrictEqual(['success_cardinality']); }); }); it('too low over field cardinality', () => { return testCardinality('over_field_name', 9, ids => { - expect(ids).to.eql(['cardinality_over_field_low']); + expect(ids).toStrictEqual(['cardinality_over_field_low']); }); }); it('too high over field cardinality', () => { return testCardinality('over_field_name', 1000001, ids => { - expect(ids).to.eql(['cardinality_over_field_high']); + expect(ids).toStrictEqual(['cardinality_over_field_high']); }); }); const cardinality = 10000; it(`disabled model_plot, over field cardinality of ${cardinality} doesn't trigger a warning`, () => { - const job = getJobConfig('over_field_name'); + const job = (getJobConfig('over_field_name') as unknown) as CombinedJob; job.model_plot_config = { enabled: false }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; return validateCardinality(callWithRequestFactory(mockCardinality), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_cardinality']); + expect(ids).toStrictEqual(['success_cardinality']); }); }); it(`enabled model_plot, over field cardinality of ${cardinality} triggers a model plot warning`, () => { - const job = getJobConfig('over_field_name'); + const job = (getJobConfig('over_field_name') as unknown) as CombinedJob; job.model_plot_config = { enabled: true }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; return validateCardinality(callWithRequestFactory(mockCardinality), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['cardinality_model_plot_high']); + expect(ids).toStrictEqual(['cardinality_model_plot_high']); }); }); it(`disabled model_plot, by field cardinality of ${cardinality} triggers a field cardinality warning`, () => { - const job = getJobConfig('by_field_name'); + const job = (getJobConfig('by_field_name') as unknown) as CombinedJob; job.model_plot_config = { enabled: false }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; return validateCardinality(callWithRequestFactory(mockCardinality), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['cardinality_by_field']); + expect(ids).toStrictEqual(['cardinality_by_field']); }); }); it(`enabled model_plot, by field cardinality of ${cardinality} triggers a model plot warning and field cardinality warning`, () => { - const job = getJobConfig('by_field_name'); + const job = (getJobConfig('by_field_name') as unknown) as CombinedJob; job.model_plot_config = { enabled: true }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; return validateCardinality(callWithRequestFactory(mockCardinality), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['cardinality_model_plot_high', 'cardinality_by_field']); + expect(ids).toStrictEqual(['cardinality_model_plot_high', 'cardinality_by_field']); }); }); it(`enabled model_plot with terms, by field cardinality of ${cardinality} triggers just field cardinality warning`, () => { - const job = getJobConfig('by_field_name'); + const job = (getJobConfig('by_field_name') as unknown) as CombinedJob; job.model_plot_config = { enabled: true, terms: 'AAL,AAB' }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; return validateCardinality(callWithRequestFactory(mockCardinality), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['cardinality_by_field']); + expect(ids).toStrictEqual(['cardinality_by_field']); }); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_influencers.js b/x-pack/plugins/ml/server/models/job_validation/validate_influencers.test.ts similarity index 63% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/validate_influencers.js rename to x-pack/plugins/ml/server/models/job_validation/validate_influencers.test.ts index 06b2e5205fdbde..df3310ad9f5e82 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_influencers.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_influencers.test.ts @@ -4,19 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { validateInfluencers } from '../validate_influencers'; +import { APICaller } from 'kibana/server'; + +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; + +import { validateInfluencers } from './validate_influencers'; describe('ML - validateInfluencers', () => { it('called without arguments throws an error', done => { - validateInfluencers().then( + validateInfluencers( + (undefined as unknown) as APICaller, + (undefined as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without job argument.')), () => done() ); }); it('called with non-valid job argument #1, missing analysis_config', done => { - validateInfluencers(undefined, {}).then( + validateInfluencers((undefined as unknown) as APICaller, ({} as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -28,7 +34,7 @@ describe('ML - validateInfluencers', () => { datafeed_config: { indices: [] }, data_description: { time_field: '@timestamp' }, }; - validateInfluencers(undefined, job).then( + validateInfluencers((undefined as unknown) as APICaller, (job as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -40,25 +46,29 @@ describe('ML - validateInfluencers', () => { datafeed_config: { indices: [] }, data_description: { time_field: '@timestamp' }, }; - validateInfluencers(undefined, job).then( + validateInfluencers((undefined as unknown) as APICaller, (job as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); }); - const getJobConfig = (influencers = [], detectors = []) => ({ - analysis_config: { detectors, influencers }, - data_description: { time_field: '@timestamp' }, - datafeed_config: { - indices: [], - }, - }); + const getJobConfig: ( + influencers?: string[], + detectors?: CombinedJob['analysis_config']['detectors'] + ) => CombinedJob = (influencers = [], detectors = []) => + (({ + analysis_config: { detectors, influencers }, + data_description: { time_field: '@timestamp' }, + datafeed_config: { + indices: [], + }, + } as unknown) as CombinedJob); it('success_influencer', () => { const job = getJobConfig(['airline']); - return validateInfluencers(undefined, job).then(messages => { + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_influencers']); + expect(ids).toStrictEqual(['success_influencers']); }); }); @@ -69,31 +79,30 @@ describe('ML - validateInfluencers', () => { { detector_description: 'count', function: 'count', - rules: [], detector_index: 0, }, ] ); - return validateInfluencers(undefined, job).then(messages => { + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([]); + expect(ids).toStrictEqual([]); }); }); it('influencer_low', () => { const job = getJobConfig(); - return validateInfluencers(undefined, job).then(messages => { + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['influencer_low']); + expect(ids).toStrictEqual(['influencer_low']); }); }); it('influencer_high', () => { const job = getJobConfig(['i1', 'i2', 'i3', 'i4']); - return validateInfluencers(undefined, job).then(messages => { + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['influencer_high']); + expect(ids).toStrictEqual(['influencer_high']); }); }); @@ -105,14 +114,13 @@ describe('ML - validateInfluencers', () => { detector_description: 'count', function: 'count', partition_field_name: 'airline', - rules: [], detector_index: 0, }, ] ); - return validateInfluencers(undefined, job).then(messages => { + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['influencer_low_suggestion']); + expect(ids).toStrictEqual(['influencer_low_suggestion']); }); }); @@ -124,27 +132,24 @@ describe('ML - validateInfluencers', () => { detector_description: 'count', function: 'count', partition_field_name: 'partition_field', - rules: [], detector_index: 0, }, { detector_description: 'count', function: 'count', by_field_name: 'by_field', - rules: [], detector_index: 0, }, { detector_description: 'count', function: 'count', over_field_name: 'over_field', - rules: [], detector_index: 0, }, ] ); - return validateInfluencers(undefined, job).then(messages => { - expect(messages).to.eql([ + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { + expect(messages).toStrictEqual([ { id: 'influencer_low_suggestions', influencerSuggestion: '["partition_field","by_field","over_field"]', diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_influencers.js b/x-pack/plugins/ml/server/models/job_validation/validate_influencers.ts similarity index 89% rename from x-pack/plugins/ml/server/models/job_validation/validate_influencers.js rename to x-pack/plugins/ml/server/models/job_validation/validate_influencers.ts index 60fd5c37b99586..e54ffc4586a8e3 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_influencers.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_influencers.ts @@ -4,19 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ +import { APICaller } from 'kibana/server'; + +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; + import { validateJobObject } from './validate_job_object'; const INFLUENCER_LOW_THRESHOLD = 0; const INFLUENCER_HIGH_THRESHOLD = 4; const DETECTOR_FIELD_NAMES_THRESHOLD = 1; -export async function validateInfluencers(callWithRequest, job) { +export async function validateInfluencers(callWithRequest: APICaller, job: CombinedJob) { validateJobObject(job); const messages = []; const influencers = job.analysis_config.influencers; - const detectorFieldNames = []; + const detectorFieldNames: string[] = []; job.analysis_config.detectors.forEach(d => { if (d.by_field_name) { detectorFieldNames.push(d.by_field_name); diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_time_range.js b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts similarity index 76% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/validate_time_range.js rename to x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts index e3ef62e5074850..2c3b2dd4dc6ae1 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_time_range.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts @@ -5,28 +5,32 @@ */ import _ from 'lodash'; -import expect from '@kbn/expect'; -import { isValidTimeField, validateTimeRange } from '../validate_time_range'; -import mockTimeField from './mock_time_field'; -import mockTimeFieldNested from './mock_time_field_nested'; -import mockTimeRange from './mock_time_range'; +import { APICaller } from 'kibana/server'; + +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; + +import { isValidTimeField, validateTimeRange } from './validate_time_range'; + +import mockTimeField from './__mocks__/mock_time_field.json'; +import mockTimeFieldNested from './__mocks__/mock_time_field_nested.json'; +import mockTimeRange from './__mocks__/mock_time_range.json'; const mockSearchResponse = { fieldCaps: mockTimeField, search: mockTimeRange, }; -const callWithRequestFactory = resp => { - return path => { +const callWithRequestFactory = (resp: any): APICaller => { + return (path: string) => { return new Promise(resolve => { resolve(resp[path]); - }); + }) as Promise; }; }; function getMinimalValidJob() { - return { + return ({ analysis_config: { bucket_span: '15m', detectors: [], @@ -36,12 +40,15 @@ function getMinimalValidJob() { datafeed_config: { indices: [], }, - }; + } as unknown) as CombinedJob; } describe('ML - isValidTimeField', () => { it('called without job config argument triggers Promise rejection', done => { - isValidTimeField(callWithRequestFactory(mockSearchResponse)).then( + isValidTimeField( + callWithRequestFactory(mockSearchResponse), + (undefined as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without job argument.')), () => done() ); @@ -50,7 +57,7 @@ describe('ML - isValidTimeField', () => { it('time_field `@timestamp`', done => { isValidTimeField(callWithRequestFactory(mockSearchResponse), getMinimalValidJob()).then( valid => { - expect(valid).to.be(true); + expect(valid).toBe(true); done(); }, () => done(new Error('isValidTimeField Promise failed for time_field `@timestamp`.')) @@ -71,7 +78,7 @@ describe('ML - isValidTimeField', () => { mockJobConfigNestedDate ).then( valid => { - expect(valid).to.be(true); + expect(valid).toBe(true); done(); }, () => done(new Error('isValidTimeField Promise failed for time_field `metadata.timestamp`.')) @@ -81,14 +88,19 @@ describe('ML - isValidTimeField', () => { describe('ML - validateTimeRange', () => { it('called without arguments', done => { - validateTimeRange(callWithRequestFactory(mockSearchResponse)).then( + validateTimeRange( + callWithRequestFactory(mockSearchResponse), + (undefined as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without job argument.')), () => done() ); }); it('called with non-valid job argument #2, missing datafeed_config', done => { - validateTimeRange(callWithRequestFactory(mockSearchResponse), { analysis_config: {} }).then( + validateTimeRange(callWithRequestFactory(mockSearchResponse), ({ + analysis_config: {}, + } as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -96,7 +108,10 @@ describe('ML - validateTimeRange', () => { it('called with non-valid job argument #3, missing datafeed_config.indices', done => { const job = { analysis_config: {}, datafeed_config: {} }; - validateTimeRange(callWithRequestFactory(mockSearchResponse), job).then( + validateTimeRange( + callWithRequestFactory(mockSearchResponse), + (job as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -104,7 +119,10 @@ describe('ML - validateTimeRange', () => { it('called with non-valid job argument #4, missing data_description', done => { const job = { analysis_config: {}, datafeed_config: { indices: [] } }; - validateTimeRange(callWithRequestFactory(mockSearchResponse), job).then( + validateTimeRange( + callWithRequestFactory(mockSearchResponse), + (job as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -112,7 +130,10 @@ describe('ML - validateTimeRange', () => { it('called with non-valid job argument #5, missing data_description.time_field', done => { const job = { analysis_config: {}, data_description: {}, datafeed_config: { indices: [] } }; - validateTimeRange(callWithRequestFactory(mockSearchResponse), job).then( + validateTimeRange( + callWithRequestFactory(mockSearchResponse), + (job as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -128,7 +149,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['time_field_invalid']); + expect(ids).toStrictEqual(['time_field_invalid']); }); }); @@ -142,7 +163,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['time_range_short']); + expect(ids).toStrictEqual(['time_range_short']); }); }); @@ -154,7 +175,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['time_range_short']); + expect(ids).toStrictEqual(['time_range_short']); }); }); @@ -166,7 +187,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['time_range_short']); + expect(ids).toStrictEqual(['time_range_short']); }); }); @@ -178,7 +199,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_time_range']); + expect(ids).toStrictEqual(['success_time_range']); }); }); @@ -190,7 +211,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['time_range_before_epoch']); + expect(ids).toStrictEqual(['time_range_before_epoch']); }); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts index 5f734387698512..4fb09af94dcc68 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts @@ -37,9 +37,9 @@ export async function isValidTimeField(callAsCurrentUser: APICaller, job: Combin fields: [timeField], }); - let fieldType = fieldCaps.fields[timeField]?.date?.type; + let fieldType = fieldCaps?.fields[timeField]?.date?.type; if (fieldType === undefined) { - fieldType = fieldCaps.fields[timeField]?.date_nanos?.type; + fieldType = fieldCaps?.fields[timeField]?.date_nanos?.type; } return fieldType === ES_FIELD_TYPES.DATE || fieldType === ES_FIELD_TYPES.DATE_NANOS; } @@ -47,7 +47,7 @@ export async function isValidTimeField(callAsCurrentUser: APICaller, job: Combin export async function validateTimeRange( callAsCurrentUser: APICaller, job: CombinedJob, - timeRange: TimeRange | undefined + timeRange?: TimeRange ) { const messages: ValidateTimeRangeMessage[] = []; diff --git a/x-pack/plugins/ml/server/shared.ts b/x-pack/plugins/ml/server/shared.ts new file mode 100644 index 00000000000000..1e50950bc3bced --- /dev/null +++ b/x-pack/plugins/ml/server/shared.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from '../common/types/anomalies'; diff --git a/x-pack/plugins/siem/public/components/ml_popover/types.ts b/x-pack/plugins/siem/public/components/ml_popover/types.ts index 58d40c298b3294..005f93650a8eb0 100644 --- a/x-pack/plugins/siem/public/components/ml_popover/types.ts +++ b/x-pack/plugins/siem/public/components/ml_popover/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AuditMessageBase } from '../../../../ml/common/types/audit_message'; +import { AuditMessageBase } from '../../../../ml/public'; import { MlError } from '../ml/types'; export interface Group { diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index e7db2282258804..91685a68a60ae9 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -122,20 +122,11 @@ describe('import_rules_route', () => { clients.siemClient.getSignalsIndex.mockReturnValue('mockSignalsIndex'); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptyIndex()); const response = await server.inject(request, context); - expect(response.status).toEqual(200); + expect(response.status).toEqual(400); expect(response.body).toEqual({ - errors: [ - { - error: { - message: - 'To create a rule, the index must exist first. Index mockSignalsIndex does not exist', - status_code: 409, - }, - rule_id: 'rule-1', - }, - ], - success: false, - success_count: 0, + message: + 'To create a rule, the index must exist first. Index mockSignalsIndex does not exist', + status_code: 400, }); }); @@ -145,19 +136,10 @@ describe('import_rules_route', () => { }); const response = await server.inject(request, context); - expect(response.status).toEqual(200); + expect(response.status).toEqual(500); expect(response.body).toEqual({ - errors: [ - { - error: { - message: 'Test error', - status_code: 400, - }, - rule_id: 'rule-1', - }, - ], - success: false, - success_count: 0, + message: 'Test error', + status_code: 500, }); }); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts index 4d86f0bec6502a..9ba083ae48086e 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -75,6 +75,14 @@ export const importRulesRoute = (router: IRouter, config: ConfigType) => { body: `Invalid file extension ${fileExtension}`, }); } + const signalsIndex = siemClient.getSignalsIndex(); + const indexExists = await getIndexExists(clusterClient.callAsCurrentUser, signalsIndex); + if (!indexExists) { + return siemResponse.error({ + statusCode: 400, + body: `To create a rule, the index must exist first. Index ${signalsIndex} does not exist`, + }); + } const objectLimit = config.maxRuleImportExportSize; const readStream = createRulesStreamFromNdJson(objectLimit); @@ -94,166 +102,150 @@ export const importRulesRoute = (router: IRouter, config: ConfigType) => { const batchParseObjects = chunkParseObjects.shift() ?? []; const newImportRuleResponse = await Promise.all( batchParseObjects.reduce>>((accum, parsedRule) => { - const importsWorkerPromise = new Promise( - async (resolve, reject) => { - if (parsedRule instanceof Error) { - // If the JSON object had a validation or parse error then we return - // early with the error and an (unknown) for the ruleId - resolve( - createBulkErrorObject({ - statusCode: 400, - message: parsedRule.message, - }) - ); - return null; - } - const { - anomaly_threshold: anomalyThreshold, - description, - enabled, - false_positives: falsePositives, - from, - immutable, - query, - language, - machine_learning_job_id: machineLearningJobId, - output_index: outputIndex, - saved_id: savedId, - meta, - filters, - rule_id: ruleId, - index, - interval, - max_signals: maxSignals, - risk_score: riskScore, - name, - severity, - tags, - threat, - to, - type, - references, - note, - timeline_id: timelineId, - timeline_title: timelineTitle, - version, - exceptions_list, - } = parsedRule; + const importsWorkerPromise = new Promise(async resolve => { + if (parsedRule instanceof Error) { + // If the JSON object had a validation or parse error then we return + // early with the error and an (unknown) for the ruleId + resolve( + createBulkErrorObject({ + statusCode: 400, + message: parsedRule.message, + }) + ); + return null; + } + const { + anomaly_threshold: anomalyThreshold, + description, + enabled, + false_positives: falsePositives, + from, + immutable, + query, + language, + machine_learning_job_id: machineLearningJobId, + output_index: outputIndex, + saved_id: savedId, + meta, + filters, + rule_id: ruleId, + index, + interval, + max_signals: maxSignals, + risk_score: riskScore, + name, + severity, + tags, + threat, + to, + type, + references, + note, + timeline_id: timelineId, + timeline_title: timelineTitle, + version, + exceptions_list, + } = parsedRule; - try { - validateLicenseForRuleType({ - license: context.licensing.license, - ruleType: type, - }); + try { + validateLicenseForRuleType({ + license: context.licensing.license, + ruleType: type, + }); - const signalsIndex = siemClient.getSignalsIndex(); - const indexExists = await getIndexExists( - clusterClient.callAsCurrentUser, - signalsIndex - ); - if (!indexExists) { - resolve( - createBulkErrorObject({ - ruleId, - statusCode: 409, - message: `To create a rule, the index must exist first. Index ${signalsIndex} does not exist`, - }) - ); - } - const rule = await readRules({ alertsClient, ruleId }); - if (rule == null) { - await createRules({ - alertsClient, - anomalyThreshold, - description, - enabled, - falsePositives, - from, - immutable, - query, - language, - machineLearningJobId, - outputIndex: signalsIndex, - savedId, - timelineId, - timelineTitle, - meta, - filters, - ruleId, - index, - interval, - maxSignals, - riskScore, - name, - severity, - tags, - to, - type, - threat, - references, - note, - version, - exceptions_list, - actions: [], // Actions are not imported nor exported at this time - }); - resolve({ rule_id: ruleId, status_code: 200 }); - } else if (rule != null && request.query.overwrite) { - await patchRules({ - alertsClient, - savedObjectsClient, - description, - enabled, - falsePositives, - from, - immutable, - query, - language, - outputIndex, - savedId, - timelineId, - timelineTitle, - meta, - filters, - id: undefined, - ruleId, - index, - interval, - maxSignals, - riskScore, - name, - severity, - tags, - to, - type, - threat, - references, - note, - version, - exceptions_list, - anomalyThreshold, - machineLearningJobId, - }); - resolve({ rule_id: ruleId, status_code: 200 }); - } else if (rule != null) { - resolve( - createBulkErrorObject({ - ruleId, - statusCode: 409, - message: `rule_id: "${ruleId}" already exists`, - }) - ); - } - } catch (err) { + const rule = await readRules({ alertsClient, ruleId }); + if (rule == null) { + await createRules({ + alertsClient, + anomalyThreshold, + description, + enabled, + falsePositives, + from, + immutable, + query, + language, + machineLearningJobId, + outputIndex: signalsIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + ruleId, + index, + interval, + maxSignals, + riskScore, + name, + severity, + tags, + to, + type, + threat, + references, + note, + version, + exceptions_list, + actions: [], // Actions are not imported nor exported at this time + }); + resolve({ rule_id: ruleId, status_code: 200 }); + } else if (rule != null && request.query.overwrite) { + await patchRules({ + alertsClient, + savedObjectsClient, + description, + enabled, + falsePositives, + from, + immutable, + query, + language, + outputIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + id: undefined, + ruleId, + index, + interval, + maxSignals, + riskScore, + name, + severity, + tags, + to, + type, + threat, + references, + note, + version, + exceptions_list, + anomalyThreshold, + machineLearningJobId, + }); + resolve({ rule_id: ruleId, status_code: 200 }); + } else if (rule != null) { resolve( createBulkErrorObject({ ruleId, - statusCode: 400, - message: err.message, + statusCode: 409, + message: `rule_id: "${ruleId}" already exists`, }) ); } + } catch (err) { + resolve( + createBulkErrorObject({ + ruleId, + statusCode: 400, + message: err.message, + }) + ); } - ); + }); return [...accum, importsWorkerPromise]; }, []) ); diff --git a/x-pack/plugins/siem/server/lib/machine_learning/index.ts b/x-pack/plugins/siem/server/lib/machine_learning/index.ts index eb09fdde3cce31..865a3cf51604d4 100644 --- a/x-pack/plugins/siem/server/lib/machine_learning/index.ts +++ b/x-pack/plugins/siem/server/lib/machine_learning/index.ts @@ -6,7 +6,7 @@ import { SearchResponse, SearchParams } from 'elasticsearch'; -import { AnomalyRecordDoc as Anomaly } from '../../../../ml/common/types/anomalies'; +import { AnomalyRecordDoc as Anomaly } from '../../../../ml/server'; export { Anomaly }; export type AnomalyResults = SearchResponse; diff --git a/x-pack/plugins/transform/public/__mocks__/shared_imports.ts b/x-pack/plugins/transform/public/__mocks__/shared_imports.ts index 9d8106a1366d6d..e115e086f45b56 100644 --- a/x-pack/plugins/transform/public/__mocks__/shared_imports.ts +++ b/x-pack/plugins/transform/public/__mocks__/shared_imports.ts @@ -14,8 +14,8 @@ export const useRequest = jest.fn(() => ({ })); // just passing through the reimports -export { getErrorMessage } from '../../../ml/common/util/errors'; export { + getErrorMessage, getDataGridSchemaFromKibanaFieldType, getFieldsFromKibanaIndexPattern, multiColumnSortFactory, @@ -27,5 +27,5 @@ export { SearchResponse7, UseDataGridReturnType, UseIndexDataReturnType, -} from '../../../ml/public/application/components/data_grid'; -export { INDEX_STATUS } from '../../../ml/public/application/data_frame_analytics/common'; + INDEX_STATUS, +} from '../../../ml/public'; diff --git a/x-pack/plugins/transform/public/app/common/aggregations.ts b/x-pack/plugins/transform/public/app/common/aggregations.ts index 038d68ff37d876..397a58006f1d1a 100644 --- a/x-pack/plugins/transform/public/app/common/aggregations.ts +++ b/x-pack/plugins/transform/public/app/common/aggregations.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { composeValidators, patternValidator } from '../../../../ml/common/util/validators'; +import { composeValidators, patternValidator } from '../../../../ml/public'; export type AggName = string; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx index a794b7e7c21438..d3dae0a8c8b63c 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC } from 'react'; +import React, { useState, FC } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButton, EuiButtonEmpty, + EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiFlyout, @@ -18,11 +19,10 @@ import { EuiFlyoutFooter, EuiFlyoutHeader, EuiOverlayMask, + EuiSpacer, EuiTitle, } from '@elastic/eui'; -import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; - import { getErrorMessage } from '../../../../../shared_imports'; import { @@ -30,8 +30,7 @@ import { TransformPivotConfig, REFRESH_TRANSFORM_LIST_STATE, } from '../../../../common'; -import { ToastNotificationText } from '../../../../components'; -import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies'; +import { useToastNotifications } from '../../../../app_dependencies'; import { useApi } from '../../../../hooks/use_api'; @@ -48,13 +47,14 @@ interface EditTransformFlyoutProps { } export const EditTransformFlyout: FC = ({ closeFlyout, config }) => { - const { overlays } = useAppDependencies(); const api = useApi(); const toastNotifications = useToastNotifications(); const [state, dispatch] = useEditTransformFlyout(config); + const [errorMessage, setErrorMessage] = useState(undefined); async function submitFormHandler() { + setErrorMessage(undefined); const requestConfig = applyFormFieldsToTransformConfig(config, state.formFields); const transformId = config.id; @@ -69,12 +69,7 @@ export const EditTransformFlyout: FC = ({ closeFlyout, closeFlyout(); refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.REFRESH); } catch (e) { - toastNotifications.addDanger({ - title: i18n.translate('xpack.transform.transformList.editTransformGenericErrorMessage', { - defaultMessage: 'An error occurred calling the API endpoint to update transforms.', - }), - text: toMountPoint(), - }); + setErrorMessage(getErrorMessage(e)); } } @@ -97,6 +92,24 @@ export const EditTransformFlyout: FC = ({ closeFlyout, }> + {errorMessage !== undefined && ( + <> + + +

      {errorMessage}

      +
      + + )}
      diff --git a/x-pack/plugins/transform/public/shared_imports.ts b/x-pack/plugins/transform/public/shared_imports.ts index bcd8e53e3d1919..3737377de2d5ee 100644 --- a/x-pack/plugins/transform/public/shared_imports.ts +++ b/x-pack/plugins/transform/public/shared_imports.ts @@ -16,9 +16,8 @@ export { useRequest, } from '../../../../src/plugins/es_ui_shared/public/request/np_ready_request'; -export { getErrorMessage } from '../../ml/common/util/errors'; - export { + getErrorMessage, getDataGridSchemaFromKibanaFieldType, getFieldsFromKibanaIndexPattern, multiColumnSortFactory, @@ -30,5 +29,5 @@ export { SearchResponse7, UseDataGridReturnType, UseIndexDataReturnType, -} from '../../ml/public/application/components/data_grid'; -export { INDEX_STATUS } from '../../ml/public/application/data_frame_analytics/common'; + INDEX_STATUS, +} from '../../ml/public'; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1adc77267c44f0..0d050f7bf98427 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6589,7 +6589,6 @@ "xpack.graph.sidebar.selectionsTitle": "選択項目", "xpack.graph.sidebar.styleVerticesTitle": "スタイルが選択された頂点", "xpack.graph.sidebar.topMenu.addLinksButtonTooltip": "既存の用語の間にリンクを追加します", - "xpack.graph.sidebar.topMenu.blacklistButtonTooltip": "選択項目がワークスペースに戻らないようブラックリストに追加します", "xpack.graph.sidebar.topMenu.customStyleButtonTooltip": "選択された頂点のカスタムスタイル", "xpack.graph.sidebar.topMenu.drillDownButtonTooltip": "ドリルダウン", "xpack.graph.sidebar.topMenu.expandSelectionButtonTooltip": "選択項目を拡張", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a57b517123e77a..21113d55b46415 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6594,7 +6594,6 @@ "xpack.graph.sidebar.selectionsTitle": "选择的内容", "xpack.graph.sidebar.styleVerticesTitle": "样式选择的顶点", "xpack.graph.sidebar.topMenu.addLinksButtonTooltip": "在现有字词之间添加链接", - "xpack.graph.sidebar.topMenu.blacklistButtonTooltip": "返回工作空间时选择的黑名单", "xpack.graph.sidebar.topMenu.customStyleButtonTooltip": "定制样式选择的顶点", "xpack.graph.sidebar.topMenu.drillDownButtonTooltip": "向下钻取", "xpack.graph.sidebar.topMenu.expandSelectionButtonTooltip": "展开选择内容", diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index c64ca7c3d48432..c6a7eb261d8fd2 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -12,7 +12,6 @@ import { import { UMFrontendLibs } from '../lib/lib'; import { PLUGIN } from '../../common/constants'; import { FeatureCatalogueCategory } from '../../../../../src/plugins/home/public'; -import { getKibanaFrameworkAdapter } from '../lib/adapters/framework/new_platform_adapter'; import { HomePublicPluginSetup } from '../../../../../src/plugins/home/public'; import { EmbeddableStart } from '../../../../../src/plugins/embeddable/public'; import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public'; @@ -61,6 +60,10 @@ export class UptimePlugin implements Plugin { diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx index 52d4f620f84b3f..b586c1241290bc 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx @@ -20,7 +20,7 @@ import { } from '../../../state/selectors'; import { UptimeRefreshContext } from '../../../contexts'; import { getMLJobId } from '../../../state/api/ml_anomaly'; -import { JobStat } from '../../../../../ml/common/types/data_recognizer'; +import { JobStat } from '../../../../../ml/public'; import { MonitorDurationComponent } from './monitor_duration'; import { MonitorIdParam } from '../../../../common/types'; diff --git a/x-pack/plugins/uptime/public/state/actions/ml_anomaly.ts b/x-pack/plugins/uptime/public/state/actions/ml_anomaly.ts index 441a3cefdf204d..6b564ba0e83e44 100644 --- a/x-pack/plugins/uptime/public/state/actions/ml_anomaly.ts +++ b/x-pack/plugins/uptime/public/state/actions/ml_anomaly.ts @@ -6,15 +6,17 @@ import { createAction } from 'redux-actions'; import { createAsyncAction } from './utils'; -import { MlCapabilitiesResponse } from '../../../../../plugins/ml/common/types/capabilities'; -import { AnomaliesTableRecord } from '../../../../../plugins/ml/common/types/anomalies'; +import { + MlCapabilitiesResponse, + AnomaliesTableRecord, + JobExistResult, +} from '../../../../../plugins/ml/public'; import { CreateMLJobSuccess, DeleteJobResults, MonitorIdParam, HeartbeatIndicesParam, } from './types'; -import { JobExistResult } from '../../../../../plugins/ml/common/types/data_recognizer'; export const resetMLState = createAction('RESET_ML_STATE'); diff --git a/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts b/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts index ff2ad8ba0745ff..158d7b631a8b88 100644 --- a/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts +++ b/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts @@ -8,15 +8,17 @@ import moment from 'moment'; import { apiService } from './utils'; import { AnomalyRecords, AnomalyRecordsParams } from '../actions'; import { API_URLS, ML_JOB_ID, ML_MODULE_ID } from '../../../common/constants'; -import { MlCapabilitiesResponse } from '../../../../../plugins/ml/common/types/capabilities'; +import { + MlCapabilitiesResponse, + DataRecognizerConfigResponse, + JobExistResult, +} from '../../../../../plugins/ml/public'; import { CreateMLJobSuccess, DeleteJobResults, MonitorIdParam, HeartbeatIndicesParam, } from '../actions/types'; -import { DataRecognizerConfigResponse } from '../../../../../plugins/ml/common/types/modules'; -import { JobExistResult } from '../../../../../plugins/ml/common/types/data_recognizer'; const getJobPrefix = (monitorId: string) => { // ML App doesn't support upper case characters in job name diff --git a/x-pack/plugins/uptime/public/state/reducers/ml_anomaly.ts b/x-pack/plugins/uptime/public/state/reducers/ml_anomaly.ts index 9a4a949ac4ede4..9f2da19d24208b 100644 --- a/x-pack/plugins/uptime/public/state/reducers/ml_anomaly.ts +++ b/x-pack/plugins/uptime/public/state/reducers/ml_anomaly.ts @@ -16,9 +16,8 @@ import { } from '../actions'; import { getAsyncInitialState, handleAsyncAction } from './utils'; import { AsyncInitialState } from './types'; -import { MlCapabilitiesResponse } from '../../../../../plugins/ml/common/types/capabilities'; +import { MlCapabilitiesResponse, JobExistResult } from '../../../../../plugins/ml/public'; import { CreateMLJobSuccess, DeleteJobResults } from '../actions/types'; -import { JobExistResult } from '../../../../../plugins/ml/common/types/data_recognizer'; export interface MLJobState { mlJob: AsyncInitialState; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts index 4def508fabbc31..868dafedc6849f 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts @@ -25,7 +25,56 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('legacyEs'); describe('import_rules', () => { - describe('importing rules', () => { + describe('importing rules without an index', () => { + it('should not create a rule if the index does not exist', async () => { + await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .expect(400); + + // We have to wait up to 5 seconds for any unresolved promises to flush + await new Promise(resolve => setTimeout(resolve, 5000)); + + // Try to fetch the rule which should still be a 404 (not found) + const { body } = await supertest.get(`${DETECTION_ENGINE_RULES_URL}?rule_id=rule-1`).send(); + + expect(body).to.eql({ + status_code: 404, + message: 'rule_id: "rule-1" not found', + }); + }); + + it('should return an error that the index needs to be created before you are able to import a single rule', async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .expect(400); + + expect(body).to.eql({ + message: + 'To create a rule, the index must exist first. Index .siem-signals-default does not exist', + status_code: 400, + }); + }); + + it('should return an error that the index needs to be created before you are able to import two rules', async () => { + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1', 'rule-2']), 'rules.ndjson') + .expect(400); + + expect(body).to.eql({ + message: + 'To create a rule, the index must exist first. Index .siem-signals-default does not exist', + status_code: 400, + }); + }); + }); + + describe('importing rules with an index', () => { beforeEach(async () => { await createSignalsIndex(supertest); }); diff --git a/x-pack/test_utils/testbed/testbed.ts b/x-pack/test_utils/testbed/testbed.ts index 9bf07f953595c7..b6ec0f8997e1cf 100644 --- a/x-pack/test_utils/testbed/testbed.ts +++ b/x-pack/test_utils/testbed/testbed.ts @@ -5,6 +5,7 @@ */ import { ComponentType, ReactWrapper } from 'enzyme'; + import { findTestSubject } from '../find_test_subject'; import { reactRouterMock } from '../router_helpers'; import { @@ -138,33 +139,23 @@ export const registerTestBed = ( }); }; - const waitFor: TestBed['waitFor'] = async (testSubject: T, count = 1) => { + const waitForFn: TestBed['waitForFn'] = async (predicate, errMessage) => { const triggeredAt = Date.now(); - /** - * The way jest run tests in parallel + the not deterministic DOM update from React "hooks" - * add flakiness to the tests. This is especially true for component integration tests that - * make many update to the DOM. - * - * For this reason, when we _know_ that an element should be there after we updated some state, - * we will give it 30 seconds to appear in the DOM, checking every 100 ms for its presence. - */ const MAX_WAIT_TIME = 30000; - const WAIT_INTERVAL = 100; + const WAIT_INTERVAL = 50; const process = async (): Promise => { - const elemFound = exists(testSubject, count); + const isOK = await predicate(); - if (elemFound) { + if (isOK) { // Great! nothing else to do here. return; } const timeElapsed = Date.now() - triggeredAt; if (timeElapsed > MAX_WAIT_TIME) { - throw new Error( - `I waited patiently for the "${testSubject}" test subject to appear with no luck. It is nowhere to be found!` - ); + throw new Error(errMessage); } return new Promise(resolve => setTimeout(resolve, WAIT_INTERVAL)).then(() => { @@ -176,6 +167,13 @@ export const registerTestBed = ( return process(); }; + const waitFor: TestBed['waitFor'] = (testSubject: T, count = 1) => { + return waitForFn( + () => Promise.resolve(exists(testSubject, count)), + `I waited patiently for the "${testSubject}" test subject to appear with no luck. It is nowhere to be found!` + ); + }; + /** * ---------------------------------------------------------------- * Forms @@ -201,6 +199,18 @@ export const registerTestBed = ( return new Promise(resolve => setTimeout(resolve)); }; + const setSelectValue: TestBed['form']['setSelectValue'] = (select, value) => { + const formSelect = typeof select === 'string' ? find(select) : (select as ReactWrapper); + + if (!formSelect.length) { + throw new Error(`Select "${select}" was not found.`); + } + + formSelect.simulate('change', { target: { value } }); + + component.update(); + }; + const selectCheckBox: TestBed['form']['selectCheckBox'] = ( testSubject, isChecked = true @@ -293,11 +303,13 @@ export const registerTestBed = ( find, setProps, waitFor, + waitForFn, table: { getMetaData, }, form: { setInputValue, + setSelectValue, selectCheckBox, toggleEuiSwitch, setComboBoxValue, diff --git a/x-pack/test_utils/testbed/types.ts b/x-pack/test_utils/testbed/types.ts index f3704bb463ecf9..4cc7deac601563 100644 --- a/x-pack/test_utils/testbed/types.ts +++ b/x-pack/test_utils/testbed/types.ts @@ -41,7 +41,7 @@ export interface TestBed { * * @example * - ```ts + ```typescript find('nameInput'); // or more specific, // "nameInput" is a child of "myForm" @@ -61,6 +61,7 @@ export interface TestBed { * and we need to wait for the data to be fetched (and bypass any "loading" state). */ waitFor: (testSubject: T, count?: number) => Promise; + waitForFn: (predicate: () => Promise, errMessage: string) => Promise; form: { /** * Set the value of a form text input. @@ -79,6 +80,28 @@ export interface TestBed { value: string, isAsync?: boolean ) => Promise | void; + /** + * Set the value of a or a mocked + * For the you need to mock it like this + * + ```typescript + jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + EuiSuperSelect: (props: any) => ( + { + props.onChange(e.target.value); + }} + /> + ), + })); + ``` + * @param select The form select. Can either be a data-test-subj or a reactWrapper (can be a nested path. e.g. "myForm.myInput"). + * @param value The value to set + */ + setSelectValue: (select: T | ReactWrapper, value: string) => void; /** * Select or unselect a form checkbox. *