From 77f476152d7be3017338cda23f1e9bde3662428f Mon Sep 17 00:00:00 2001 From: liza-mae Date: Fri, 3 Jul 2020 06:49:48 -0600 Subject: [PATCH 01/16] Update network idle timeout (#70629) (#70655) Co-authored-by: Elastic Machine Co-authored-by: Elastic Machine --- test/scripts/jenkins_visual_regression.sh | 2 +- test/scripts/jenkins_xpack_visual_regression.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/scripts/jenkins_visual_regression.sh b/test/scripts/jenkins_visual_regression.sh index a32782deec65b..17345d4301882 100755 --- a/test/scripts/jenkins_visual_regression.sh +++ b/test/scripts/jenkins_visual_regression.sh @@ -11,7 +11,7 @@ mkdir -p "$installDir" tar -xzf "$linuxBuild" -C "$installDir" --strip=1 echo " -> running visual regression tests from kibana directory" -yarn percy exec -t 500 -- -- \ +yarn percy exec -t 10000 -- -- \ node scripts/functional_tests \ --debug --bail \ --kibana-install-dir "$installDir" \ diff --git a/test/scripts/jenkins_xpack_visual_regression.sh b/test/scripts/jenkins_xpack_visual_regression.sh index b67c1c9060a6e..36bf3409a5421 100755 --- a/test/scripts/jenkins_xpack_visual_regression.sh +++ b/test/scripts/jenkins_xpack_visual_regression.sh @@ -13,7 +13,7 @@ tar -xzf "$linuxBuild" -C "$installDir" --strip=1 echo " -> running visual regression tests from x-pack directory" cd "$XPACK_DIR" -yarn percy exec -t 500 -- -- \ +yarn percy exec -t 10000 -- -- \ node scripts/functional_tests \ --debug --bail \ --kibana-install-dir "$installDir" \ From 0ceda68f085d34aa63e17aaf07cdb1817b2be983 Mon Sep 17 00:00:00 2001 From: liza-mae Date: Fri, 3 Jul 2020 06:52:14 -0600 Subject: [PATCH 02/16] Build docker image for elasticsearch snapshot (#70482) (#70641) * Build docker image for elasticsearch snapshot * Consolidate statements * Update .ci/es-snapshots/Jenkinsfile_build_es Co-authored-by: Brian Seeders * Update find * Use larger worker Co-authored-by: Elastic Machine Co-authored-by: Brian Seeders Co-authored-by: Elastic Machine Co-authored-by: Brian Seeders --- .ci/es-snapshots/Jenkinsfile_build_es | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.ci/es-snapshots/Jenkinsfile_build_es b/.ci/es-snapshots/Jenkinsfile_build_es index a3470cd750738..aafdf06433c6d 100644 --- a/.ci/es-snapshots/Jenkinsfile_build_es +++ b/.ci/es-snapshots/Jenkinsfile_build_es @@ -25,7 +25,7 @@ def PROMOTE_WITHOUT_VERIFY = !!params.PROMOTE_WITHOUT_VERIFICATION timeout(time: 120, unit: 'MINUTES') { timestamps { ansiColor('xterm') { - node(workers.label('s')) { + node(workers.label('l')) { catchErrors { def VERSION def SNAPSHOT_ID @@ -154,9 +154,10 @@ def buildArchives(destination) { "NODE_NAME=", ]) { sh """ - ./gradlew -p distribution/archives assemble --parallel + ./gradlew -Dbuild.docker=true assemble --parallel mkdir -p ${destination} - find distribution/archives -type f \\( -name 'elasticsearch-*-*-*-*.tar.gz' -o -name 'elasticsearch-*-*-*-*.zip' \\) -not -path *no-jdk* -exec cp {} ${destination} \\; + find distribution -type f \\( -name 'elasticsearch-*-*-*-*.tar.gz' -o -name 'elasticsearch-*-*-*-*.zip' \\) -not -path *no-jdk* -not -path *build-context* -exec cp {} ${destination} \\; + docker images "docker.elastic.co/elasticsearch/elasticsearch" --format "{{.Tag}}" | xargs -n1 bash -c 'docker save docker.elastic.co/elasticsearch/elasticsearch:\${0} | gzip > ${destination}/elasticsearch-\${0}-docker-image.tar.gz' """ } } From fbc887598309129640444f173fe7607803c77f84 Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Fri, 3 Jul 2020 15:01:36 +0200 Subject: [PATCH 03/16] Allow Saved Object type mappings to set a field's `doc_values` property (#70433) (#70700) * Allow doc_values to be disabled * Make doc_values optional * doc_values type for CoreFieldMapping * doc_values not doc_value * Update docs Co-authored-by: Elastic Machine Co-authored-by: Elastic Machine --- ...rver.savedobjectscomplexfieldmapping.doc_values.md | 11 +++++++++++ ...gin-core-server.savedobjectscomplexfieldmapping.md | 1 + ...-server.savedobjectscorefieldmapping.doc_values.md | 11 +++++++++++ ...plugin-core-server.savedobjectscorefieldmapping.md | 1 + src/core/server/saved_objects/mappings/types.ts | 2 ++ src/core/server/server.api.md | 4 ++++ 6 files changed, 30 insertions(+) create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscomplexfieldmapping.doc_values.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscorefieldmapping.doc_values.md diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscomplexfieldmapping.doc_values.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscomplexfieldmapping.doc_values.md new file mode 100644 index 0000000000000..3f2d81cc97c7c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscomplexfieldmapping.doc_values.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsComplexFieldMapping](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.md) > [doc\_values](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.doc_values.md) + +## SavedObjectsComplexFieldMapping.doc\_values property + +Signature: + +```typescript +doc_values?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscomplexfieldmapping.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscomplexfieldmapping.md index a7d13b0015e3f..cb81686b424ec 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscomplexfieldmapping.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscomplexfieldmapping.md @@ -18,6 +18,7 @@ export interface SavedObjectsComplexFieldMapping | Property | Type | Description | | --- | --- | --- | +| [doc\_values](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.doc_values.md) | boolean | | | [properties](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.properties.md) | SavedObjectsMappingProperties | | | [type](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.type.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscorefieldmapping.doc_values.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscorefieldmapping.doc_values.md new file mode 100644 index 0000000000000..2a79eafd85a6c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscorefieldmapping.doc_values.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCoreFieldMapping](./kibana-plugin-core-server.savedobjectscorefieldmapping.md) > [doc\_values](./kibana-plugin-core-server.savedobjectscorefieldmapping.doc_values.md) + +## SavedObjectsCoreFieldMapping.doc\_values property + +Signature: + +```typescript +doc_values?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscorefieldmapping.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscorefieldmapping.md index 9a31d37b3ff30..b9e726eac799d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscorefieldmapping.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscorefieldmapping.md @@ -16,6 +16,7 @@ export interface SavedObjectsCoreFieldMapping | Property | Type | Description | | --- | --- | --- | +| [doc\_values](./kibana-plugin-core-server.savedobjectscorefieldmapping.doc_values.md) | boolean | | | [enabled](./kibana-plugin-core-server.savedobjectscorefieldmapping.enabled.md) | boolean | | | [fields](./kibana-plugin-core-server.savedobjectscorefieldmapping.fields.md) | {
[subfield: string]: {
type: string;
ignore_above?: number;
};
} | | | [index](./kibana-plugin-core-server.savedobjectscorefieldmapping.index.md) | boolean | | diff --git a/src/core/server/saved_objects/mappings/types.ts b/src/core/server/saved_objects/mappings/types.ts index c037ed733549e..7521e4a4bee86 100644 --- a/src/core/server/saved_objects/mappings/types.ts +++ b/src/core/server/saved_objects/mappings/types.ts @@ -133,6 +133,7 @@ export interface SavedObjectsCoreFieldMapping { type: string; null_value?: number | boolean | string; index?: boolean; + doc_values?: boolean; enabled?: boolean; fields?: { [subfield: string]: { @@ -153,6 +154,7 @@ export interface SavedObjectsCoreFieldMapping { * @public */ export interface SavedObjectsComplexFieldMapping { + doc_values?: boolean; type?: string; properties: SavedObjectsMappingProperties; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 1cabaa57e519c..cb413be2c19b8 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1978,6 +1978,8 @@ export interface SavedObjectsClientWrapperOptions { // @public export interface SavedObjectsComplexFieldMapping { + // (undocumented) + doc_values?: boolean; // (undocumented) properties: SavedObjectsMappingProperties; // (undocumented) @@ -1986,6 +1988,8 @@ export interface SavedObjectsComplexFieldMapping { // @public export interface SavedObjectsCoreFieldMapping { + // (undocumented) + doc_values?: boolean; // (undocumented) enabled?: boolean; // (undocumented) From bfce6e89731ada634198d4dec38f89f6a21439c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Fri, 3 Jul 2020 15:08:38 +0200 Subject: [PATCH 04/16] [7.x] [Composable template] Create / Edit wizard (#70220) (#70698) --- .../forms/form_wizard/form_wizard_context.tsx | 2 +- .../multi_content/multi_content_context.tsx | 7 +- .../forms/multi_content/use_multi_content.ts | 4 + src/plugins/es_ui_shared/public/index.ts | 7 +- .../forms/helpers/field_validators/is_json.ts | 9 +- .../home/index_templates_tab.helpers.ts | 54 ++-- .../home/index_templates_tab.test.ts | 30 +- .../template_create.test.tsx | 3 +- .../common/constants/index.ts | 1 - .../common/constants/index_templates.ts | 12 - .../plugins/index_management/common/index.ts | 2 +- .../index_management/common/lib/index.ts | 4 +- .../common/lib/template_serialization.ts | 5 +- .../common/types/templates.ts | 2 + .../component_templates.scss | 34 +++ .../component_templates.tsx | 169 +++++++++++ .../component_templates_list.tsx | 28 ++ .../component_templates_list_item.scss | 31 +++ .../component_templates_list_item.tsx | 103 +++++++ .../component_templates_selection.tsx | 64 +++++ .../component_templates_selector.scss | 36 +++ .../component_templates_selector.tsx | 263 ++++++++++++++++++ .../components/create_button_popover.tsx | 85 ++++++ .../components/filter_list_button.tsx | 91 ++++++ .../components/index.ts | 8 + .../component_template_selector/index.ts | 7 + .../component_templates_context.tsx | 2 + .../components/component_templates/index.ts | 2 + .../components/component_templates/lib/api.ts | 4 +- .../component_templates/lib/request.ts | 8 +- .../component_templates/shared_imports.ts | 1 + .../components/shared/components/index.ts | 2 + .../components/template_content_indicator.tsx | 0 .../components/wizard_steps/step_aliases.tsx | 4 +- .../wizard_steps/step_aliases_container.tsx | 2 +- .../components/wizard_steps/step_mappings.tsx | 6 +- .../wizard_steps/step_mappings_container.tsx | 4 +- .../components/wizard_steps/step_settings.tsx | 4 +- .../wizard_steps/step_settings_container.tsx | 4 +- .../application/components/shared/index.ts | 1 + .../components/template_form/steps/index.ts | 1 + .../template_form/steps/step_components.tsx | 112 ++++++++ .../steps/step_components_container.tsx | 25 ++ .../template_form/steps/step_logistics.tsx | 187 ++++++++++--- .../steps/step_logistics_container.tsx | 12 +- .../template_form/steps/step_review.tsx | 103 ++++++- .../template_form/template_form.tsx | 53 ++-- .../template_form/template_form_schemas.tsx | 47 ++++ .../home/template_list/components/index.ts | 1 - .../template_details/template_details.tsx | 4 +- .../template_table/template_table.tsx | 11 +- .../home/template_list/template_list.tsx | 7 +- .../template_table/template_table.tsx | 57 +++- .../template_create/template_create.tsx | 20 +- .../public/application/services/routing.ts | 12 +- .../index_management/public/shared_imports.ts | 6 +- .../server/client/elasticsearch.ts | 28 ++ .../server/routes/api/templates/lib.ts | 68 +++++ .../api/templates/register_create_route.ts | 29 +- .../api/templates/register_get_routes.ts | 64 +++-- .../api/templates/register_update_route.ts | 28 +- .../routes/api/templates/validate_schemas.ts | 3 + .../index_management/templates.helpers.js | 88 +++--- .../management/index_management/templates.js | 154 ++++++++-- x-pack/test_utils/router_helpers.tsx | 7 +- x-pack/test_utils/testbed/types.ts | 2 +- 66 files changed, 1901 insertions(+), 333 deletions(-) delete mode 100644 x-pack/plugins/index_management/common/constants/index_templates.ts create mode 100644 x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.scss create mode 100644 x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_list.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_list_item.scss create mode 100644 x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_list_item.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selection.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.scss create mode 100644 x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/components/create_button_popover.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/components/filter_list_button.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/components/index.ts create mode 100644 x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/index.ts rename x-pack/plugins/index_management/public/application/{sections/home/template_list => components/shared}/components/template_content_indicator.tsx (100%) create mode 100644 x-pack/plugins/index_management/public/application/components/template_form/steps/step_components.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/template_form/steps/step_components_container.tsx create mode 100644 x-pack/plugins/index_management/server/routes/api/templates/lib.ts diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_context.tsx b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_context.tsx index 5667220881df2..39b91a2e20b53 100644 --- a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_context.tsx +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_context.tsx @@ -23,7 +23,7 @@ import { WithMultiContent, useMultiContentContext, HookProps } from '../multi_co export interface Props { onSave: (data: T) => void | Promise; - children: JSX.Element | JSX.Element[]; + children: JSX.Element | Array; isEditing?: boolean; defaultActiveStep?: number; defaultValue?: HookProps['defaultValue']; diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx b/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx index 5fbe3d2bbbdd4..210b0cedccd06 100644 --- a/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx +++ b/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx @@ -54,7 +54,7 @@ export function useMultiContentContext(contentId: keyof T) { +export function useContent(contentId: K) { const { updateContentAt, saveSnapshotAndRemoveContent, getData } = useMultiContentContext(); const updateContent = useCallback( @@ -71,8 +71,11 @@ export function useContent(contentId: }; }, [contentId, saveSnapshotAndRemoveContent]); + const data = getData(); + const defaultValue = data[contentId]; + return { - defaultValue: getData()[contentId]!, + defaultValue, updateContent, getData, }; diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts b/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts index 0a2c7bb651959..adc68a39a4a5b 100644 --- a/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts +++ b/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts @@ -150,6 +150,10 @@ export function useMultiContent({ * Validate the multi-content active content(s) in the DOM */ const validate = useCallback(async () => { + if (Object.keys(contents.current).length === 0) { + return Boolean(validation.isValid); + } + const updatedValidation = {} as { [key in keyof T]?: boolean | undefined }; for (const [id, _content] of Object.entries(contents.current)) { diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts index 28baa3d8372f0..67c1ee3c7d677 100644 --- a/src/plugins/es_ui_shared/public/index.ts +++ b/src/plugins/es_ui_shared/public/index.ts @@ -22,6 +22,7 @@ * In the future, each top level folder should be exported like that to avoid naming collision */ import * as Forms from './forms'; +import * as Monaco from './monaco'; export { JsonEditor, OnJsonEditorUpdateHandler } from './components/json_editor'; @@ -53,10 +54,6 @@ export { expandLiteralStrings, } from './console_lang'; -import * as Monaco from './monaco'; - -export { Monaco }; - export { AuthorizationContext, AuthorizationProvider, @@ -69,7 +66,7 @@ export { useAuthorizationContext, } from './authorization'; -export { Forms }; +export { Monaco, Forms }; /** dummy plugin, we just want esUiShared to have its own bundle */ export function plugin() { diff --git a/src/plugins/es_ui_shared/static/forms/helpers/field_validators/is_json.ts b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/is_json.ts index dc8321aa07004..019a0e8053d0d 100644 --- a/src/plugins/es_ui_shared/static/forms/helpers/field_validators/is_json.ts +++ b/src/plugins/es_ui_shared/static/forms/helpers/field_validators/is_json.ts @@ -21,12 +21,13 @@ import { ValidationFunc } from '../../hook_form_lib'; import { isJSON } from '../../../validators/string'; import { ERROR_CODE } from './types'; -export const isJsonField = (message: string) => ( - ...args: Parameters -): ReturnType> => { +export const isJsonField = ( + message: string, + { allowEmptyString = false }: { allowEmptyString?: boolean } = {} +) => (...args: Parameters): ReturnType> => { const [{ value }] = args; - if (typeof value !== 'string') { + if (typeof value !== 'string' || (allowEmptyString && value.trim() === '')) { return; } diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts index 98bd3077670a7..5eb4eaf6e2ca1 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { @@ -13,44 +12,21 @@ import { TestBedConfig, findTestSubject, } from '../../../../../test_utils'; -// NOTE: We have to use the Home component instead of the TemplateList component because we depend -// upon react router to provide the name of the template to load in the detail panel. -import { IndexManagementHome } from '../../../public/application/sections/home'; // eslint-disable-line @kbn/eslint/no-restricted-paths -import { indexManagementStore } from '../../../public/application/store'; // eslint-disable-line @kbn/eslint/no-restricted-paths +import { TemplateList } from '../../../public/application/sections/home/template_list'; // eslint-disable-line @kbn/eslint/no-restricted-paths import { TemplateDeserialized } from '../../../common'; -import { WithAppDependencies, services, TestSubjects } from '../helpers'; +import { WithAppDependencies, TestSubjects } from '../helpers'; const testBedConfig: TestBedConfig = { - store: () => indexManagementStore(services as any), memoryRouter: { - initialEntries: [`/indices`], - componentRoutePath: `/:section(indices|templates)`, + initialEntries: [`/templates`], + componentRoutePath: `/templates/:templateName?`, }, doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(IndexManagementHome), testBedConfig); - -export interface IndexTemplatesTabTestBed extends TestBed { - findAction: (action: 'edit' | 'clone' | 'delete') => ReactWrapper; - actions: { - goToTemplatesList: () => void; - selectDetailsTab: (tab: 'summary' | 'settings' | 'mappings' | 'aliases') => void; - clickReloadButton: () => void; - clickTemplateAction: ( - name: TemplateDeserialized['name'], - action: 'edit' | 'clone' | 'delete' - ) => void; - clickTemplateAt: (index: number) => void; - clickCloseDetailsButton: () => void; - clickActionMenu: (name: TemplateDeserialized['name']) => void; - toggleViewItem: (view: 'composable' | 'system') => void; - }; -} - -export const setup = async (): Promise => { - const testBed = await initTestBed(); +const initTestBed = registerTestBed(WithAppDependencies(TemplateList), testBedConfig); +const createActions = (testBed: TestBed) => { /** * Additional helpers */ @@ -64,11 +40,6 @@ export const setup = async (): Promise => { /** * User Actions */ - - const goToTemplatesList = () => { - testBed.find('templatesTab').simulate('click'); - }; - const selectDetailsTab = (tab: 'summary' | 'settings' | 'mappings' | 'aliases') => { const tabs = ['summary', 'settings', 'mappings', 'aliases']; @@ -136,10 +107,8 @@ export const setup = async (): Promise => { }; return { - ...testBed, findAction, actions: { - goToTemplatesList, selectDetailsTab, clickReloadButton, clickTemplateAction, @@ -150,3 +119,14 @@ export const setup = async (): Promise => { }, }; }; + +export const setup = async (): Promise => { + const testBed = await initTestBed(); + + return { + ...testBed, + ...createActions(testBed), + }; +}; + +export type IndexTemplatesTabTestBed = TestBed & ReturnType; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts index 2ff3743cd866c..fb3e16e5345cb 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts @@ -30,28 +30,15 @@ describe('Index Templates tab', () => { server.restore(); }); - beforeEach(async () => { - httpRequestsMockHelpers.setLoadIndicesResponse([]); - - await act(async () => { - testBed = await setup(); - }); - }); - describe('when there are no index templates', () => { - beforeEach(async () => { - const { actions, component } = testBed; - + test('should display an empty prompt', async () => { httpRequestsMockHelpers.setLoadTemplatesResponse({ templates: [], legacyTemplates: [] }); await act(async () => { - actions.goToTemplatesList(); + testBed = await setup(); }); + const { exists, component } = testBed; component.update(); - }); - - test('should display an empty prompt', async () => { - const { exists } = testBed; expect(exists('sectionLoading')).toBe(false); expect(exists('emptyPrompt')).toBe(true); @@ -119,14 +106,12 @@ describe('Index Templates tab', () => { const legacyTemplates = [template4, template5, template6]; beforeEach(async () => { - const { actions, component } = testBed; - httpRequestsMockHelpers.setLoadTemplatesResponse({ templates, legacyTemplates }); await act(async () => { - actions.goToTemplatesList(); + testBed = await setup(); }); - component.update(); + testBed.component.update(); }); test('should list them in the table', async () => { @@ -151,6 +136,7 @@ describe('Index Templates tab', () => { composedOfString, priorityFormatted, 'M S A', // Mappings Settings Aliases badges + '', // Column of actions ]); }); @@ -192,8 +178,10 @@ describe('Index Templates tab', () => { ); }); - test('should have a button to create a new template', () => { + test('should have a button to create a template', () => { const { exists } = testBed; + // Both composable and legacy templates + expect(exists('createTemplateButton')).toBe(true); expect(exists('createLegacyTemplateButton')).toBe(true); }); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx index 07a27e2414aed..69d7a13edfcfb 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx @@ -7,7 +7,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { CREATE_LEGACY_TEMPLATE_BY_DEFAULT } from '../../../common'; import { setupEnvironment, nextTick } from '../helpers'; import { @@ -369,7 +368,7 @@ describe.skip('', () => { aliases: ALIASES, }, _kbnMeta: { - isLegacy: CREATE_LEGACY_TEMPLATE_BY_DEFAULT, + isLegacy: false, isManaged: false, }, }; diff --git a/x-pack/plugins/index_management/common/constants/index.ts b/x-pack/plugins/index_management/common/constants/index.ts index 526b9fede2a67..d1700f0e611c0 100644 --- a/x-pack/plugins/index_management/common/constants/index.ts +++ b/x-pack/plugins/index_management/common/constants/index.ts @@ -9,7 +9,6 @@ export { BASE_PATH } from './base_path'; export { API_BASE_PATH } from './api_base_path'; export { INVALID_INDEX_PATTERN_CHARS, INVALID_TEMPLATE_NAME_CHARS } from './invalid_characters'; export * from './index_statuses'; -export { CREATE_LEGACY_TEMPLATE_BY_DEFAULT } from './index_templates'; export { UIM_APP_NAME, diff --git a/x-pack/plugins/index_management/common/constants/index_templates.ts b/x-pack/plugins/index_management/common/constants/index_templates.ts deleted file mode 100644 index 7696b3832c51e..0000000000000 --- a/x-pack/plugins/index_management/common/constants/index_templates.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/** - * Up until the end of the 8.x release cycle we need to support both - * legacy and composable index template formats. This constant keeps track of whether - * we create legacy index template format by default in the UI. - */ -export const CREATE_LEGACY_TEMPLATE_BY_DEFAULT = true; diff --git a/x-pack/plugins/index_management/common/index.ts b/x-pack/plugins/index_management/common/index.ts index 4ad428744deab..119d4e0c54edd 100644 --- a/x-pack/plugins/index_management/common/index.ts +++ b/x-pack/plugins/index_management/common/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export { PLUGIN, API_BASE_PATH, CREATE_LEGACY_TEMPLATE_BY_DEFAULT, BASE_PATH } from './constants'; +export { PLUGIN, API_BASE_PATH, BASE_PATH } from './constants'; export { getTemplateParameter } from './lib'; diff --git a/x-pack/plugins/index_management/common/lib/index.ts b/x-pack/plugins/index_management/common/lib/index.ts index 4e76a40ced524..6b1005b4faa05 100644 --- a/x-pack/plugins/index_management/common/lib/index.ts +++ b/x-pack/plugins/index_management/common/lib/index.ts @@ -7,9 +7,11 @@ export { deserializeDataStream, deserializeDataStreamList } from './data_stream_serialization'; export { - deserializeLegacyTemplateList, + deserializeTemplate, deserializeTemplateList, deserializeLegacyTemplate, + deserializeLegacyTemplateList, + serializeTemplate, serializeLegacyTemplate, } from './template_serialization'; diff --git a/x-pack/plugins/index_management/common/lib/template_serialization.ts b/x-pack/plugins/index_management/common/lib/template_serialization.ts index 249881f668d9f..608a8b8aca294 100644 --- a/x-pack/plugins/index_management/common/lib/template_serialization.ts +++ b/x-pack/plugins/index_management/common/lib/template_serialization.ts @@ -13,7 +13,7 @@ import { const hasEntries = (data: object = {}) => Object.entries(data).length > 0; export function serializeTemplate(templateDeserialized: TemplateDeserialized): TemplateSerialized { - const { version, priority, indexPatterns, template, composedOf } = templateDeserialized; + const { version, priority, indexPatterns, template, composedOf, _meta } = templateDeserialized; return { version, @@ -21,6 +21,7 @@ export function serializeTemplate(templateDeserialized: TemplateDeserialized): T template, index_patterns: indexPatterns, composed_of: composedOf, + _meta, }; } @@ -34,6 +35,7 @@ export function deserializeTemplate( index_patterns: indexPatterns, template = {}, priority, + _meta, composed_of: composedOf, } = templateEs; const { settings } = template; @@ -46,6 +48,7 @@ export function deserializeTemplate( template, ilmPolicy: settings?.index?.lifecycle, composedOf, + _meta, _kbnMeta: { isManaged: Boolean(managedTemplatePrefix && name.startsWith(managedTemplatePrefix)), }, diff --git a/x-pack/plugins/index_management/common/types/templates.ts b/x-pack/plugins/index_management/common/types/templates.ts index 006a2d9dea8f2..14318b5fa2a8d 100644 --- a/x-pack/plugins/index_management/common/types/templates.ts +++ b/x-pack/plugins/index_management/common/types/templates.ts @@ -21,6 +21,7 @@ export interface TemplateSerialized { composed_of?: string[]; version?: number; priority?: number; + _meta?: { [key: string]: any }; } /** @@ -43,6 +44,7 @@ export interface TemplateDeserialized { ilmPolicy?: { name: string; }; + _meta?: { [key: string]: any }; _kbnMeta: { isManaged: boolean; isLegacy?: boolean; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.scss b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.scss new file mode 100644 index 0000000000000..51e8a829e81b1 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.scss @@ -0,0 +1,34 @@ + + +/** + * [1] Will center vertically the empty search result + */ + +$heightHeader: $euiSizeL * 2; + +.componentTemplates { + @include euiBottomShadowFlat; + height: 100%; + + &__header { + height: $heightHeader; + + .euiFormControlLayout { + max-width: initial; + } + } + + &__searchBox { + border-bottom: $euiBorderThin; + box-shadow: none; + max-width: initial; + } + + &__listWrapper { + height: calc(100% - #{$heightHeader}); + + &--is-empty { + display: flex; // [1] + } + } +} diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.tsx new file mode 100644 index 0000000000000..64c7cd400ba0d --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.tsx @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import classNames from 'classnames'; +import React, { useState, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; + +import { ComponentTemplateListItem } from '../../../../../common'; +import { FilterListButton } from './components'; +import { ComponentTemplatesList } from './component_templates_list'; +import { Props as ComponentTemplatesListItemProps } from './component_templates_list_item'; + +import './component_templates.scss'; + +interface Props { + isLoading: boolean; + components: ComponentTemplateListItem[]; + listItemProps: Omit; +} + +interface Filters { + [key: string]: { name: string; checked: 'on' | 'off' }; +} + +function fuzzyMatch(searchValue: string, text: string) { + const pattern = `.*${searchValue.split('').join('.*')}.*`; + const regex = new RegExp(pattern); + return regex.test(text); +} + +const i18nTexts = { + filters: { + settings: i18n.translate( + 'xpack.idxMgmt.componentTemplatesSelector.filters.indexSettingsLabel', + { defaultMessage: 'Index settings' } + ), + mappings: i18n.translate('xpack.idxMgmt.componentTemplatesSelector.filters.mappingsLabel', { + defaultMessage: 'Mappings', + }), + aliases: i18n.translate('xpack.idxMgmt.componentTemplatesSelector.filters.aliasesLabel', { + defaultMessage: 'Aliases', + }), + }, + searchBoxPlaceholder: i18n.translate( + 'xpack.idxMgmt.componentTemplatesSelector.searchBox.placeholder', + { + defaultMessage: 'Search components', + } + ), +}; + +const getInitialFilters = (): Filters => ({ + settings: { + name: i18nTexts.filters.settings, + checked: 'off', + }, + mappings: { + name: i18nTexts.filters.mappings, + checked: 'off', + }, + aliases: { + name: i18nTexts.filters.aliases, + checked: 'off', + }, +}); + +export const ComponentTemplates = ({ isLoading, components, listItemProps }: Props) => { + const [searchValue, setSearchValue] = useState(''); + + const [filters, setFilters] = useState(getInitialFilters); + + const filteredComponents = useMemo(() => { + if (isLoading) { + return []; + } + + return components.filter((component) => { + if (filters.settings.checked === 'on' && !component.hasSettings) { + return false; + } + if (filters.mappings.checked === 'on' && !component.hasMappings) { + return false; + } + if (filters.aliases.checked === 'on' && !component.hasAliases) { + return false; + } + + if (searchValue.trim() === '') { + return true; + } + + const match = fuzzyMatch(searchValue, component.name); + return match; + }); + }, [isLoading, components, searchValue, filters]); + + const isSearchResultEmpty = filteredComponents.length === 0 && components.length > 0; + + if (isLoading) { + return null; + } + + const clearSearch = () => { + setSearchValue(''); + setFilters(getInitialFilters()); + }; + + const renderEmptyResult = () => { + return ( + + + + } + actions={ + + + + } + /> + ); + }; + + return ( +
+
+ + + { + setSearchValue(e.target.value); + }} + aria-label={i18nTexts.searchBoxPlaceholder} + className="componentTemplates__searchBox" + /> + + + + + +
+
+ {isSearchResultEmpty ? ( + renderEmptyResult() + ) : ( + + )} +
+
+ ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_list.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_list.tsx new file mode 100644 index 0000000000000..0c64c38c8963f --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_list.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { ComponentTemplateListItem } from '../../../../../common'; +import { + ComponentTemplatesListItem, + Props as ComponentTemplatesListItemProps, +} from './component_templates_list_item'; + +interface Props { + components: ComponentTemplateListItem[]; + listItemProps: Omit; +} + +export const ComponentTemplatesList = ({ components, listItemProps }: Props) => { + return ( + <> + {components.map((component) => ( + + ))} + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_list_item.scss b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_list_item.scss new file mode 100644 index 0000000000000..b454d8697c5fc --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_list_item.scss @@ -0,0 +1,31 @@ +.componentTemplatesListItem { + background-color: white; + padding: $euiSizeM; + border-bottom: $euiBorderThin; + position: relative; + height: $euiSizeL * 2; + + &--selected { + &::before { + content: ''; + background-color: rgba(255, 255, 255, 0.7); + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + z-index: 1; + } + } + + &__contentIndicator { + flex-direction: row; + } + + &__checkIcon { + position: absolute; + right: $euiSize; + top: $euiSize; + z-index: 2; + } +} diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_list_item.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_list_item.tsx new file mode 100644 index 0000000000000..ad75c8dcbcc54 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_list_item.tsx @@ -0,0 +1,103 @@ +/* + * 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 classNames from 'classnames'; +import React from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonIcon, + EuiLink, + EuiIcon, + EuiToolTip, +} from '@elastic/eui'; + +import { ComponentTemplateListItem } from '../../../../../common'; +import { TemplateContentIndicator } from '../../shared'; + +import './component_templates_list_item.scss'; + +interface Action { + label: string; + icon: string; + handler: (component: ComponentTemplateListItem) => void; +} +export interface Props { + component: ComponentTemplateListItem; + isSelected?: boolean | ((component: ComponentTemplateListItem) => boolean); + onViewDetail: (component: ComponentTemplateListItem) => void; + actions?: Action[]; + dragHandleProps?: { [key: string]: any }; +} + +export const ComponentTemplatesListItem = ({ + component, + onViewDetail, + actions, + isSelected = false, + dragHandleProps, +}: Props) => { + const hasActions = actions && actions.length > 0; + const isSelectedValue = typeof isSelected === 'function' ? isSelected(component) : isSelected; + const isDraggable = Boolean(dragHandleProps); + + return ( +
+ + + + {isDraggable && ( + +
+ +
+
+ )} + + {/* {component.name} */} + onViewDetail(component)}>{component.name} + + + + +
+
+ + {/* Actions */} + {hasActions && !isSelectedValue && ( + + + {actions!.map((action, i) => ( + + + action.handler(component)} + data-test-subj="addPropertyButton" + aria-label={action.label} + /> + + + ))} + + + )} +
+ + {/* Check icon when selected */} + {isSelectedValue && ( + + )} +
+ ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selection.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selection.tsx new file mode 100644 index 0000000000000..0a305eec19180 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selection.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiDragDropContext, EuiDraggable, EuiDroppable, euiDragDropReorder } from '@elastic/eui'; + +import { ComponentTemplateListItem } from '../../../../../common'; +import { + ComponentTemplatesListItem, + Props as ComponentTemplatesListItemProps, +} from './component_templates_list_item'; + +interface DraggableLocation { + droppableId: string; + index: number; +} + +interface Props { + components: ComponentTemplateListItem[]; + onReorder: (components: ComponentTemplateListItem[]) => void; + listItemProps: Omit; +} + +export const ComponentTemplatesSelection = ({ components, onReorder, listItemProps }: Props) => { + const onDragEnd = ({ + source, + destination, + }: { + source?: DraggableLocation; + destination?: DraggableLocation; + }) => { + if (source && destination) { + const items = euiDragDropReorder(components, source.index, destination.index); + onReorder(items); + } + }; + + return ( + + + {components.map((component, idx) => ( + + {(provided) => ( + + )} + + ))} + + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.scss b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.scss new file mode 100644 index 0000000000000..6abbbe65790e7 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.scss @@ -0,0 +1,36 @@ +/* +[1] Height to align left and right column headers +*/ + +.componentTemplatesSelector { + height: 480px; + + &__selection { + @include euiBottomShadowFlat; + + padding: 0 $euiSize $euiSize; + color: $euiColorDarkShade; + + &--is-empty { + align-items: center; + justify-content: center; + } + + &__header { + background-color: $euiColorLightestShade; + border-bottom: $euiBorderThin; + color: $euiColorInk; + height: $euiSizeXXL; // [1] + line-height: $euiSizeXXL; // [1] + font-size: $euiSizeM; + margin-bottom: $euiSizeS; + margin-left: $euiSize * -1; + margin-right: $euiSize * -1; + padding-left: $euiSize; + + &__count { + font-weight: 600; + } + } + } +} diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx new file mode 100644 index 0000000000000..af48c3c79379a --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx @@ -0,0 +1,263 @@ +/* + * 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 classNames from 'classnames'; +import React, { useState, useEffect, useRef } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiEmptyPrompt, EuiLink, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { ComponentTemplateListItem } from '../../../../../common'; +import { SectionError, SectionLoading } from '../shared_imports'; +import { ComponentTemplateDetailsFlyout } from '../component_template_details'; +import { CreateButtonPopOver } from './components'; +import { ComponentTemplates } from './component_templates'; +import { ComponentTemplatesSelection } from './component_templates_selection'; +import { useApi } from '../component_templates_context'; + +import './component_templates_selector.scss'; + +interface Props { + onChange: (components: string[]) => void; + onComponentsLoaded: (components: ComponentTemplateListItem[]) => void; + defaultValue: string[]; + docUri: string; + emptyPrompt?: { + text?: string | JSX.Element; + showCreateButton?: boolean; + }; +} + +const i18nTexts = { + icons: { + view: i18n.translate('xpack.idxMgmt.componentTemplatesSelector.viewItemIconLabel', { + defaultMessage: 'View', + }), + select: i18n.translate('xpack.idxMgmt.componentTemplatesSelector.selectItemIconLabel', { + defaultMessage: 'Select', + }), + remove: i18n.translate('xpack.idxMgmt.componentTemplatesSelector.removeItemIconLabel', { + defaultMessage: 'Remove', + }), + }, +}; + +export const ComponentTemplatesSelector = ({ + onChange, + defaultValue, + onComponentsLoaded, + docUri, + emptyPrompt: { text, showCreateButton } = {}, +}: Props) => { + const { data: components, isLoading, error } = useApi().useLoadComponentTemplates(); + const [selectedComponent, setSelectedComponent] = useState(null); + const [componentsSelected, setComponentsSelected] = useState([]); + const isInitialized = useRef(false); + + const hasSelection = Object.keys(componentsSelected).length > 0; + const hasComponents = components && components.length > 0 ? true : false; + + useEffect(() => { + if (components) { + if ( + defaultValue.length > 0 && + componentsSelected.length === 0 && + isInitialized.current === false + ) { + // Once the components are loaded we check the ones selected + // from the defaultValue provided + const nextComponentsSelected = defaultValue + .map((name) => components.find((comp) => comp.name === name)) + .filter(Boolean) as ComponentTemplateListItem[]; + + setComponentsSelected(nextComponentsSelected); + onChange(nextComponentsSelected.map(({ name }) => name)); + isInitialized.current = true; + } else { + onChange(componentsSelected.map(({ name }) => name)); + } + } + }, [defaultValue, components, componentsSelected, onChange]); + + useEffect(() => { + if (!isLoading && !error) { + onComponentsLoaded(components ?? []); + } + }, [isLoading, error, components, onComponentsLoaded]); + + const onSelectionReorder = (reorderedComponents: ComponentTemplateListItem[]) => { + setComponentsSelected(reorderedComponents); + }; + + const renderLoading = () => ( + + + + ); + + const renderError = () => ( + + } + error={error!} + /> + ); + + const renderSelector = () => ( + + {/* Selection */} + + {hasSelection ? ( + <> +
+ + {componentsSelected.length} + + ), + }} + /> +
+
+ { + setSelectedComponent(component.name); + }, + actions: [ + { + label: i18nTexts.icons.remove, + icon: 'minusInCircle', + handler: (component: ComponentTemplateListItem) => { + setComponentsSelected((prev) => { + return prev.filter(({ name }) => component.name !== name); + }); + }, + }, + ], + }} + /> +
+ + ) : ( +
+ +
+ )} +
+ + {/* List of components */} + + { + setSelectedComponent(component.name); + }, + actions: [ + { + label: i18nTexts.icons.select, + icon: 'plusInCircle', + handler: (component: ComponentTemplateListItem) => { + setComponentsSelected((prev) => { + return [...prev, component]; + }); + }, + }, + ], + isSelected: (component: ComponentTemplateListItem) => { + return componentsSelected.find(({ name }) => component.name === name) !== undefined; + }, + }} + /> + +
+ ); + + const renderComponentDetails = () => { + if (!selectedComponent) { + return null; + } + + return ( + setSelectedComponent(null)} + componentTemplateName={selectedComponent} + /> + ); + }; + + if (isLoading) { + return renderLoading(); + } else if (error) { + return renderError(); + } else if (hasComponents) { + return ( + <> + {renderSelector()} + {renderComponentDetails()} + + ); + } + + // No components: render empty prompt + const emptyPromptBody = ( + +

+ {text ?? ( + + )} +
+ + + +

+
+ ); + return ( + + + + } + body={emptyPromptBody} + actions={showCreateButton ? : undefined} + data-test-subj="emptyPrompt" + /> + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/components/create_button_popover.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/components/create_button_popover.tsx new file mode 100644 index 0000000000000..941e8ec362de2 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/components/create_button_popover.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { EuiPopover, EuiButton, EuiContextMenu } from '@elastic/eui'; + +interface Props { + anchorPosition?: 'upCenter' | 'downCenter'; +} + +export const CreateButtonPopOver = ({ anchorPosition = 'upCenter' }: Props) => { + const [isPopoverOpen, setIsPopOverOpen] = useState(false); + + return ( + setIsPopOverOpen((prev) => !prev)} + > + + + } + isOpen={isPopoverOpen} + closePopover={() => setIsPopOverOpen(false)} + panelPaddingSize="none" + withTitle + anchorPosition={anchorPosition} + repositionOnScroll + > + { + // console.log('Create component template...'); + }, + }, + { + name: i18n.translate( + 'xpack.idxMgmt.componentTemplatesFlyout.createComponentTemplateFromExistingButtonLabel', + { + defaultMessage: 'From existing index template', + } + ), + icon: 'symlink', + onClick: () => { + // console.log('Create component template from index template...'); + }, + }, + ], + }, + ]} + /> + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/components/filter_list_button.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/components/filter_list_button.tsx new file mode 100644 index 0000000000000..7236a385a704e --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/components/filter_list_button.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFilterButton, EuiPopover, EuiFilterSelectItem } from '@elastic/eui'; + +interface Filter { + name: string; + checked: 'on' | 'off'; +} + +interface Props { + filters: Filters; + onChange(filters: Filters): void; +} + +export interface Filters { + [key: string]: Filter; +} + +export function FilterListButton({ onChange, filters }: Props) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const activeFilters = Object.values(filters).filter((v) => (v as Filter).checked === 'on'); + + const onButtonClick = () => { + setIsPopoverOpen(!isPopoverOpen); + }; + + const closePopover = () => { + setIsPopoverOpen(false); + }; + + const toggleFilter = (filter: string) => { + const previousValue = filters[filter].checked; + const nextValue = previousValue === 'on' ? 'off' : 'on'; + + onChange({ + ...filters, + [filter]: { + ...filters[filter], + checked: nextValue, + }, + }); + }; + + const button = ( + 0} + numActiveFilters={activeFilters.length} + data-test-subj="viewButton" + > + + + ); + + return ( + +
+ {Object.entries(filters).map(([filter, item], index) => ( + toggleFilter(filter)} + data-test-subj="filterItem" + > + {(item as Filter).name} + + ))} +
+
+ ); +} diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/components/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/components/index.ts new file mode 100644 index 0000000000000..999b2e64cf133 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/components/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 * from './create_button_popover'; +export * from './filter_list_button'; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/index.ts new file mode 100644 index 0000000000000..261a3d50d4626 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/index.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 { ComponentTemplatesSelector } from './component_templates_selector'; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx index c78d24f126e29..bfea8d39e1203 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx @@ -61,3 +61,5 @@ export const useComponentTemplatesContext = () => { } return ctx; }; + +export const useApi = () => useComponentTemplatesContext().api; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/index.ts index 72e79a57ae413..52235502e33df 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/index.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/index.ts @@ -9,3 +9,5 @@ export { ComponentTemplatesProvider } from './component_templates_context'; export { ComponentTemplateList } from './component_template_list'; export { ComponentTemplateDetailsFlyout } from './component_template_details'; + +export * from './component_template_selector'; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/lib/api.ts b/x-pack/plugins/index_management/public/application/components/component_templates/lib/api.ts index 4a8cf965adfb9..63fe127c6b2d7 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/lib/api.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/lib/api.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ComponentTemplateListItem, ComponentTemplateDeserialized } from '../shared_imports'; +import { ComponentTemplateListItem, ComponentTemplateDeserialized, Error } from '../shared_imports'; import { UIM_COMPONENT_TEMPLATE_DELETE_MANY, UIM_COMPONENT_TEMPLATE_DELETE } from '../constants'; import { UseRequestHook, SendRequestHook } from './request'; @@ -15,7 +15,7 @@ export const getApi = ( trackMetric: (type: 'loaded' | 'click' | 'count', eventName: string) => void ) => { function useLoadComponentTemplates() { - return useRequest({ + return useRequest({ path: `${apiBasePath}/component_templates`, method: 'get', }); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/lib/request.ts b/x-pack/plugins/index_management/public/application/components/component_templates/lib/request.ts index 97ffa4d875ecb..27ee2bb81caf1 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/lib/request.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/lib/request.ts @@ -15,13 +15,15 @@ import { useRequest as _useRequest, } from '../shared_imports'; -export type UseRequestHook = (config: UseRequestConfig) => UseRequestResponse; +export type UseRequestHook = ( + config: UseRequestConfig +) => UseRequestResponse; export type SendRequestHook = (config: SendRequestConfig) => Promise; -export const getUseRequest = (httpClient: HttpSetup): UseRequestHook => ( +export const getUseRequest = (httpClient: HttpSetup): UseRequestHook => ( config: UseRequestConfig ) => { - return _useRequest(httpClient, config); + return _useRequest(httpClient, config); }; export const getSendRequest = (httpClient: HttpSetup): SendRequestHook => ( diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts b/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts index 4e56f4a8c9818..bd19c2004894c 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts @@ -18,6 +18,7 @@ export { Error, useAuthorizationContext, NotAuthorizedSection, + Forms, } from '../../../../../../../src/plugins/es_ui_shared/public'; export { TabMappings, TabSettings, TabAliases } from '../shared'; diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/index.ts b/x-pack/plugins/index_management/public/application/components/shared/components/index.ts index b67a9c355e723..b0a76b828449c 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/index.ts +++ b/x-pack/plugins/index_management/public/application/components/shared/components/index.ts @@ -12,3 +12,5 @@ export { StepSettingsContainer, CommonWizardSteps, } from './wizard_steps'; + +export { TemplateContentIndicator } from './template_content_indicator'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/components/template_content_indicator.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/template_content_indicator.tsx similarity index 100% rename from x-pack/plugins/index_management/public/application/sections/home/template_list/components/template_content_indicator.tsx rename to x-pack/plugins/index_management/public/application/components/shared/components/template_content_indicator.tsx diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx index 0d28ec4b50c9a..d71d72d873c8e 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx @@ -23,13 +23,13 @@ import { Forms } from '../../../../../shared_imports'; import { useJsonStep } from './use_json_step'; interface Props { - defaultValue: { [key: string]: any }; + defaultValue?: { [key: string]: any }; onChange: (content: Forms.Content) => void; esDocsBase: string; } export const StepAliases: React.FunctionComponent = React.memo( - ({ defaultValue, onChange, esDocsBase }) => { + ({ defaultValue = {}, onChange, esDocsBase }) => { const { jsonContent, setJsonContent, error } = useJsonStep({ defaultValue, onChange, diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases_container.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases_container.tsx index a5953ea00a106..c8297e6f298b6 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases_container.tsx @@ -14,7 +14,7 @@ interface Props { } export const StepAliasesContainer: React.FunctionComponent = ({ esDocsBase }) => { - const { defaultValue, updateContent } = Forms.useContent('aliases'); + const { defaultValue, updateContent } = Forms.useContent('aliases'); return ( diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings.tsx index 2b9b689e17cb9..bbf7a04080a28 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings.tsx @@ -24,14 +24,14 @@ import { } from '../../../mappings_editor'; interface Props { - defaultValue: { [key: string]: any }; onChange: (content: Forms.Content) => void; - indexSettings?: IndexSettings; esDocsBase: string; + defaultValue?: { [key: string]: any }; + indexSettings?: IndexSettings; } export const StepMappings: React.FunctionComponent = React.memo( - ({ defaultValue, onChange, indexSettings, esDocsBase }) => { + ({ defaultValue = {}, onChange, indexSettings, esDocsBase }) => { const [mappings, setMappings] = useState(defaultValue); const onMappingsEditorUpdate = useCallback( diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx index 34e05d88c651d..38c4a85bbe0ff 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx @@ -14,7 +14,9 @@ interface Props { } export const StepMappingsContainer: React.FunctionComponent = ({ esDocsBase }) => { - const { defaultValue, updateContent, getData } = Forms.useContent('mappings'); + const { defaultValue, updateContent, getData } = Forms.useContent( + 'mappings' + ); return ( void; esDocsBase: string; + defaultValue?: { [key: string]: any }; } export const StepSettings: React.FunctionComponent = React.memo( - ({ defaultValue, onChange, esDocsBase }) => { + ({ defaultValue = {}, onChange, esDocsBase }) => { const { jsonContent, setJsonContent, error } = useJsonStep({ defaultValue, onChange, diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings_container.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings_container.tsx index c540ddceb95c2..42be2c4b28c10 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings_container.tsx @@ -14,7 +14,9 @@ interface Props { } export const StepSettingsContainer = React.memo(({ esDocsBase }: Props) => { - const { defaultValue, updateContent } = Forms.useContent('settings'); + const { defaultValue, updateContent } = Forms.useContent( + 'settings' + ); return ( diff --git a/x-pack/plugins/index_management/public/application/components/shared/index.ts b/x-pack/plugins/index_management/public/application/components/shared/index.ts index 897e86c99eca0..9b0eeb7d18f6e 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/index.ts +++ b/x-pack/plugins/index_management/public/application/components/shared/index.ts @@ -12,4 +12,5 @@ export { StepMappingsContainer, StepSettingsContainer, CommonWizardSteps, + TemplateContentIndicator, } from './components'; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/index.ts b/x-pack/plugins/index_management/public/application/components/template_form/steps/index.ts index b7e3e36e61814..d8baca2db78a0 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/index.ts +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/index.ts @@ -5,4 +5,5 @@ */ export { StepLogisticsContainer } from './step_logistics_container'; +export { StepComponentContainer } from './step_components_container'; export { StepReviewContainer } from './step_review_container'; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components.tsx new file mode 100644 index 0000000000000..01771f40f89ea --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState, useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiButtonEmpty, +} from '@elastic/eui'; + +import { ComponentTemplateListItem } from '../../../../../common'; +import { Forms } from '../../../../shared_imports'; +import { ComponentTemplatesSelector } from '../../component_templates'; + +interface Props { + esDocsBase: string; + onChange: (content: Forms.Content) => void; + defaultValue?: string[]; +} + +const i18nTexts = { + description: ( + + ), +}; + +export const StepComponents = ({ defaultValue = [], onChange, esDocsBase }: Props) => { + const [state, setState] = useState<{ + isLoadingComponents: boolean; + components: ComponentTemplateListItem[]; + }>({ isLoadingComponents: true, components: [] }); + + const onComponentsLoaded = useCallback((components: ComponentTemplateListItem[]) => { + setState({ isLoadingComponents: false, components }); + }, []); + + const onComponentSelectionChange = useCallback( + (components: string[]) => { + onChange({ isValid: true, validate: async () => true, getData: () => components }); + }, + [onChange] + ); + + const showHeader = state.isLoadingComponents === true || state.components.length > 0; + const docUri = `${esDocsBase}/indices-component-template.html`; + + const renderHeader = () => { + if (!showHeader) { + return null; + } + + return ( + <> + + + +

+ +

+
+ + + + +

{i18nTexts.description}

+
+
+ + + + + + +
+ + + + ); + }; + + return ( +
+ {renderHeader()} + + +
+ ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components_container.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components_container.tsx new file mode 100644 index 0000000000000..b9b09bf0e3d9a --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components_container.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +import { Forms } from '../../../../shared_imports'; +import { documentationService } from '../../../services/documentation'; +import { WizardContent } from '../template_form'; +import { StepComponents } from './step_components'; + +export const StepComponentContainer = () => { + const { defaultValue, updateContent } = Forms.useContent( + 'components' + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx index d011b4b06546a..44ec4db0873f3 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx @@ -8,7 +8,15 @@ import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiButtonEmpty, EuiSpacer } from ' import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { useForm, Form, getUseField, getFormRow, Field, Forms } from '../../../../shared_imports'; +import { + useForm, + Form, + getUseField, + getFormRow, + Field, + Forms, + JsonEditorField, +} from '../../../../shared_imports'; import { documentationService } from '../../../services/documentation'; import { schemas, nameConfig, nameConfigWithoutValidations } from '../template_form_schemas'; @@ -47,6 +55,15 @@ const fieldsMeta = { }), testSubject: 'orderField', }, + priority: { + title: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.priorityTitle', { + defaultMessage: 'Merge priority', + }), + description: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.priorityDescription', { + defaultMessage: 'The merge priority when multiple templates match an index.', + }), + testSubject: 'priorityField', + }, version: { title: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.versionTitle', { defaultMessage: 'Version', @@ -62,20 +79,26 @@ interface Props { defaultValue: { [key: string]: any }; onChange: (content: Forms.Content) => void; isEditing?: boolean; + isLegacy?: boolean; } export const StepLogistics: React.FunctionComponent = React.memo( - ({ defaultValue, isEditing, onChange }) => { + ({ defaultValue, isEditing = false, onChange, isLegacy = false }) => { const { form } = useForm({ schema: schemas.logistics, defaultValue, options: { stripEmptyFields: false }, }); + /** + * When the consumer call validate() on this step, we submit the form so it enters the "isSubmitted" state + * and we can display the form errors on top of the forms if there are any. + */ + const validate = async () => { + return (await form.submit()).isValid; + }; + useEffect(() => { - const validate = async () => { - return (await form.submit()).isValid; - }; onChange({ isValid: form.isValid, validate, @@ -83,10 +106,22 @@ export const StepLogistics: React.FunctionComponent = React.memo( }); }, [form.isValid, onChange]); // eslint-disable-line react-hooks/exhaustive-deps - const { name, indexPatterns, order, version } = fieldsMeta; + useEffect(() => { + const subscription = form.subscribe(({ data, isValid }) => { + onChange({ + isValid, + validate, + getData: data.format, + }); + }); + return subscription.unsubscribe; + }, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps + + const { name, indexPatterns, order, priority, version } = fieldsMeta; return ( -
+ <> + {/* Header */} @@ -114,46 +149,106 @@ export const StepLogistics: React.FunctionComponent = React.memo( + - {/* Name */} - - - - {/* Index patterns */} - - - - {/* Order */} - - - - {/* Version */} - - - - + +
+ {/* Name */} + + + + + {/* Index patterns */} + + + + + {/* Order */} + {isLegacy && ( + + + + )} + + {/* Priority */} + {isLegacy === false && ( + + + + )} + + {/* Version */} + + + + + {/* _meta */} + {isLegacy === false && ( + + + + } + > + + + )} +
+ ); } ); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics_container.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics_container.tsx index 867ecff799858..68a3419499088 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics_container.tsx @@ -10,13 +10,19 @@ import { WizardContent } from '../template_form'; import { StepLogistics } from './step_logistics'; interface Props { + isLegacy?: boolean; isEditing?: boolean; } -export const StepLogisticsContainer = ({ isEditing = false }: Props) => { - const { defaultValue, updateContent } = Forms.useContent('logistics'); +export const StepLogisticsContainer = ({ isEditing, isLegacy }: Props) => { + const { defaultValue, updateContent } = Forms.useContent('logistics'); return ( - + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx index ab49736d8c0bb..5d0eab93c4f02 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx @@ -22,10 +22,7 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { serializers } from '../../../../shared_imports'; -import { - serializeLegacyTemplate, - serializeTemplate, -} from '../../../../../common/lib/template_serialization'; +import { serializeLegacyTemplate, serializeTemplate } from '../../../../../common/lib'; import { TemplateDeserialized, getTemplateParameter } from '../../../../../common'; import { doMappingsHaveType } from '../../mappings_editor/lib'; import { WizardSection } from '../template_form'; @@ -67,6 +64,9 @@ export const StepReview: React.FunctionComponent = React.memo( indexPatterns, version, order, + priority, + composedOf, + _meta, _kbnMeta: { isLegacy }, } = template!; @@ -97,6 +97,7 @@ export const StepReview: React.FunctionComponent = React.memo( + {/* Index patterns */} = React.memo( )} - - - - - {order ? order : } - + {/* Priority / Order */} + {isLegacy ? ( + <> + + + + + {order ? order : } + + + ) : ( + <> + + + + + {priority ? priority : } + + + )} + {/* Version */} = React.memo( {version ? version : } + + {/* components */} + {isLegacy !== true && ( + <> + + + + + {composedOf && composedOf.length > 0 ? ( + composedOf.length > 1 ? ( + +
    + {composedOf.map((component: string, i: number) => { + return ( +
  • + + {component} + +
  • + ); + })} +
+
+ ) : ( + composedOf.toString() + ) + ) : ( + + )} +
+ + )}
+ {/* Index settings */} = React.memo( {getDescriptionText(serializedSettings)} + + {/* Mappings */} = React.memo( {getDescriptionText(serializedMappings)} + + {/* Aliases */} = React.memo( {getDescriptionText(serializedAliases)} + + {/* Metadata (optional) */} + {isLegacy !== true && _meta && ( + <> + + + + + {JSON.stringify(_meta, null, 2)} + + + )}
@@ -183,7 +257,8 @@ export const StepReview: React.FunctionComponent = React.memo( const RequestTab = () => { const includeTypeName = doMappingsHaveType(template!.template.mappings); - const endpoint = `PUT _template/${name || ''}${ + const esApiEndpoint = isLegacy ? '_template' : '_index_template'; + const endpoint = `PUT ${esApiEndpoint}/${name || ''}${ includeTypeName ? '?include_type_name' : '' }`; const templateString = JSON.stringify(serializedTemplate, null, 2); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx index 8a2c991aea8d0..269ad94251074 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx @@ -8,10 +8,10 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiSpacer } from '@elastic/eui'; -import { TemplateDeserialized, CREATE_LEGACY_TEMPLATE_BY_DEFAULT } from '../../../../common'; +import { TemplateDeserialized } from '../../../../common'; import { serializers, Forms } from '../../../shared_imports'; import { SectionError } from '../section_error'; -import { StepLogisticsContainer, StepReviewContainer } from './steps'; +import { StepLogisticsContainer, StepComponentContainer, StepReviewContainer } from './steps'; import { CommonWizardSteps, StepSettingsContainer, @@ -28,12 +28,14 @@ interface Props { clearSaveError: () => void; isSaving: boolean; saveError: any; + isLegacy?: boolean; defaultValue?: TemplateDeserialized; isEditing?: boolean; } export interface WizardContent extends CommonWizardSteps { logistics: Omit; + components: TemplateDeserialized['composedOf']; } export type WizardSection = keyof WizardContent | 'review'; @@ -45,6 +47,12 @@ const wizardSections: { [id: string]: { id: WizardSection; label: string } } = { defaultMessage: 'Logistics', }), }, + components: { + id: 'components', + label: i18n.translate('xpack.idxMgmt.templateForm.steps.componentsStepName', { + defaultMessage: 'Components', + }), + }, settings: { id: 'settings', label: i18n.translate('xpack.idxMgmt.templateForm.steps.settingsStepName', { @@ -72,9 +80,18 @@ const wizardSections: { [id: string]: { id: WizardSection; label: string } } = { }; export const TemplateForm = ({ - defaultValue = { + defaultValue, + isEditing, + isSaving, + isLegacy = false, + saveError, + clearSaveError, + onSave, +}: Props) => { + const indexTemplate = defaultValue ?? { name: '', indexPatterns: [], + composedOf: [], template: { settings: {}, mappings: {}, @@ -82,26 +99,23 @@ export const TemplateForm = ({ }, _kbnMeta: { isManaged: false, - isLegacy: CREATE_LEGACY_TEMPLATE_BY_DEFAULT, + isLegacy, }, - }, - isEditing, - isSaving, - saveError, - clearSaveError, - onSave, -}: Props) => { + }; + const { template: { settings, mappings, aliases }, + composedOf, _kbnMeta, ...logistics - } = defaultValue; + } = indexTemplate; const wizardDefaultValue: WizardContent = { logistics, settings, mappings, aliases, + components: indexTemplate.composedOf, }; const i18nTexts = { @@ -139,6 +153,7 @@ export const TemplateForm = ({ ): TemplateDeserialized => ({ ...initialTemplate, ...wizardData.logistics, + composedOf: wizardData.components, template: { settings: wizardData.settings, mappings: wizardData.mappings, @@ -148,7 +163,7 @@ export const TemplateForm = ({ const onSaveTemplate = useCallback( async (wizardData: WizardContent) => { - const template = buildTemplateObject(defaultValue)(wizardData); + const template = buildTemplateObject(indexTemplate)(wizardData); // We need to strip empty string, otherwise if the "order" or "version" // are not set, they will be empty string and ES expect a number for those parameters. @@ -160,7 +175,7 @@ export const TemplateForm = ({ clearSaveError(); }, - [defaultValue, onSave, clearSaveError] + [indexTemplate, onSave, clearSaveError] ); return ( @@ -177,9 +192,15 @@ export const TemplateForm = ({ label={wizardSections.logistics.label} isRequired > - + + {indexTemplate._kbnMeta.isLegacy !== true && ( + + + + )} + @@ -193,7 +214,7 @@ export const TemplateForm = ({ - + ); diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form_schemas.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form_schemas.tsx index 9ff73b71adf50..5af3b4dd00c4f 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form_schemas.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form_schemas.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCode } from '@elastic/eui'; import { FormSchema, @@ -28,6 +29,7 @@ const { startsWithField, indexPatternField, lowerCaseStringField, + isJsonField, } = fieldValidators; const { toInt } = fieldFormatters; const indexPatternInvalidCharacters = INVALID_INDEX_PATTERN_CHARS.join(' '); @@ -133,6 +135,13 @@ export const schemas: Record = { }), formatters: [toInt], }, + priority: { + type: FIELD_TYPES.NUMBER, + label: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.fieldPriorityLabel', { + defaultMessage: 'Priority (optional)', + }), + formatters: [toInt], + }, version: { type: FIELD_TYPES.NUMBER, label: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.fieldVersionLabel', { @@ -140,5 +149,43 @@ export const schemas: Record = { }), formatters: [toInt], }, + _meta: { + label: i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.metaFieldEditorLabel', { + defaultMessage: '_meta field data (optional)', + }), + helpText: ( + {JSON.stringify({ arbitrary_data: 'anything_goes' })}, + }} + /> + ), + validations: [ + { + validator: isJsonField( + i18n.translate('xpack.idxMgmt.templateForm.stepLogistics.metaFieldEditorJsonError', { + defaultMessage: 'The _meta field JSON is not valid.', + }), + { allowEmptyString: true } + ), + }, + ], + deserializer: (value: any) => { + if (value === '') { + return value; + } + return JSON.stringify(value, null, 2); + }, + serializer: (value: string) => { + try { + return JSON.parse(value); + } catch (error) { + // swallow error and return non-parsed value; + return value; + } + }, + }, }, }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts b/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts index dcaba319bb21a..156d792c26f1d 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts @@ -5,4 +5,3 @@ */ export * from './filter_list_button'; -export * from './template_content_indicator'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_details/template_details.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_details/template_details.tsx index ab4ce6a61a9b6..f85b14ea0d2d5 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_details/template_details.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_details/template_details.tsx @@ -46,7 +46,7 @@ import { TabSummary } from '../../template_details/tabs'; interface Props { template: { name: string; isLegacy?: boolean }; onClose: () => void; - editTemplate: (name: string, isLegacy?: boolean) => void; + editTemplate: (name: string, isLegacy: boolean) => void; cloneTemplate: (name: string, isLegacy?: boolean) => void; reload: () => Promise; } @@ -290,7 +290,7 @@ export const LegacyTemplateDetails: React.FunctionComponent = ({ } ), icon: 'pencil', - onClick: () => editTemplate(templateName, isLegacy), + onClick: () => editTemplate(templateName, true), disabled: isManaged, }, { diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_table/template_table.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_table/template_table.tsx index edce05018ce39..99915c2b70e2a 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_table/template_table.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/legacy_templates/template_table/template_table.tsx @@ -19,7 +19,7 @@ import { useServices } from '../../../../../app_context'; interface Props { templates: TemplateListItem[]; reload: () => Promise; - editTemplate: (name: string, isLegacy?: boolean) => void; + editTemplate: (name: string, isLegacy: boolean) => void; cloneTemplate: (name: string, isLegacy?: boolean) => void; history: ScopedHistory; } @@ -150,8 +150,8 @@ export const LegacyTemplateTable: React.FunctionComponent = ({ ), icon: 'pencil', type: 'icon', - onClick: ({ name, _kbnMeta: { isLegacy } }: TemplateListItem) => { - editTemplate(name, isLegacy); + onClick: ({ name }: TemplateListItem) => { + editTemplate(name, true); }, enabled: ({ _kbnMeta: { isManaged } }: TemplateListItem) => !isManaged, }, @@ -252,7 +252,10 @@ export const LegacyTemplateTable: React.FunctionComponent = ({ iconType="plusInCircle" data-test-subj="createLegacyTemplateButton" key="createTemplateButton" - {...reactRouterNavigate(history, '/create_template')} + {...reactRouterNavigate(history, { + pathname: '/create_template', + search: 'legacy=true', + })} > - + ) : null; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_table/template_table.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_table/template_table.tsx index 7c3f8c07a7e04..6a5328f76fb06 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_table/template_table.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_table/template_table.tsx @@ -7,18 +7,27 @@ import React, { useState, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiInMemoryTable, EuiBasicTableColumn } from '@elastic/eui'; +import { EuiInMemoryTable, EuiBasicTableColumn, EuiButton } from '@elastic/eui'; +import { ScopedHistory } from 'kibana/public'; + import { TemplateListItem } from '../../../../../../common'; import { TemplateDeleteModal } from '../../../../components'; -import { SendRequestResponse } from '../../../../../shared_imports'; -import { TemplateContentIndicator } from '../components'; +import { SendRequestResponse, reactRouterNavigate } from '../../../../../shared_imports'; +import { TemplateContentIndicator } from '../../../../components/shared'; interface Props { templates: TemplateListItem[]; reload: () => Promise; + editTemplate: (name: string) => void; + history: ScopedHistory; } -export const TemplateTable: React.FunctionComponent = ({ templates, reload }) => { +export const TemplateTable: React.FunctionComponent = ({ + templates, + reload, + history, + editTemplate, +}) => { const [templatesToDelete, setTemplatesToDelete] = useState< Array<{ name: string; isLegacy?: boolean }> >([]); @@ -80,13 +89,11 @@ export const TemplateTable: React.FunctionComponent = ({ templates, reloa sortable: true, }, { - field: 'hasMappings', name: i18n.translate('xpack.idxMgmt.templateList.table.overridesColumnTitle', { defaultMessage: 'Overrides', }), truncateText: true, - sortable: false, - render: (_, item) => ( + render: (item: TemplateListItem) => ( = ({ templates, reloa /> ), }, + { + name: i18n.translate('xpack.idxMgmt.templateList.table.actionColumnTitle', { + defaultMessage: 'Actions', + }), + actions: [ + { + name: i18n.translate('xpack.idxMgmt.templateList.table.actionEditText', { + defaultMessage: 'Edit', + }), + isPrimary: true, + description: i18n.translate('xpack.idxMgmt.templateList.table.actionEditDecription', { + defaultMessage: 'Edit this template', + }), + icon: 'pencil', + type: 'icon', + onClick: ({ name }: TemplateListItem) => { + editTemplate(name); + }, + enabled: ({ _kbnMeta: { isManaged } }: TemplateListItem) => !isManaged, + }, + ], + }, ]; const pagination = { @@ -112,6 +141,20 @@ export const TemplateTable: React.FunctionComponent = ({ templates, reloa box: { incremental: true, }, + toolsRight: [ + + + , + ], }; return ( diff --git a/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx b/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx index f567b9835d53d..fb82f52968eb4 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx @@ -7,6 +7,8 @@ import React, { useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { useLocation } from 'react-router-dom'; +import { parse } from 'query-string'; import { TemplateForm } from '../../components'; import { breadcrumbService } from '../../services/breadcrumbs'; @@ -17,6 +19,8 @@ import { getTemplateDetailsLink } from '../../services/routing'; export const TemplateCreate: React.FunctionComponent = ({ history }) => { const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(null); + const search = parse(useLocation().search.substring(1)); + const isLegacy = Boolean(search.legacy); const onSave = async (template: TemplateDeserialized) => { const { name } = template; @@ -49,10 +53,17 @@ export const TemplateCreate: React.FunctionComponent = ({ h

- + {isLegacy ? ( + + ) : ( + + )}

@@ -61,6 +72,7 @@ export const TemplateCreate: React.FunctionComponent = ({ h isSaving={isSaving} saveError={saveError} clearSaveError={clearSaveError} + isLegacy={isLegacy} />
diff --git a/x-pack/plugins/index_management/public/application/services/routing.ts b/x-pack/plugins/index_management/public/application/services/routing.ts index 2a895196189d0..8831fa2368f47 100644 --- a/x-pack/plugins/index_management/public/application/services/routing.ts +++ b/x-pack/plugins/index_management/public/application/services/routing.ts @@ -16,11 +16,19 @@ export const getTemplateDetailsLink = (name: string, isLegacy?: boolean, withHas }; export const getTemplateEditLink = (name: string, isLegacy?: boolean) => { - return encodeURI(`/edit_template/${encodePathForReactRouter(name)}?legacy=${isLegacy === true}`); + let url = `/edit_template/${encodePathForReactRouter(name)}`; + if (isLegacy) { + url = `${url}?legacy=true`; + } + return encodeURI(url); }; export const getTemplateCloneLink = (name: string, isLegacy?: boolean) => { - return encodeURI(`/clone_template/${encodePathForReactRouter(name)}?legacy=${isLegacy === true}`); + let url = `/clone_template/${encodePathForReactRouter(name)}`; + if (isLegacy) { + url = `${url}?legacy=true`; + } + return encodeURI(url); }; export const decodePathFromReactRouter = (pathname: string): string => { diff --git a/x-pack/plugins/index_management/public/shared_imports.ts b/x-pack/plugins/index_management/public/shared_imports.ts index 69cd07ba6dba0..ad221ae73fecf 100644 --- a/x-pack/plugins/index_management/public/shared_imports.ts +++ b/x-pack/plugins/index_management/public/shared_imports.ts @@ -29,7 +29,11 @@ export { serializers, } from '../../../../src/plugins/es_ui_shared/static/forms/helpers'; -export { getFormRow, Field } from '../../../../src/plugins/es_ui_shared/static/forms/components'; +export { + getFormRow, + Field, + JsonEditorField, +} from '../../../../src/plugins/es_ui_shared/static/forms/components'; export { isJSON } from '../../../../src/plugins/es_ui_shared/static/validators/string'; diff --git a/x-pack/plugins/index_management/server/client/elasticsearch.ts b/x-pack/plugins/index_management/server/client/elasticsearch.ts index 6c0fbe3dd6a65..9f8bce241ae69 100644 --- a/x-pack/plugins/index_management/server/client/elasticsearch.ts +++ b/x-pack/plugins/index_management/server/client/elasticsearch.ts @@ -126,6 +126,20 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'GET', }); + dataManagement.getComposableIndexTemplate = ca({ + urls: [ + { + fmt: '/_index_template/<%=name%>', + req: { + name: { + type: 'string', + }, + }, + }, + ], + method: 'GET', + }); + dataManagement.saveComposableIndexTemplate = ca({ urls: [ { @@ -154,4 +168,18 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) ], method: 'DELETE', }); + + dataManagement.existsTemplate = ca({ + urls: [ + { + fmt: '/_index_template/<%=name%>', + req: { + name: { + type: 'string', + }, + }, + }, + ], + method: 'HEAD', + }); }; diff --git a/x-pack/plugins/index_management/server/routes/api/templates/lib.ts b/x-pack/plugins/index_management/server/routes/api/templates/lib.ts new file mode 100644 index 0000000000000..aae04269c5eb4 --- /dev/null +++ b/x-pack/plugins/index_management/server/routes/api/templates/lib.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { serializeTemplate, serializeLegacyTemplate } from '../../../../common/lib'; +import { TemplateDeserialized, LegacyTemplateSerialized } from '../../../../common'; +import { CallAsCurrentUser } from '../../../types'; + +export const doesTemplateExist = async ({ + name, + callAsCurrentUser, + isLegacy, +}: { + name: string; + callAsCurrentUser: CallAsCurrentUser; + isLegacy?: boolean; +}) => { + if (isLegacy) { + return await callAsCurrentUser('indices.existsTemplate', { name }); + } + return await callAsCurrentUser('dataManagement.existsTemplate', { name }); +}; + +export const saveTemplate = async ({ + template, + callAsCurrentUser, + isLegacy, + include_type_name, +}: { + template: TemplateDeserialized; + callAsCurrentUser: CallAsCurrentUser; + isLegacy?: boolean; + include_type_name?: string; +}) => { + const serializedTemplate = isLegacy + ? serializeLegacyTemplate(template) + : serializeTemplate(template); + + if (isLegacy) { + const { + order, + index_patterns, + version, + settings, + mappings, + aliases, + } = serializedTemplate as LegacyTemplateSerialized; + + return await callAsCurrentUser('indices.putTemplate', { + name: template.name, + order, + include_type_name, + body: { + index_patterns, + version, + settings, + mappings, + aliases, + }, + }); + } + + return await callAsCurrentUser('dataManagement.saveComposableIndexTemplate', { + name: template.name, + body: serializedTemplate, + }); +}; diff --git a/x-pack/plugins/index_management/server/routes/api/templates/register_create_route.ts b/x-pack/plugins/index_management/server/routes/api/templates/register_create_route.ts index 89460ff89aacf..f9fcc9bf3a9c9 100644 --- a/x-pack/plugins/index_management/server/routes/api/templates/register_create_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/templates/register_create_route.ts @@ -8,10 +8,10 @@ import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; import { TemplateDeserialized } from '../../../../common'; -import { serializeLegacyTemplate } from '../../../../common/lib'; import { RouteDependencies } from '../../../types'; import { addBasePath } from '../index'; import { templateSchema } from './validate_schemas'; +import { saveTemplate, doesTemplateExist } from './lib'; const bodySchema = templateSchema; const querySchema = schema.object({ @@ -22,23 +22,18 @@ export function registerCreateRoute({ router, license, lib }: RouteDependencies) router.post( { path: addBasePath('/index_templates'), validate: { body: bodySchema, query: querySchema } }, license.guardApiRoute(async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; + const { callAsCurrentUser } = ctx.dataManagement!.client; const { include_type_name } = req.query as TypeOf; const template = req.body as TemplateDeserialized; const { _kbnMeta: { isLegacy }, } = template; - if (!isLegacy) { - return res.badRequest({ body: 'Only legacy index templates can be created.' }); - } - - const serializedTemplate = serializeLegacyTemplate(template); - const { order, index_patterns, version, settings, mappings, aliases } = serializedTemplate; - // Check that template with the same name doesn't already exist - const templateExists = await callAsCurrentUser('indices.existsTemplate', { + const templateExists = await doesTemplateExist({ name: template.name, + callAsCurrentUser, + isLegacy, }); if (templateExists) { @@ -56,17 +51,11 @@ export function registerCreateRoute({ router, license, lib }: RouteDependencies) try { // Otherwise create new index template - const response = await callAsCurrentUser('indices.putTemplate', { - name: template.name, - order, + const response = await saveTemplate({ + template, + callAsCurrentUser, + isLegacy, include_type_name, - body: { - index_patterns, - version, - settings, - mappings, - aliases, - }, }); return res.ok({ body: response }); diff --git a/x-pack/plugins/index_management/server/routes/api/templates/register_get_routes.ts b/x-pack/plugins/index_management/server/routes/api/templates/register_get_routes.ts index af6c139bcd416..23c8635740c7e 100644 --- a/x-pack/plugins/index_management/server/routes/api/templates/register_get_routes.ts +++ b/x-pack/plugins/index_management/server/routes/api/templates/register_get_routes.ts @@ -6,9 +6,10 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { + deserializeTemplate, + deserializeTemplateList, deserializeLegacyTemplate, deserializeLegacyTemplateList, - deserializeTemplateList, } from '../../../../common/lib'; import { getManagedTemplatePrefix } from '../../../lib/get_managed_templates'; import { RouteDependencies } from '../../../types'; @@ -18,22 +19,21 @@ export function registerGetAllRoute({ router, license }: RouteDependencies) { router.get( { path: addBasePath('/index_templates'), validate: false }, license.guardApiRoute(async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; + const { callAsCurrentUser } = ctx.dataManagement!.client; const managedTemplatePrefix = await getManagedTemplatePrefix(callAsCurrentUser); - const _legacyTemplates = await callAsCurrentUser('indices.getTemplate', { + const legacyTemplatesEs = await callAsCurrentUser('indices.getTemplate', { include_type_name: true, }); - const { index_templates: _templates } = await callAsCurrentUser('transport.request', { - path: '_index_template', - method: 'GET', - }); + const { index_templates: templatesEs } = await callAsCurrentUser( + 'dataManagement.getComposableIndexTemplates' + ); const legacyTemplates = deserializeLegacyTemplateList( - _legacyTemplates, + legacyTemplatesEs, managedTemplatePrefix ); - const templates = deserializeTemplateList(_templates, managedTemplatePrefix); + const templates = deserializeTemplateList(templatesEs, managedTemplatePrefix); const body = { templates, @@ -51,7 +51,7 @@ const paramsSchema = schema.object({ // Require the template format version (V1 or V2) to be provided as Query param const querySchema = schema.object({ - legacy: schema.maybe(schema.boolean()), + legacy: schema.maybe(schema.oneOf([schema.literal('true'), schema.literal('false')])), }); export function registerGetOneRoute({ router, license, lib }: RouteDependencies) { @@ -62,28 +62,40 @@ export function registerGetOneRoute({ router, license, lib }: RouteDependencies) }, license.guardApiRoute(async (ctx, req, res) => { const { name } = req.params as TypeOf; - const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; + const { callAsCurrentUser } = ctx.dataManagement!.client; - const { legacy } = req.query as TypeOf; - - if (!legacy) { - return res.badRequest({ body: 'Only index template version 1 can be fetched.' }); - } + const isLegacy = (req.query as TypeOf).legacy === 'true'; try { const managedTemplatePrefix = await getManagedTemplatePrefix(callAsCurrentUser); - const indexTemplateByName = await callAsCurrentUser('indices.getTemplate', { - name, - include_type_name: true, - }); - if (indexTemplateByName[name]) { - return res.ok({ - body: deserializeLegacyTemplate( - { ...indexTemplateByName[name], name }, - managedTemplatePrefix - ), + if (isLegacy) { + const indexTemplateByName = await callAsCurrentUser('indices.getTemplate', { + name, + include_type_name: true, }); + + if (indexTemplateByName[name]) { + return res.ok({ + body: deserializeLegacyTemplate( + { ...indexTemplateByName[name], name }, + managedTemplatePrefix + ), + }); + } + } else { + const { + index_templates: indexTemplates, + } = await callAsCurrentUser('dataManagement.getComposableIndexTemplate', { name }); + + if (indexTemplates.length > 0) { + return res.ok({ + body: deserializeTemplate( + { ...indexTemplates[0].index_template, name }, + managedTemplatePrefix + ), + }); + } } return res.notFound(); diff --git a/x-pack/plugins/index_management/server/routes/api/templates/register_update_route.ts b/x-pack/plugins/index_management/server/routes/api/templates/register_update_route.ts index 27cd03d80b84b..1458b8709fd27 100644 --- a/x-pack/plugins/index_management/server/routes/api/templates/register_update_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/templates/register_update_route.ts @@ -6,10 +6,10 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { TemplateDeserialized } from '../../../../common'; -import { serializeLegacyTemplate } from '../../../../common/lib'; import { RouteDependencies } from '../../../types'; import { addBasePath } from '../index'; import { templateSchema } from './validate_schemas'; +import { saveTemplate, doesTemplateExist } from './lib'; const bodySchema = templateSchema; const paramsSchema = schema.object({ @@ -26,7 +26,7 @@ export function registerUpdateRoute({ router, license, lib }: RouteDependencies) validate: { body: bodySchema, params: paramsSchema, query: querySchema }, }, license.guardApiRoute(async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.core.elasticsearch.legacy.client; + const { callAsCurrentUser } = ctx.dataManagement!.client; const { name } = req.params as typeof paramsSchema.type; const { include_type_name } = req.query as TypeOf; const template = req.body as TemplateDeserialized; @@ -34,16 +34,8 @@ export function registerUpdateRoute({ router, license, lib }: RouteDependencies) _kbnMeta: { isLegacy }, } = template; - if (!isLegacy) { - return res.badRequest({ body: 'Only legacy index template can be edited.' }); - } - - const serializedTemplate = serializeLegacyTemplate(template); - - const { order, index_patterns, version, settings, mappings, aliases } = serializedTemplate; - // Verify the template exists (ES will throw 404 if not) - const doesExist = await callAsCurrentUser('indices.existsTemplate', { name }); + const doesExist = await doesTemplateExist({ name, callAsCurrentUser, isLegacy }); if (!doesExist) { return res.notFound(); @@ -51,17 +43,11 @@ export function registerUpdateRoute({ router, license, lib }: RouteDependencies) try { // Next, update index template - const response = await callAsCurrentUser('indices.putTemplate', { - name, - order, + const response = await saveTemplate({ + template, + callAsCurrentUser, + isLegacy, include_type_name, - body: { - index_patterns, - version, - settings, - mappings, - aliases, - }, }); return res.ok({ body: response }); diff --git a/x-pack/plugins/index_management/server/routes/api/templates/validate_schemas.ts b/x-pack/plugins/index_management/server/routes/api/templates/validate_schemas.ts index 6ab28e9021123..f82ea8f3cf152 100644 --- a/x-pack/plugins/index_management/server/routes/api/templates/validate_schemas.ts +++ b/x-pack/plugins/index_management/server/routes/api/templates/validate_schemas.ts @@ -11,6 +11,7 @@ export const templateSchema = schema.object({ indexPatterns: schema.arrayOf(schema.string()), version: schema.maybe(schema.number()), order: schema.maybe(schema.number()), + priority: schema.maybe(schema.number()), template: schema.maybe( schema.object({ settings: schema.maybe(schema.object({}, { unknowns: 'allow' })), @@ -18,6 +19,8 @@ export const templateSchema = schema.object({ mappings: schema.maybe(schema.object({}, { unknowns: 'allow' })), }) ), + composedOf: schema.maybe(schema.arrayOf(schema.string())), + _meta: schema.maybe(schema.object({}, { unknowns: 'allow' })), ilmPolicy: schema.maybe( schema.object({ name: schema.maybe(schema.string()), diff --git a/x-pack/test/api_integration/apis/management/index_management/templates.helpers.js b/x-pack/test/api_integration/apis/management/index_management/templates.helpers.js index 292aabad85054..a563b956df344 100644 --- a/x-pack/test/api_integration/apis/management/index_management/templates.helpers.js +++ b/x-pack/test/api_integration/apis/management/index_management/templates.helpers.js @@ -7,50 +7,63 @@ import { API_BASE_PATH, INDEX_PATTERNS } from './constants'; export const registerHelpers = ({ supertest }) => { + let templatesCreated = []; + const getAllTemplates = () => supertest.get(`${API_BASE_PATH}/index_templates`); - const getOneTemplate = (name, isLegacy = true) => + const getOneTemplate = (name, isLegacy = false) => supertest.get(`${API_BASE_PATH}/index_templates/${name}?legacy=${isLegacy}`); - const getTemplatePayload = (name, isLegacy = true) => ({ - name, - order: 1, - indexPatterns: INDEX_PATTERNS, - version: 1, - template: { - settings: { - number_of_shards: 1, - index: { - lifecycle: { - name: 'my_policy', + const getTemplatePayload = (name, indexPatterns = INDEX_PATTERNS, isLegacy = false) => { + const baseTemplate = { + name, + indexPatterns, + version: 1, + template: { + settings: { + number_of_shards: 1, + index: { + lifecycle: { + name: 'my_policy', + }, }, }, - }, - mappings: { - _source: { - enabled: false, - }, - properties: { - host_name: { - type: 'keyword', + mappings: { + _source: { + enabled: false, }, - created_at: { - type: 'date', - format: 'EEE MMM dd HH:mm:ss Z yyyy', + properties: { + host_name: { + type: 'keyword', + }, + created_at: { + type: 'date', + format: 'EEE MMM dd HH:mm:ss Z yyyy', + }, }, }, + aliases: { + alias1: {}, + }, }, - aliases: { - alias1: {}, + _kbnMeta: { + isLegacy, }, - }, - _kbnMeta: { - isLegacy, - }, - }); + }; + + if (isLegacy) { + baseTemplate.order = 1; + } else { + baseTemplate.priority = 1; + } - const createTemplate = (payload) => - supertest.post(`${API_BASE_PATH}/index_templates`).set('kbn-xsrf', 'xxx').send(payload); + return baseTemplate; + }; + + const createTemplate = (template) => { + templatesCreated.push({ name: template.name, isLegacy: template._kbnMeta.isLegacy }); + return supertest.post(`${API_BASE_PATH}/index_templates`).set('kbn-xsrf', 'xxx').send(template); + }; const deleteTemplates = (templates) => supertest @@ -64,6 +77,16 @@ export const registerHelpers = ({ supertest }) => { .set('kbn-xsrf', 'xxx') .send(payload); + // Delete all templates created during tests + const cleanUpTemplates = async () => { + try { + await deleteTemplates(templatesCreated); + templatesCreated = []; + } catch (e) { + // Silently swallow errors + } + }; + return { getAllTemplates, getOneTemplate, @@ -71,5 +94,6 @@ export const registerHelpers = ({ supertest }) => { createTemplate, updateTemplate, deleteTemplates, + cleanUpTemplates, }; }; diff --git a/x-pack/test/api_integration/apis/management/index_management/templates.js b/x-pack/test/api_integration/apis/management/index_management/templates.js index fcee8ed6a183f..3a3d73ab68412 100644 --- a/x-pack/test/api_integration/apis/management/index_management/templates.js +++ b/x-pack/test/api_integration/apis/management/index_management/templates.js @@ -22,33 +22,78 @@ export default function ({ getService }) { getTemplatePayload, deleteTemplates, updateTemplate, + cleanUpTemplates, } = registerHelpers({ supertest }); describe('index templates', () => { - after(() => Promise.all([cleanUpEsResources()])); + after(() => Promise.all([cleanUpEsResources(), cleanUpTemplates()])); describe('get all', () => { const templateName = `template-${getRandomString()}`; - const payload = getTemplatePayload(templateName); + const indexTemplate = getTemplatePayload(templateName, [getRandomString()]); + const legacyTemplate = getTemplatePayload(templateName, [getRandomString()], true); beforeEach(async () => { - await createTemplate(payload).expect(200); + const res1 = await createTemplate(indexTemplate); + if (res1.status !== 200) { + throw new Error(res1.body.message); + } + + const res2 = await createTemplate(legacyTemplate); + if (res2.status !== 200) { + throw new Error(res2.body.message); + } }); - // TODO: When the "Create" API handler is added for V2 template, - // update this test to list composable templates. it('should list all the index templates with the expected parameters', async () => { const { body: allTemplates } = await getAllTemplates().expect(200); - // Composable index templates may have been created by other apps, e.g. Ingest Manager, - // so we don't make any assertion about these contents. - expect(allTemplates.templates).to.be.an('array'); - - // Legacy templates - const legacyTemplate = allTemplates.legacyTemplates.find( - (template) => template.name === payload.name + // Index templates (composable) + const indexTemplateFound = allTemplates.templates.find( + (template) => template.name === indexTemplate.name ); + + if (!indexTemplateFound) { + throw new Error( + `Index template "${indexTemplate.name}" not found in ${JSON.stringify( + allTemplates.templates, + null, + 2 + )}` + ); + } + const expectedKeys = [ + 'name', + 'indexPatterns', + 'hasSettings', + 'hasAliases', + 'hasMappings', + 'ilmPolicy', + 'priority', + 'composedOf', + 'version', + '_kbnMeta', + ].sort(); + + expect(Object.keys(indexTemplateFound).sort()).to.eql(expectedKeys); + + // Legacy index templates + const legacyTemplateFound = allTemplates.legacyTemplates.find( + (template) => template.name === legacyTemplate.name + ); + + if (!legacyTemplateFound) { + throw new Error( + `Legacy template "${legacyTemplate.name}" not found in ${JSON.stringify( + allTemplates.legacyTemplates, + null, + 2 + )}` + ); + } + + const expectedLegacyKeys = [ 'name', 'indexPatterns', 'hasSettings', @@ -60,20 +105,40 @@ export default function ({ getService }) { '_kbnMeta', ].sort(); - expect(Object.keys(legacyTemplate).sort()).to.eql(expectedKeys); + expect(Object.keys(legacyTemplateFound).sort()).to.eql(expectedLegacyKeys); }); }); describe('get one', () => { const templateName = `template-${getRandomString()}`; - const payload = getTemplatePayload(templateName); - beforeEach(async () => { - await createTemplate(payload).expect(200); - }); + it('should return an index template with the expected parameters', async () => { + const template = getTemplatePayload(templateName, [getRandomString()]); + await createTemplate(template).expect(200); - it('should return the index template with the expected parameters', async () => { const { body } = await getOneTemplate(templateName).expect(200); + const expectedKeys = [ + 'name', + 'indexPatterns', + 'template', + 'composedOf', + 'ilmPolicy', + 'priority', + 'version', + '_kbnMeta', + ].sort(); + const expectedTemplateKeys = ['aliases', 'mappings', 'settings'].sort(); + + expect(body.name).to.equal(templateName); + expect(Object.keys(body).sort()).to.eql(expectedKeys); + expect(Object.keys(body.template).sort()).to.eql(expectedTemplateKeys); + }); + + it('should return a legacy index template with the expected parameters', async () => { + const legacyTemplate = getTemplatePayload(templateName, [getRandomString()], true); + await createTemplate(legacyTemplate).expect(200); + + const { body } = await getOneTemplate(templateName, true).expect(200); const expectedKeys = [ 'name', 'indexPatterns', @@ -94,14 +159,21 @@ export default function ({ getService }) { describe('create', () => { it('should create an index template', async () => { const templateName = `template-${getRandomString()}`; - const payload = getTemplatePayload(templateName); + const payload = getTemplatePayload(templateName, [getRandomString()]); + + await createTemplate(payload).expect(200); + }); + + it('should create a legacy index template', async () => { + const templateName = `template-${getRandomString()}`; + const payload = getTemplatePayload(templateName, [getRandomString()], true); await createTemplate(payload).expect(200); }); it('should throw a 409 conflict when trying to create 2 templates with the same name', async () => { const templateName = `template-${getRandomString()}`; - const payload = getTemplatePayload(templateName); + const payload = getTemplatePayload(templateName, [getRandomString()], true); await createTemplate(payload); @@ -110,7 +182,7 @@ export default function ({ getService }) { it('should validate the request payload', async () => { const templateName = `template-${getRandomString()}`; - const payload = getTemplatePayload(templateName); + const payload = getTemplatePayload(templateName, [getRandomString()], true); delete payload.indexPatterns; // index patterns are required @@ -124,13 +196,40 @@ export default function ({ getService }) { describe('update', () => { it('should update an index template', async () => { const templateName = `template-${getRandomString()}`; - const payload = getTemplatePayload(templateName); + const indexTemplate = getTemplatePayload(templateName, [getRandomString()]); - await createTemplate(payload).expect(200); + await createTemplate(indexTemplate).expect(200); + + let catTemplateResponse = await catTemplate(templateName); + + const { name, version } = indexTemplate; + + expect( + catTemplateResponse.find(({ name: templateName }) => templateName === name).version + ).to.equal(version.toString()); + + // Update template with new version + const updatedVersion = 2; + await updateTemplate({ ...indexTemplate, version: updatedVersion }, templateName).expect( + 200 + ); + + catTemplateResponse = await catTemplate(templateName); + + expect( + catTemplateResponse.find(({ name: templateName }) => templateName === name).version + ).to.equal(updatedVersion.toString()); + }); + + it('should update a legacy index template', async () => { + const templateName = `template-${getRandomString()}`; + const legacyIndexTemplate = getTemplatePayload(templateName, [getRandomString()], true); + + await createTemplate(legacyIndexTemplate).expect(200); let catTemplateResponse = await catTemplate(templateName); - const { name, version } = payload; + const { name, version } = legacyIndexTemplate; expect( catTemplateResponse.find(({ name: templateName }) => templateName === name).version @@ -138,7 +237,10 @@ export default function ({ getService }) { // Update template with new version const updatedVersion = 2; - await updateTemplate({ ...payload, version: updatedVersion }, templateName).expect(200); + await updateTemplate( + { ...legacyIndexTemplate, version: updatedVersion }, + templateName + ).expect(200); catTemplateResponse = await catTemplate(templateName); @@ -151,7 +253,7 @@ export default function ({ getService }) { describe('delete', () => { it('should delete an index template', async () => { const templateName = `template-${getRandomString()}`; - const payload = getTemplatePayload(templateName); + const payload = getTemplatePayload(templateName, [getRandomString()], true); await createTemplate(payload).expect(200); diff --git a/x-pack/test_utils/router_helpers.tsx b/x-pack/test_utils/router_helpers.tsx index 76c1e2259545b..f2099e1eb7c91 100644 --- a/x-pack/test_utils/router_helpers.tsx +++ b/x-pack/test_utils/router_helpers.tsx @@ -16,9 +16,10 @@ export const WithMemoryRouter = (initialEntries: string[] = ['/'], initialIndex: ); -export const WithRoute = (componentRoutePath = '/', onRouter = (router: any) => {}) => ( - WrappedComponent: ComponentType -) => { +export const WithRoute = ( + componentRoutePath: string | string[] = '/', + onRouter = (router: any) => {} +) => (WrappedComponent: ComponentType) => { // Create a class component that will catch the router // and forward it to our "onRouter()" handler. const CatchRouter = withRouter( diff --git a/x-pack/test_utils/testbed/types.ts b/x-pack/test_utils/testbed/types.ts index 4975e073eea1f..e2b6693ce77aa 100644 --- a/x-pack/test_utils/testbed/types.ts +++ b/x-pack/test_utils/testbed/types.ts @@ -163,7 +163,7 @@ export interface MemoryRouterConfig { /** The React Router **initial index** setting ([see documentation](https://github.com/ReactTraining/react-router/blob/master/packages/react-router/docs/api/MemoryRouter.md)) */ initialIndex?: number; /** The route **path** for the mounted component (defaults to `"/"`) */ - componentRoutePath?: string; + componentRoutePath?: string | string[]; /** A callBack that will be called with the React Router instance once mounted */ onRouter?: (router: any) => void; } From 2bf52d3391abba23eae06c8c00907fe9c7890fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Fri, 3 Jul 2020 15:05:32 +0100 Subject: [PATCH 05/16] [7.x] [Telemetry] Add documentation about Application Usage (#70624) (#70709) Co-authored-by: Christiane (Tina) Heiligers Co-authored-by: Elastic Machine Co-authored-by: Christiane (Tina) Heiligers Co-authored-by: Elastic Machine --- src/plugins/kibana_usage_collection/README.md | 2 +- .../collectors/application_usage/README.md | 37 +++++++++++++++++++ .../application_usage/saved_objects_types.ts | 13 ++----- 3 files changed, 41 insertions(+), 11 deletions(-) create mode 100644 src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md diff --git a/src/plugins/kibana_usage_collection/README.md b/src/plugins/kibana_usage_collection/README.md index 1aade472c2326..6ef4f19c1570f 100644 --- a/src/plugins/kibana_usage_collection/README.md +++ b/src/plugins/kibana_usage_collection/README.md @@ -2,7 +2,7 @@ This plugin registers the basic usage collectors from Kibana: -- Application Usage +- [Application Usage](./server/collectors/application_usage/README.md) - UI Metrics - Ops stats - Number of Saved Objects per type diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md b/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md new file mode 100644 index 0000000000000..1ffd01fc6fde7 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md @@ -0,0 +1,37 @@ +# Application Usage + +This collector reports the number of general clicks and minutes on screen for each registered application in Kibana. + +The final payload matches the following contract: + +```JSON +{ + "application_usage": { + "application_ID": { + "clicks_7_days": 10, + "clicks_30_days": 100, + "clicks_90_days": 300, + "clicks_total": 600, + "minutes_on_screen_7_days": 10.40, + "minutes_on_screen_30_days": 20.0, + "minutes_on_screen_90_days": 110.1, + "minutes_on_screen_total": 112.5 + } + } +} +``` + +Where `application_ID` matches the `id` registered when calling the method `core.application.register`. +This collection occurs by default for every application registered via the mentioned method and there is no need to do anything else to enable it or _opt-in_ for your plugin. + +**Note to maintainers in the Kibana repo:** At the moment of writing, the `usageCollector.schema` is not updated automatically ([#70622](https://github.com/elastic/kibana/issues/70622)) so, if you are adding a new app to Kibana, you'll need to give the Kibana Telemetry team a heads up to update the mappings in the Telemetry Cluster accordingly. + +## Developer notes + +In order to keep the count of the events, this collector uses 2 Saved Objects: + +1. `application_usage_transactional`: It stores each individually reported event (up to 90 days old). Grouped by `timestamp` and `appId`. +2. `application_usage_totals`: It stores the sum of all the events older than 90 days old per `appId`. + +Both of them use the shared fields `appId: 'keyword'`, `numberOfClicks: 'long'` and `minutesOnScreen: 'float'`. `application_usage_transactional` also stores `timestamp: { type: 'date' }`. +but they are currently not added in the mappings because we don't use them for search purposes, and we need to be thoughtful with the number of mapped fields in the SavedObjects index ([#43673](https://github.com/elastic/kibana/issues/43673)). diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts index 8d6a2d110efe0..551c6e230972e 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts @@ -35,13 +35,10 @@ export function registerMappings(registerType: SavedObjectsServiceSetup['registe hidden: false, namespaceType: 'agnostic', mappings: { + // Not indexing any of its contents because we use them "as-is" and don't search by these fields + // for more info, see the README.md for application_usage dynamic: false, - properties: { - // Disabled the mapping of these fields since they are not searched and we need to reduce the amount of indexed fields (#43673) - // appId: { type: 'keyword' }, - // numberOfClicks: { type: 'long' }, - // minutesOnScreen: { type: 'float' }, - }, + properties: {}, }, }); @@ -53,10 +50,6 @@ export function registerMappings(registerType: SavedObjectsServiceSetup['registe dynamic: false, properties: { timestamp: { type: 'date' }, - // Disabled the mapping of these fields since they are not searched and we need to reduce the amount of indexed fields (#43673) - // appId: { type: 'keyword' }, - // numberOfClicks: { type: 'long' }, - // minutesOnScreen: { type: 'float' }, }, }, }); From 535aff77bf376300a0570bd3537c2e79e3a90c67 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Fri, 3 Jul 2020 09:03:03 -0700 Subject: [PATCH 06/16] [Ingest Manager] Add ability to sort to agent configs and package configs (#70676) (#70686) * Add sorting params to list endpoints; allow sorting on agent config and package config tables; normalize casing of 'desc' and 'asc' * Fix es archiver data * Fix tests --- .../common/types/rest_spec/common.ts | 6 ++-- .../ingest_manager/hooks/index.ts | 1 + .../ingest_manager/hooks/use_sorting.tsx | 16 +++++++++ .../step_select_config.tsx | 7 +++- .../package_configs/package_configs_table.tsx | 2 ++ .../sections/agent_config/list_page/index.tsx | 32 ++++++++++++----- .../agent_reassign_config_flyout/index.tsx | 5 ++- .../server/saved_objects/index.ts | 3 +- .../server/services/agent_config.ts | 5 +-- .../server/services/agents/crud.ts | 36 +++++++------------ .../server/services/agents/events.ts | 2 +- .../server/services/agents/status.ts | 2 +- .../services/api_keys/enrollment_api_key.ts | 2 +- .../server/services/package_config.ts | 4 ++- .../server/types/rest_spec/common.ts | 6 ++-- .../es_archives/fleet/agents/data.json | 3 +- 16 files changed, 83 insertions(+), 49 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_sorting.tsx diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/common.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/common.ts index c52471ccfb4f5..0d1f72afa16f1 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/common.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/common.ts @@ -5,7 +5,9 @@ */ export interface ListWithKuery { - page: number; - perPage: number; + page?: number; + perPage?: number; + sortField?: string; + sortOrder?: 'desc' | 'asc'; kuery?: string; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts index 6ebfd3f28fd9b..36b7d412bf276 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts @@ -13,6 +13,7 @@ export { useLink } from './use_link'; export { useKibanaLink } from './use_kibana_link'; export { usePackageIconType, UsePackageIconType } from './use_package_icon_type'; export { usePagination, Pagination } from './use_pagination'; +export { useSorting } from './use_sorting'; export { useDebounce } from './use_debounce'; export * from './use_request'; export * from './use_input'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_sorting.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_sorting.tsx new file mode 100644 index 0000000000000..b00809249897b --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_sorting.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { useState } from 'react'; +import { CriteriaWithPagination } from '@elastic/eui/src/components/basic_table/basic_table'; + +export function useSorting(defaultSorting: CriteriaWithPagination['sort']) { + const [sorting, setSorting] = useState['sort']>(defaultSorting); + + return { + sorting, + setSorting, + }; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_config.tsx index 70668c2856f98..849d7bfc63f34 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_config.tsx @@ -31,7 +31,12 @@ export const StepSelectConfig: React.FunctionComponent<{ data: agentConfigsData, error: agentConfigsError, isLoading: isAgentConfigsLoading, - } = useGetAgentConfigs(); + } = useGetAgentConfigs({ + page: 1, + perPage: 1000, + sortField: 'name', + sortOrder: 'asc', + }); const agentConfigs = agentConfigsData?.items || []; const agentConfigsById = agentConfigs.reduce( (acc: { [key: string]: GetAgentConfigsResponseItem }, config) => { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/package_configs/package_configs_table.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/package_configs/package_configs_table.tsx index 19243084f6821..42d1075e2ee1f 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/package_configs/package_configs_table.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/package_configs/package_configs_table.tsx @@ -118,6 +118,7 @@ export const PackageConfigsTable: React.FunctionComponent = ({ (): EuiInMemoryTableProps['columns'] => [ { field: 'name', + sortable: true, name: i18n.translate( 'xpack.ingestManager.configDetails.packageConfigsTable.nameColumnTitle', { @@ -137,6 +138,7 @@ export const PackageConfigsTable: React.FunctionComponent = ({ }, { field: 'packageTitle', + sortable: true, name: i18n.translate( 'xpack.ingestManager.configDetails.packageConfigsTable.packageNameColumnTitle', { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx index 0a9daf0038aab..4e79bd4fa7997 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx @@ -17,6 +17,7 @@ import { EuiTableFieldDataColumnType, EuiTextColor, } from '@elastic/eui'; +import { CriteriaWithPagination } from '@elastic/eui/src/components/basic_table/basic_table'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedDate } from '@kbn/i18n/react'; import { useHistory } from 'react-router-dom'; @@ -27,6 +28,7 @@ import { useCapabilities, useGetAgentConfigs, usePagination, + useSorting, useLink, useConfig, useUrlParams, @@ -84,6 +86,10 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { : urlParams.kuery ?? '' ); const { pagination, pageSizeOptions, setPagination } = usePagination(); + const { sorting, setSorting } = useSorting({ + field: 'updated_at', + direction: 'desc', + }); const history = useHistory(); const isCreateAgentConfigFlyoutOpen = 'create' in urlParams; const setIsCreateAgentConfigFlyoutOpen = useCallback( @@ -106,6 +112,8 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { const { isLoading, data: agentConfigData, sendRequest } = useGetAgentConfigs({ page: pagination.currentPage, perPage: pagination.pageSize, + sortField: sorting?.field, + sortOrder: sorting?.direction, kuery: search, }); @@ -116,6 +124,7 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { > = [ { field: 'name', + sortable: true, name: i18n.translate('xpack.ingestManager.agentConfigList.nameColumnTitle', { defaultMessage: 'Name', }), @@ -158,6 +167,7 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { }, { field: 'updated_at', + sortable: true, name: i18n.translate('xpack.ingestManager.agentConfigList.updatedOnColumnTitle', { defaultMessage: 'Last updated on', }), @@ -240,6 +250,16 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { [createAgentConfigButton] ); + const onTableChange = (criteria: CriteriaWithPagination) => { + const newPagination = { + ...pagination, + currentPage: criteria.page.index + 1, + pageSize: criteria.page.size, + }; + setPagination(newPagination); + setSorting(criteria.sort); + }; + return ( {isCreateAgentConfigFlyoutOpen ? ( @@ -276,7 +296,7 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { - loading={isLoading} hasActions={true} noItemsMessage={ @@ -314,14 +334,8 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { totalItemCount: agentConfigData ? agentConfigData.total : 0, pageSizeOptions, }} - onChange={({ page }: { page: { index: number; size: number } }) => { - const newPagination = { - ...pagination, - currentPage: page.index + 1, - pageSize: page.size, - }; - setPagination(newPagination); - }} + sorting={{ sort: sorting }} + onChange={onTableChange} /> ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_reassign_config_flyout/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_reassign_config_flyout/index.tsx index 450def54ba1d0..592ca7f7b8380 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_reassign_config_flyout/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_reassign_config_flyout/index.tsx @@ -36,7 +36,10 @@ export const AgentReassignConfigFlyout: React.FunctionComponent = ({ onCl agent.config_id ); - const agentConfigsRequest = useGetAgentConfigs(); + const agentConfigsRequest = useGetAgentConfigs({ + page: 1, + perPage: 1000, + }); const agentConfigs = agentConfigsRequest.data ? agentConfigsRequest.data.items : []; const [isSubmitting, setIsSubmitting] = useState(false); diff --git a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts index 98de9ac217af9..9819a4fa5d750 100644 --- a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts +++ b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts @@ -119,8 +119,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { }, mappings: { properties: { - id: { type: 'keyword' }, - name: { type: 'text' }, + name: { type: 'keyword' }, description: { type: 'text' }, namespace: { type: 'keyword' }, is_default: { type: 'boolean' }, diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.ts index ada35d1825069..bd00727714c33 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts @@ -143,10 +143,12 @@ class AgentConfigService { soClient: SavedObjectsClientContract, options: ListWithKuery ): Promise<{ items: AgentConfig[]; total: number; page: number; perPage: number }> { - const { page = 1, perPage = 20, kuery } = options; + const { page = 1, perPage = 20, sortField = 'updated_at', sortOrder = 'desc', kuery } = options; const agentConfigs = await soClient.find({ type: SAVED_OBJECT_TYPE, + sortField, + sortOrder, page, perPage, // To ensure users don't need to know about SO data structure... @@ -273,7 +275,6 @@ class AgentConfigService { soClient, id, { - ...oldAgentConfig, package_configs: uniq( [...((oldAgentConfig.package_configs || []) as string[])].filter( (pkgConfigId) => !packageConfigIds.includes(pkgConfigId) diff --git a/x-pack/plugins/ingest_manager/server/services/agents/crud.ts b/x-pack/plugins/ingest_manager/server/services/agents/crud.ts index c78a9ff8bb7b5..4420135aec952 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/crud.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/crud.ts @@ -12,20 +12,24 @@ import { AGENT_TYPE_EPHEMERAL, AGENT_POLLING_THRESHOLD_MS, } from '../../constants'; -import { AgentSOAttributes, Agent, AgentEventSOAttributes } from '../../types'; +import { AgentSOAttributes, Agent, AgentEventSOAttributes, ListWithKuery } from '../../types'; import { savedObjectToAgent } from './saved_objects'; import { escapeSearchQueryPhrase } from '../saved_object'; export async function listAgents( soClient: SavedObjectsClientContract, - options: { - page: number; - perPage: number; - kuery?: string; + options: ListWithKuery & { showInactive: boolean; } ) { - const { page, perPage, kuery, showInactive = false } = options; + const { + page = 1, + perPage = 20, + sortField = 'enrolled_at', + sortOrder = 'desc', + kuery, + showInactive = false, + } = options; const filters = []; @@ -49,10 +53,11 @@ export async function listAgents( const { saved_objects, total } = await soClient.find({ type: AGENT_SAVED_OBJECT_TYPE, + sortField, + sortOrder, page, perPage, filter: _joinFilters(filters), - ..._getSortFields(), }); const agents: Agent[] = saved_objects.map(savedObjectToAgent); @@ -137,23 +142,6 @@ export async function deleteAgent(soClient: SavedObjectsClientContract, agentId: }); } -function _getSortFields(sortOption?: string) { - switch (sortOption) { - case 'ASC': - return { - sortField: 'enrolled_at', - sortOrder: 'ASC', - }; - - case 'DESC': - default: - return { - sortField: 'enrolled_at', - sortOrder: 'DESC', - }; - } -} - function _joinFilters(filters: string[], operator = 'AND') { return filters.reduce((acc: string | undefined, filter) => { if (acc) { diff --git a/x-pack/plugins/ingest_manager/server/services/agents/events.ts b/x-pack/plugins/ingest_manager/server/services/agents/events.ts index b6d87c9ca5b2f..55970607c74ab 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/events.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/events.ts @@ -31,7 +31,7 @@ export async function getAgentEvents( perPage, page, sortField: 'timestamp', - sortOrder: 'DESC', + sortOrder: 'desc', defaultSearchOperator: 'AND', search: agentId, searchFields: ['agent_id'], diff --git a/x-pack/plugins/ingest_manager/server/services/agents/status.ts b/x-pack/plugins/ingest_manager/server/services/agents/status.ts index 0efb202eff532..016a2344cf532 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/status.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/status.ts @@ -61,7 +61,7 @@ async function getEventsCount(soClient: SavedObjectsClientContract, configId?: s perPage: 0, page: 1, sortField: 'timestamp', - sortOrder: 'DESC', + sortOrder: 'desc', defaultSearchOperator: 'AND', }); diff --git a/x-pack/plugins/ingest_manager/server/services/api_keys/enrollment_api_key.ts b/x-pack/plugins/ingest_manager/server/services/api_keys/enrollment_api_key.ts index 3b003f47eb6f9..02e2c8151fac7 100644 --- a/x-pack/plugins/ingest_manager/server/services/api_keys/enrollment_api_key.ts +++ b/x-pack/plugins/ingest_manager/server/services/api_keys/enrollment_api_key.ts @@ -29,7 +29,7 @@ export async function listEnrollmentApiKeys( page, perPage, sortField: 'created_at', - sortOrder: 'DESC', + sortOrder: 'desc', filter: kuery && kuery !== '' ? kuery.replace( diff --git a/x-pack/plugins/ingest_manager/server/services/package_config.ts b/x-pack/plugins/ingest_manager/server/services/package_config.ts index c886f4868ad30..5a7546bfee2e0 100644 --- a/x-pack/plugins/ingest_manager/server/services/package_config.ts +++ b/x-pack/plugins/ingest_manager/server/services/package_config.ts @@ -145,10 +145,12 @@ class PackageConfigService { soClient: SavedObjectsClientContract, options: ListWithKuery ): Promise<{ items: PackageConfig[]; total: number; page: number; perPage: number }> { - const { page = 1, perPage = 20, kuery } = options; + const { page = 1, perPage = 20, sortField = 'updated_at', sortOrder = 'desc', kuery } = options; const packageConfigs = await soClient.find({ type: SAVED_OBJECT_TYPE, + sortField, + sortOrder, page, perPage, // To ensure users don't need to know about SO data structure... diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/common.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/common.ts index 2c8134d2e8f92..dc0f111680490 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/common.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/common.ts @@ -6,8 +6,10 @@ import { schema, TypeOf } from '@kbn/config-schema'; export const ListWithKuerySchema = schema.object({ - page: schema.number({ defaultValue: 1 }), - perPage: schema.number({ defaultValue: 20 }), + page: schema.maybe(schema.number({ defaultValue: 1 })), + perPage: schema.maybe(schema.number({ defaultValue: 20 })), + sortField: schema.maybe(schema.string()), + sortOrder: schema.maybe(schema.oneOf([schema.literal('desc'), schema.literal('asc')])), kuery: schema.maybe(schema.string()), }); diff --git a/x-pack/test/functional/es_archives/fleet/agents/data.json b/x-pack/test/functional/es_archives/fleet/agents/data.json index c317aad8ba05b..b3d49199b0d9e 100644 --- a/x-pack/test/functional/es_archives/fleet/agents/data.json +++ b/x-pack/test/functional/es_archives/fleet/agents/data.json @@ -220,8 +220,7 @@ ], "revision": 2, "updated_at": "2020-05-07T19:34:42.533Z", - "updated_by": "system", - "id": "config1" + "updated_by": "system" } } } From 654ad964fe0793e04e63bdffc085cf15e1e6e7ab Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Fri, 3 Jul 2020 12:24:15 -0400 Subject: [PATCH 07/16] [Alerting] document requirements for developing new action types (#69164) (#70684) * [Alerting] document requirements for developing new action types resolves https://github.com/elastic/kibana/issues/67864 Adding this to the actions README.md, it should be published as asciidocs at GA. --- x-pack/plugins/actions/README.md | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 605676cee363d..494f2f38e8bff 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -71,6 +71,7 @@ Table of Contents - [`params`](#params-7) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-1) - [Command Line Utility](#command-line-utility) +- [Developing New Action Types](#developing-new-action-types) ## Terminology @@ -606,3 +607,39 @@ $ kbn-action create .slack "post to slack" '{"webhookUrl": "https://hooks.slack. "version": "WzMsMV0=" } ``` + +# Developing New Action Types + +When creating a new action type, your plugin will eventually call `server.plugins.actions.setup.registerType()` to register the type with the actions plugin, but there are some additional things to think about about and implement. + +Consider working with the alerting team on early structure /design feedback of new actions, especially as the APIs and infrastructure are still under development. + +## licensing + +Currently actions are licensed as "basic" if the action only interacts with the stack, eg the server log and es index actions. Other actions are at least "gold" level. + +## plugin location + +Currently actions that are licensed as "basic" **MUST** be implemented in the actions plugin, other actions can be implemented in any other plugin that pre-reqs the actions plugin. If the new action is generic across the stack, it probably belongs in the actions plugin, but if your action is very specific to a plugin/solution, it might be easiest to implement it in the plugin/solution. Keep in mind that if Kibana is run without the plugin being enabled, any actions defined in that plugin will not run, nor will those actions be available via APIs or UI. + +Actions that take URLs or hostnames should check that those values are whitelisted. The whitelisting utilities are currently internal to the actions plugin, and so such actions will need to be implemented in the actions plugin. Longer-term, we will expose these utilities so they can be used by alerts implemented in other plugins; see [issue #64659](https://github.com/elastic/kibana/issues/64659). + +## documentation + +You should also create some asciidoc for the new action type. An entry should be made in the action type index - [`docs/user/alerting/action-types.asciidoc`](../../../docs/user/alerting/action-types.asciidoc) which points to a new document for the action type that should be in the directory [`docs/user/alerting/action-types`](../../../docs/user/alerting/action-types). + +## tests + +The action type should have both jest tests and functional tests. For functional tests, if your action interacts with a 3rd party service via HTTP, you may be able to create a simulator for your service, to test with. See the existing functional test servers in the directory [`x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server`](../../test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server) + +## action type config and secrets + +Action types must define `config` and `secrets` which are used to create connectors. This data should be described with `@kbn/config-schema` object schemas, and you **MUST NOT** use `schema.maybe()` to define properties. + +This is due to the fact that the structures are persisted in saved objects, which performs partial updates on the persisted data. If a property value is already persisted, but an update either doesn't include the property, or sets it to `undefined`, the persisted value will not be changed. Beyond this being a semantic error in general, it also ends up invalidating the encryption used to save secrets, and will render the secrets will not be able to be unencrypted later. + +Instead of `schema.maybe()`, use `schema.nullable()`, which is the same as `schema.maybe()` except that when passed an `undefined` value, the object returned from the validation will be set to `null`. The resulting type will be `property-type | null`, whereas with `schema.maybe()` it would be `property-type | undefined`. + +## user interface + +In order to make this action usable in the Kibana UI, you will need to provide all the UI editing aspects of the action. The existing action type user interfaces are defined in [`x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types`](../triggers_actions_ui/public/application/components/builtin_action_types). For more information, see the [UI documentation](../triggers_actions_ui/README.md#create-and-register-new-action-type-ui). From 2c0651e6d885167d66c08569132a79c27fbd608e Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 3 Jul 2020 18:37:16 +0200 Subject: [PATCH 08/16] [Lens] Add ability to set colors for y-axis series (#70311) (#70719) --- .../__snapshots__/xy_expression.test.tsx.snap | 14 ++ .../public/xy_visualization/state_helpers.ts | 11 +- .../public/xy_visualization/to_expression.ts | 3 +- .../lens/public/xy_visualization/types.ts | 5 + .../xy_visualization/xy_config_panel.tsx | 230 +++++++++++++----- .../xy_visualization/xy_expression.test.tsx | 69 ++++++ .../public/xy_visualization/xy_expression.tsx | 3 +- 7 files changed, 275 insertions(+), 60 deletions(-) diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap index 8cb30037379da..6e87e47a5cf9f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap @@ -36,6 +36,7 @@ exports[`xy_expression XYChart component it renders area 1`] = ` title="a" /> { + if (layer.splitAccessor) { + return null; + } + return ( + layer?.yConfig?.find((yConfig: YConfig) => yConfig.forAccessor === accessor)?.color || null + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 6ec22270d8b18..b5b796dc019de 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -188,7 +188,8 @@ export const buildExpression = ( function: 'lens_xy_yConfig', arguments: { forAccessor: [yConfig.forAccessor], - axisMode: [yConfig.axisMode], + axisMode: yConfig.axisMode ? [yConfig.axisMode] : [], + color: yConfig.color ? [yConfig.color] : [], }, }, ], diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index e62c5f60a58e1..8ea9683ca042c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -100,6 +100,10 @@ export const yAxisConfig: ExpressionFunctionDefinition< options: ['auto', 'left', 'right'], help: 'The axis mode of the metric', }, + color: { + types: ['string'], + help: 'The color of the series', + }, }, fn: function fn(input: unknown, args: YConfig) { return { @@ -195,6 +199,7 @@ export type YAxisMode = 'auto' | 'left' | 'right'; export interface YConfig { forAccessor: string; axisMode?: YAxisMode; + color?: string; } export interface LayerConfig { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index 3e73cd256bdbf..e6c284f09ab4e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -4,12 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonGroup, EuiFormRow, htmlIdGenerator } from '@elastic/eui'; +import { debounce } from 'lodash'; +import { + EuiButtonGroup, + EuiFormRow, + htmlIdGenerator, + EuiForm, + EuiColorPicker, + EuiColorPickerProps, + EuiToolTip, + EuiIcon, +} from '@elastic/eui'; import { State, SeriesType, visualizationTypes, YAxisMode } from './types'; import { VisualizationDimensionEditorProps, VisualizationLayerWidgetProps } from '../types'; -import { isHorizontalChart, isHorizontalSeries } from './state_helpers'; +import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_helpers'; import { trackUiEvent } from '../lens_ui_telemetry'; type UnwrapArray = T extends Array ? P : T; @@ -70,70 +80,176 @@ export function LayerContextMenu(props: VisualizationLayerWidgetProps) { const idPrefix = htmlIdGenerator()(); -export function DimensionEditor({ - state, - setState, - layerId, - accessor, -}: VisualizationDimensionEditorProps) { +export function DimensionEditor(props: VisualizationDimensionEditorProps) { + const { state, setState, layerId, accessor } = props; const index = state.layers.findIndex((l) => l.layerId === layerId); const layer = state.layers[index]; const axisMode = (layer.yConfig && layer.yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode) || 'auto'; + return ( - - + + + { - const newMode = id.replace(idPrefix, '') as YAxisMode; - const newYAxisConfigs = [...(layer.yConfig || [])]; - const existingIndex = newYAxisConfigs.findIndex( - (yAxisConfig) => yAxisConfig.forAccessor === accessor - ); - if (existingIndex !== -1) { - newYAxisConfigs[existingIndex].axisMode = newMode; + > + { + const newMode = id.replace(idPrefix, '') as YAxisMode; + const newYAxisConfigs = [...(layer.yConfig || [])]; + const existingIndex = newYAxisConfigs.findIndex( + (yAxisConfig) => yAxisConfig.forAccessor === accessor + ); + if (existingIndex !== -1) { + newYAxisConfigs[existingIndex].axisMode = newMode; + } else { + newYAxisConfigs.push({ + forAccessor: accessor, + axisMode: newMode, + }); + } + setState(updateLayer(state, { ...layer, yConfig: newYAxisConfigs }, index)); + }} + /> + + + ); +} + +const tooltipContent = { + auto: i18n.translate('xpack.lens.configPanel.color.tooltip.auto', { + defaultMessage: 'Lens automatically picks colors for you unless you specify a custom color.', + }), + custom: i18n.translate('xpack.lens.configPanel.color.tooltip.custom', { + defaultMessage: 'Clear the custom color to return to “Auto” mode.', + }), + disabled: i18n.translate('xpack.lens.configPanel.color.tooltip.disabled', { + defaultMessage: + 'Individual series cannot be custom colored when the layer includes a “Break down by“', + }), +}; + +const ColorPicker = ({ + state, + setState, + layerId, + accessor, +}: VisualizationDimensionEditorProps) => { + const index = state.layers.findIndex((l) => l.layerId === layerId); + const layer = state.layers[index]; + const disabled = !!layer.splitAccessor; + + const [color, setColor] = useState(getSeriesColor(layer, accessor)); + + const handleColor: EuiColorPickerProps['onChange'] = (text, output) => { + setColor(text); + if (output.isValid || text === '') { + updateColorInState(text, output); + } + }; + + const updateColorInState: EuiColorPickerProps['onChange'] = React.useMemo( + () => + debounce((text, output) => { + const newYConfigs = [...(layer.yConfig || [])]; + const existingIndex = newYConfigs.findIndex((yConfig) => yConfig.forAccessor === accessor); + if (existingIndex !== -1) { + if (text === '') { + delete newYConfigs[existingIndex].color; } else { - newYAxisConfigs.push({ - forAccessor: accessor, - axisMode: newMode, - }); + newYConfigs[existingIndex].color = output.hex; } - setState(updateLayer(state, { ...layer, yConfig: newYAxisConfigs }, index)); - }} - /> + } else { + newYConfigs.push({ + forAccessor: accessor, + color: output.hex, + }); + } + setState(updateLayer(state, { ...layer, yConfig: newYConfigs }, index)); + }, 256), + [state, layer, accessor, index] + ); + + return ( + + + {i18n.translate('xpack.lens.xyChart.seriesColor.label', { + defaultMessage: 'Series color', + })}{' '} + + + + } + > + {disabled ? ( + + + + ) : ( + + )} ); -} +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx index f433a88e3bdbd..4a532d378eaf7 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx @@ -996,6 +996,75 @@ describe('xy_expression', () => { }); }); + describe('y series coloring', () => { + test('color is applied to chart for multiple series', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + splitAccessor: undefined, + accessors: ['a', 'b'], + yConfig: [ + { + forAccessor: 'a', + color: '#550000', + }, + { + forAccessor: 'b', + color: '#FFFF00', + }, + ], + }, + { + ...args.layers[0], + splitAccessor: undefined, + accessors: ['c'], + yConfig: [ + { + forAccessor: 'c', + color: '#FEECDF', + }, + ], + }, + ], + } as XYArgs; + + const component = getRenderedComponent(dataWithoutFormats, newArgs); + expect((component.find(LineSeries).at(0).prop('color') as Function)!()).toEqual('#550000'); + expect((component.find(LineSeries).at(1).prop('color') as Function)!()).toEqual('#FFFF00'); + expect((component.find(LineSeries).at(2).prop('color') as Function)!()).toEqual('#FEECDF'); + }); + test('color is not applied to chart when splitAccessor is defined or when yConfig is not configured', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['a'], + yConfig: [ + { + forAccessor: 'a', + color: '#550000', + }, + ], + }, + { + ...args.layers[0], + splitAccessor: undefined, + accessors: ['c'], + }, + ], + } as XYArgs; + + const component = getRenderedComponent(dataWithoutFormats, newArgs); + expect((component.find(LineSeries).at(0).prop('color') as Function)!()).toEqual(null); + expect((component.find(LineSeries).at(1).prop('color') as Function)!()).toEqual(null); + }); + }); + describe('provides correct series naming', () => { const nameFnArgs = { seriesKeys: [], diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx index 3ff7bd7fda304..3e5fb10e080d0 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -35,7 +35,7 @@ import { } from '../types'; import { XYArgs, SeriesType, visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; -import { isHorizontalChart } from './state_helpers'; +import { isHorizontalChart, getSeriesColor } from './state_helpers'; import { parseInterval } from '../../../../../src/plugins/data/common'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; import { EmptyPlaceholder } from '../shared_components'; @@ -433,6 +433,7 @@ export function XYChart({ data: rows, xScaleType, yScaleType, + color: () => getSeriesColor(layer, accessor), groupId: yAxesConfiguration.find((axisConfiguration) => axisConfiguration.series.find((currentSeries) => currentSeries.accessor === accessor) )?.groupId, From cc1b8565719f54520137d8646d4fe54412ad31de Mon Sep 17 00:00:00 2001 From: Shahzad Date: Fri, 3 Jul 2020 21:43:21 +0200 Subject: [PATCH 09/16] [7.x] [RUM Dashboard] Added service name filter (#70349) (#70721) Co-authored-by: Elastic Machine Co-authored-by: Elastic Machine --- x-pack/plugins/apm/common/agent_name.ts | 6 +- .../app/RumDashboard/ClientMetrics/index.tsx | 6 +- .../PageLoadDistribution/index.tsx | 13 +++- .../PageLoadDistribution/use_breakdowns.ts | 6 +- .../app/RumDashboard/PageViewsTrend/index.tsx | 6 +- .../components/app/RumDashboard/index.tsx | 43 ++++++++++- .../ServiceNameFilter/index.tsx | 74 +++++++++++++++++++ .../lib/ui_filters/local_ui_filters/config.ts | 7 ++ 8 files changed, 145 insertions(+), 16 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx diff --git a/x-pack/plugins/apm/common/agent_name.ts b/x-pack/plugins/apm/common/agent_name.ts index 630f5739806af..9d462dad87ec0 100644 --- a/x-pack/plugins/apm/common/agent_name.ts +++ b/x-pack/plugins/apm/common/agent_name.ts @@ -30,10 +30,12 @@ export function isAgentName(agentName: string): agentName is AgentName { return AGENT_NAMES.includes(agentName as AgentName); } +export const RUM_AGENTS = ['js-base', 'rum-js']; + export function isRumAgentName( - agentName: string | undefined + agentName?: string ): agentName is 'js-base' | 'rum-js' { - return agentName === 'js-base' || agentName === 'rum-js'; + return RUM_AGENTS.includes(agentName!); } export function isJavaAgentName( diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx index 776f74a169966..df72fa604e4b3 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx @@ -22,11 +22,11 @@ const ClFlexGroup = styled(EuiFlexGroup)` export function ClientMetrics() { const { urlParams, uiFilters } = useUrlParams(); - const { start, end } = urlParams; + const { start, end, serviceName } = urlParams; const { data, status } = useFetcher( (callApmApi) => { - if (start && end) { + if (start && end && serviceName) { return callApmApi({ pathname: '/api/apm/rum/client-metrics', params: { @@ -35,7 +35,7 @@ export function ClientMetrics() { }); } }, - [start, end, uiFilters] + [start, end, serviceName, uiFilters] ); const STAT_STYLE = { width: '240px' }; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx index c6b34c8b76698..7d48cee49b104 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx @@ -27,7 +27,7 @@ export interface PercentileRange { export const PageLoadDistribution = () => { const { urlParams, uiFilters } = useUrlParams(); - const { start, end } = urlParams; + const { start, end, serviceName } = urlParams; const [percentileRange, setPercentileRange] = useState({ min: null, @@ -38,7 +38,7 @@ export const PageLoadDistribution = () => { const { data, status } = useFetcher( (callApmApi) => { - if (start && end) { + if (start && end && serviceName) { return callApmApi({ pathname: '/api/apm/rum-client/page-load-distribution', params: { @@ -57,7 +57,14 @@ export const PageLoadDistribution = () => { }); } }, - [end, start, uiFilters, percentileRange.min, percentileRange.max] + [ + end, + start, + serviceName, + uiFilters, + percentileRange.min, + percentileRange.max, + ] ); const onPercentileChange = (min: number, max: number) => { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts index 814cf977c9569..805d19e2321d5 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts @@ -17,13 +17,13 @@ interface Props { export const useBreakdowns = ({ percentileRange, field, value }: Props) => { const { urlParams, uiFilters } = useUrlParams(); - const { start, end } = urlParams; + const { start, end, serviceName } = urlParams; const { min: minP, max: maxP } = percentileRange ?? {}; return useFetcher( (callApmApi) => { - if (start && end && field && value) { + if (start && end && serviceName && field && value) { return callApmApi({ pathname: '/api/apm/rum-client/page-load-distribution/breakdown', params: { @@ -43,6 +43,6 @@ export const useBreakdowns = ({ percentileRange, field, value }: Props) => { }); } }, - [end, start, uiFilters, field, value, minP, maxP] + [end, start, serviceName, uiFilters, field, value, minP, maxP] ); }; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx index 34347f3f95947..328b873ef8562 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx @@ -16,13 +16,13 @@ import { BreakdownItem } from '../../../../../typings/ui_filters'; export const PageViewsTrend = () => { const { urlParams, uiFilters } = useUrlParams(); - const { start, end } = urlParams; + const { start, end, serviceName } = urlParams; const [breakdowns, setBreakdowns] = useState([]); const { data, status } = useFetcher( (callApmApi) => { - if (start && end) { + if (start && end && serviceName) { return callApmApi({ pathname: '/api/apm/rum-client/page-view-trends', params: { @@ -40,7 +40,7 @@ export const PageViewsTrend = () => { }); } }, - [end, start, uiFilters, breakdowns] + [end, start, serviceName, uiFilters, breakdowns] ); const onBreakdownChange = (values: BreakdownItem[]) => { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx index 8f21065b0dab0..c9e475ef15316 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx @@ -4,12 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiSpacer, +} from '@elastic/eui'; import React, { useMemo } from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { PROJECTION } from '../../../../common/projections/typings'; import { RumDashboard } from './RumDashboard'; +import { ServiceNameFilter } from '../../shared/LocalUIFilters/ServiceNameFilter'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { useFetcher } from '../../../hooks/useFetcher'; +import { RUM_AGENTS } from '../../../../common/agent_name'; export function RumOverview() { useTrackPageview({ app: 'apm', path: 'rum_overview' }); @@ -24,12 +33,42 @@ export function RumOverview() { return config; }, []); + const { + urlParams: { start, end }, + } = useUrlParams(); + + const { data } = useFetcher( + (callApmApi) => { + if (start && end) { + return callApmApi({ + pathname: '/api/apm/services', + params: { + query: { + start, + end, + uiFilters: JSON.stringify({ agentName: RUM_AGENTS }), + }, + }, + }); + } + }, + [start, end] + ); + return ( <> - + + service.serviceName) ?? [] + } + /> + + + diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx new file mode 100644 index 0000000000000..e12a4a4831e17 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; +import { + EuiTitle, + EuiHorizontalRule, + EuiSpacer, + EuiSelect, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { history } from '../../../../utils/history'; +import { fromQuery, toQuery } from '../../Links/url_helpers'; + +interface Props { + serviceNames: string[]; +} + +const ServiceNameFilter = ({ serviceNames }: Props) => { + const { + urlParams: { serviceName }, + } = useUrlParams(); + + const options = serviceNames.map((type) => ({ + text: type, + value: type, + })); + + const updateServiceName = (serviceN: string) => { + const newLocation = { + ...history.location, + search: fromQuery({ + ...toQuery(history.location.search), + serviceName: serviceN, + }), + }; + history.push(newLocation); + }; + + useEffect(() => { + if (!serviceName && serviceNames.length > 0) { + updateServiceName(serviceNames[0]); + } + }, [serviceNames, serviceName]); + + return ( + <> + +

+ {i18n.translate('xpack.apm.localFilters.titles.serviceName', { + defaultMessage: 'Service name', + })} +

+
+ + + + { + updateServiceName(event.target.value); + }} + /> + + ); +}; + +export { ServiceNameFilter }; diff --git a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/config.ts b/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/config.ts index 7a3d9d94dec8e..9f2483ab8a24e 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/config.ts +++ b/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/config.ts @@ -16,6 +16,7 @@ import { USER_AGENT_DEVICE, USER_AGENT_OS, CLIENT_GEO_COUNTRY_ISO_CODE, + SERVICE_NAME, } from '../../../../common/elasticsearch_fieldnames'; const filtersByName = { @@ -85,6 +86,12 @@ const filtersByName = { }), fieldName: USER_AGENT_OS, }, + serviceName: { + title: i18n.translate('xpack.apm.localFilters.titles.serviceName', { + defaultMessage: 'Service name', + }), + fieldName: SERVICE_NAME, + }, }; export type LocalUIFilterName = keyof typeof filtersByName; From dd248633f61d9263355a90ebb74b2f6e3f7db01d Mon Sep 17 00:00:00 2001 From: Shahzad Date: Fri, 3 Jul 2020 21:43:48 +0200 Subject: [PATCH 10/16] [7.x] [RUM Dashboard] Update rum title to be consistent with APM (#70460) (#70720) --- .../apm/public/components/app/Home/index.tsx | 7 +- .../app/RumDashboard/RumDashboard.tsx | 67 +++++++------------ .../app/RumDashboard/translations.ts | 8 --- 3 files changed, 26 insertions(+), 56 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/Home/index.tsx b/x-pack/plugins/apm/public/components/app/Home/index.tsx index 69699b72a96df..f612ac0d383ef 100644 --- a/x-pack/plugins/apm/public/components/app/Home/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Home/index.tsx @@ -27,7 +27,6 @@ import { ServiceOverview } from '../ServiceOverview'; import { TraceOverview } from '../TraceOverview'; import { RumOverview } from '../RumDashboard'; import { RumOverviewLink } from '../../shared/Links/apm/RumOverviewLink'; -import { I18LABELS } from '../RumDashboard/translations'; function getHomeTabs({ serviceMapEnabled = true, @@ -109,11 +108,7 @@ export function Home({ tab }: Props) { -

- {selectedTab.name === 'rum-overview' - ? I18LABELS.endUserExperience - : 'APM'} -

+

APM

diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx index cd50f3b575113..326d4a00fd31f 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx @@ -16,50 +16,33 @@ import { ClientMetrics } from './ClientMetrics'; import { PageViewsTrend } from './PageViewsTrend'; import { PageLoadDistribution } from './PageLoadDistribution'; import { I18LABELS } from './translations'; -import { useUrlParams } from '../../../hooks/useUrlParams'; export function RumDashboard() { - const { urlParams } = useUrlParams(); - - const { environment } = urlParams; - - let environmentLabel = environment || 'all environments'; - - if (environment === 'ENVIRONMENT_NOT_DEFINED') { - environmentLabel = 'undefined environment'; - } - return ( - <> - -

{I18LABELS.getWhatIsGoingOn(environmentLabel)}

-
- - - - - - - -

{I18LABELS.pageLoadTimes}

-
- - -
-
-
-
- - - - - - - - - - -
- + + + + + + +

{I18LABELS.pageLoadTimes}

+
+ + +
+
+
+
+ + + + + + + + + + +
); } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts index 4da7b59ec7fa5..2784d9bfd8efa 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts @@ -7,14 +7,6 @@ import { i18n } from '@kbn/i18n'; export const I18LABELS = { - endUserExperience: i18n.translate('xpack.apm.rum.dashboard.title', { - defaultMessage: 'End User Experience', - }), - getWhatIsGoingOn: (environmentVal: string) => - i18n.translate('xpack.apm.rum.dashboard.environment.title', { - defaultMessage: `What's going on in {environmentVal}?`, - values: { environmentVal }, - }), backEnd: i18n.translate('xpack.apm.rum.dashboard.backend', { defaultMessage: 'Backend', }), From 4cc8ca8a78c7bce57610db0747806d43204df231 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Fri, 3 Jul 2020 21:44:47 +0200 Subject: [PATCH 11/16] [7.x] [Uptime] Use elastic charts donut (#70364) (#70722) Co-authored-by: Elastic Machine Co-authored-by: Elastic Machine --- .../__snapshots__/chart_wrapper.test.tsx.snap | 2 - .../__snapshots__/donut_chart.test.tsx.snap | 412 +++++++++++++++++- .../charts/__tests__/chart_wrapper.test.tsx | 9 +- .../components/common/charts/donut_chart.tsx | 97 ++--- .../common/charts/donut_chart_legend.tsx | 2 +- .../__snapshots__/snapshot.test.tsx.snap | 1 - .../components/overview/snapshot/snapshot.tsx | 8 +- 7 files changed, 442 insertions(+), 89 deletions(-) diff --git a/x-pack/plugins/uptime/public/components/common/charts/__tests__/__snapshots__/chart_wrapper.test.tsx.snap b/x-pack/plugins/uptime/public/components/common/charts/__tests__/__snapshots__/chart_wrapper.test.tsx.snap index 71690432fd01b..d8235765bda2d 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/__tests__/__snapshots__/chart_wrapper.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/common/charts/__tests__/__snapshots__/chart_wrapper.test.tsx.snap @@ -123,7 +123,6 @@ exports[`ChartWrapper component renders the component with loading false 1`] = ` down={8} height={144} up={4} - width={144} /> @@ -252,7 +251,6 @@ exports[`ChartWrapper component renders the component with loading true 1`] = ` down={8} height={144} up={4} - width={144} /> - + baseTheme={ + Object { + "arcSeriesStyle": Object { + "arc": Object { + "opacity": 1, + "stroke": "black", + "strokeWidth": 1, + "visible": true, + }, + }, + "areaSeriesStyle": Object { + "area": Object { + "opacity": 0.3, + "visible": true, + }, + "line": Object { + "opacity": 1, + "strokeWidth": 1, + "visible": true, + }, + "point": Object { + "fill": "white", + "opacity": 1, + "radius": 2, + "strokeWidth": 1, + "visible": false, + }, + }, + "axes": Object { + "axisLineStyle": Object { + "stroke": "#eaeaea", + "strokeWidth": 1, + }, + "axisTitleStyle": Object { + "fill": "#333", + "fontFamily": "sans-serif", + "fontSize": 12, + "fontStyle": "bold", + "padding": 8, + }, + "gridLineStyle": Object { + "horizontal": Object { + "dash": Array [ + 0, + 0, + ], + "opacity": 1, + "stroke": "#D3DAE6", + "strokeWidth": 1, + "visible": true, + }, + "vertical": Object { + "dash": Array [ + 0, + 0, + ], + "opacity": 1, + "stroke": "#D3DAE6", + "strokeWidth": 1, + "visible": true, + }, + }, + "tickLabelStyle": Object { + "fill": "#777", + "fontFamily": "sans-serif", + "fontSize": 10, + "fontStyle": "normal", + "padding": 4, + }, + "tickLineStyle": Object { + "stroke": "#eaeaea", + "strokeWidth": 1, + "visible": true, + }, + }, + "background": Object { + "color": "transparent", + }, + "barSeriesStyle": Object { + "displayValue": Object { + "fill": "#777", + "fontFamily": "sans-serif", + "fontSize": 8, + "fontStyle": "normal", + "offsetX": 0, + "offsetY": 0, + "padding": 0, + }, + "rect": Object { + "opacity": 1, + }, + "rectBorder": Object { + "strokeWidth": 0, + "visible": false, + }, + }, + "bubbleSeriesStyle": Object { + "point": Object { + "fill": "white", + "opacity": 1, + "radius": 2, + "strokeWidth": 1, + "visible": true, + }, + }, + "chartMargins": Object { + "bottom": 10, + "left": 10, + "right": 10, + "top": 10, + }, + "chartPaddings": Object { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + }, + "colors": Object { + "defaultVizColor": "red", + "vizColors": Array [ + "#1EA593", + "#2B70F7", + "#CE0060", + "#38007E", + "#FCA5D3", + "#F37020", + "#E49E29", + "#B0916F", + "#7B000B", + "#34130C", + ], + }, + "crosshair": Object { + "band": Object { + "fill": "#F5F5F5", + "visible": true, + }, + "line": Object { + "dash": Array [ + 5, + 5, + ], + "stroke": "#777", + "strokeWidth": 1, + "visible": true, + }, + }, + "legend": Object { + "horizontalHeight": 64, + "spacingBuffer": 10, + "verticalWidth": 200, + }, + "lineSeriesStyle": Object { + "line": Object { + "opacity": 1, + "strokeWidth": 1, + "visible": true, + }, + "point": Object { + "fill": "white", + "opacity": 1, + "radius": 2, + "strokeWidth": 1, + "visible": true, + }, + }, + "scales": Object { + "barsPadding": 0.25, + "histogramPadding": 0.05, + }, + "sharedStyle": Object { + "default": Object { + "opacity": 1, + }, + "highlighted": Object { + "opacity": 1, + }, + "unhighlighted": Object { + "opacity": 0.25, + }, + }, + } + } + renderer="canvas" + size={125} + theme={ + Object { + "areaSeriesStyle": Object { + "area": Object { + "opacity": 0.3, + }, + "line": Object { + "strokeWidth": 2, + }, + "point": Object { + "fill": "rgba(255, 255, 255, 1)", + "radius": 3, + "strokeWidth": 2, + "visible": false, + }, + }, + "axes": Object { + "axisLineStyle": Object { + "stroke": "rgba(238, 240, 243, 1)", + }, + "axisTitleStyle": Object { + "fill": "rgba(52, 55, 65, 1)", + "fontFamily": "'Inter UI', -apple-system, BlinkMacSystemFont, + 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", + "fontSize": 12, + "padding": 10, + }, + "gridLineStyle": Object { + "horizontal": Object { + "dash": Array [ + 0, + 0, + ], + "opacity": 1, + "stroke": "rgba(238, 240, 243, 1)", + "strokeWidth": 1, + "visible": true, + }, + "vertical": Object { + "dash": Array [ + 4, + 4, + ], + "opacity": 1, + "stroke": "rgba(238, 240, 243, 1)", + "strokeWidth": 1, + "visible": true, + }, + }, + "tickLabelStyle": Object { + "fill": "rgba(105, 112, 125, 1)", + "fontFamily": "'Inter UI', -apple-system, BlinkMacSystemFont, + 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", + "fontSize": 10, + "padding": 8, + }, + "tickLineStyle": Object { + "stroke": "rgba(238, 240, 243, 1)", + "strokeWidth": 1, + "visible": false, + }, + }, + "barSeriesStyle": Object { + "displayValue": Object { + "fill": "rgba(105, 112, 125, 1)", + "fontFamily": "'Inter UI', -apple-system, BlinkMacSystemFont, + 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'", + "fontSize": 8, + }, + }, + "chartMargins": Object { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + }, + "colors": Object { + "defaultVizColor": "#6092C0", + "vizColors": Array [ + "#54B399", + "#6092C0", + "#9170B8", + "#CA8EAE", + "#D36086", + "#E7664C", + "#AA6556", + "#DA8B45", + "#B9A888", + "#D6BF57", + ], + }, + "crosshair": Object { + "band": Object { + "fill": "rgba(245, 247, 250, 1)", + }, + "line": Object { + "dash": Array [ + 4, + 4, + ], + "stroke": "rgba(105, 112, 125, 1)", + "strokeWidth": 1, + }, + }, + "lineSeriesStyle": Object { + "line": Object { + "strokeWidth": 2, + }, + "point": Object { + "fill": "rgba(255, 255, 255, 1)", + "radius": 3, + "strokeWidth": 2, + }, + }, + "scales": Object { + "barsPadding": 0.25, + "histogramPadding": 0.05, + }, + } + } + > + + +
- +
+
+
+
+
+
- +
+
+
+
+
+
{ it('renders the component with loading false', () => { @@ -20,7 +19,7 @@ describe('ChartWrapper component', () => { - + ); expect(component).toMatchSnapshot(); @@ -31,7 +30,7 @@ describe('ChartWrapper component', () => { - + ); expect(component).toMatchSnapshot(); @@ -42,7 +41,7 @@ describe('ChartWrapper component', () => { - + ); @@ -64,7 +63,7 @@ describe('ChartWrapper component', () => { - + ); diff --git a/x-pack/plugins/uptime/public/components/common/charts/donut_chart.tsx b/x-pack/plugins/uptime/public/components/common/charts/donut_chart.tsx index b4d6864423dfc..19716f0d3b1c9 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/donut_chart.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/donut_chart.tsx @@ -5,10 +5,10 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; -import React, { useContext, useEffect, useRef } from 'react'; -import * as d3 from 'd3'; +import React, { useContext } from 'react'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; +import { Chart, Datum, Partition, Settings, PartitionLayout } from '@elastic/charts'; import { DonutChartLegend } from './donut_chart_legend'; import { UptimeThemeContext } from '../../../contexts'; @@ -16,7 +16,6 @@ interface DonutChartProps { down: number; height: number; up: number; - width: number; } export const GreenCheckIcon = styled(EuiIcon)` @@ -28,72 +27,56 @@ export const GreenCheckIcon = styled(EuiIcon)` position: absolute; `; -export const DonutChart = ({ height, down, up, width }: DonutChartProps) => { - const chartElement = useRef(null); - +export const DonutChart = ({ height, down, up }: DonutChartProps) => { const { colors: { danger, gray }, + chartTheme, } = useContext(UptimeThemeContext); - let upCount = up; - if (up === 0 && down === 0) { - upCount = 1; - } - useEffect(() => { - if (chartElement.current !== null) { - // we must remove any existing paths before painting - d3.select(chartElement.current).selectAll('g').remove(); - - const svgElement = d3 - .select(chartElement.current) - .append('g') - .attr('transform', `translate(${width / 2}, ${height / 2})`); - - const color = d3.scale.ordinal().domain(['up', 'down']).range([gray, danger]); - - const pieGenerator = d3.layout - .pie() - .value(({ value }: any) => value) - // these start/end angles will reverse the direction of the pie, - // which matches our design - .startAngle(2 * Math.PI) - .endAngle(0); - - svgElement - .selectAll('g') - .data( - // @ts-ignore pie generator expects param of type number[], but only works with - // output of d3.entries, which is like Array<{ key: string, value: number }> - pieGenerator(d3.entries({ up: upCount, down })) - ) - .enter() - .append('path') - .attr( - 'd', - // @ts-ignore attr does not expect a param of type Arc but it behaves as desired - d3.svg - .arc() - .innerRadius(width * 0.28) - .outerRadius(Math.min(width, height) / 2 - 10) - ) - .attr('fill', (d: any) => color(d.data.key) as any); - } - }, [danger, down, gray, height, upCount, width]); - return ( - - {/* When all monitors are up we show green check icon in middle of donut to indicate, all is well */} + {...chartTheme} + > + + d.value as number} + layers={[ + { + groupByRollup: (d: Datum) => d.label, + nodeLabel: (d: Datum) => d, + shape: { + fillColor: (d: Datum) => { + return d.dataName === 'Down' ? danger : gray; + }, + }, + }, + ]} + config={{ + partitionLayout: PartitionLayout.sunburst, + linkLabel: { + maximumSection: Infinity, + }, + margin: { top: 0, bottom: 0, left: 0, right: 0 }, + idealFontSizeJump: 1.1, + outerSizeRatio: 0.9, + emptySizeRatio: 0.4, + circlePadding: 4, + }} + /> + {down === 0 && } diff --git a/x-pack/plugins/uptime/public/components/common/charts/donut_chart_legend.tsx b/x-pack/plugins/uptime/public/components/common/charts/donut_chart_legend.tsx index a514013eeed98..cbbffdff745f8 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/donut_chart_legend.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/donut_chart_legend.tsx @@ -12,7 +12,7 @@ import { DonutChartLegendRow } from './donut_chart_legend_row'; import { UptimeThemeContext } from '../../../contexts'; const LegendContainer = styled.div` - max-width: 260px; + max-width: 150px; min-width: 100px; @media (max-width: 767px) { min-width: 0px; diff --git a/x-pack/plugins/uptime/public/components/overview/__tests__/__snapshots__/snapshot.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/__tests__/__snapshots__/snapshot.test.tsx.snap index db41dfb0b04c4..2135fc32c2b5b 100644 --- a/x-pack/plugins/uptime/public/components/overview/__tests__/__snapshots__/snapshot.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/__tests__/__snapshots__/snapshot.test.tsx.snap @@ -14,7 +14,6 @@ exports[`Snapshot component renders without errors 1`] = ` down={2} height={144} up={8} - width={144} /> `; diff --git a/x-pack/plugins/uptime/public/components/overview/snapshot/snapshot.tsx b/x-pack/plugins/uptime/public/components/overview/snapshot/snapshot.tsx index 8d6933ad18ced..bc54f14e87822 100644 --- a/x-pack/plugins/uptime/public/components/overview/snapshot/snapshot.tsx +++ b/x-pack/plugins/uptime/public/components/overview/snapshot/snapshot.tsx @@ -11,7 +11,6 @@ import { ChartWrapper } from '../../common/charts/chart_wrapper'; import { SnapshotHeading } from './snapshot_heading'; import { Snapshot as SnapshotType } from '../../../../common/runtime_types'; -const SNAPSHOT_CHART_WIDTH = 144; const SNAPSHOT_CHART_HEIGHT = 144; interface SnapshotComponentProps { @@ -29,11 +28,6 @@ export const SnapshotComponent: React.FC = ({ count, hei - + ); From 4d1d79a0dd0c8fc27560913cb831563251ddf3f1 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Fri, 3 Jul 2020 15:49:44 -0400 Subject: [PATCH 12/16] [SECURITY] Bug fix for topN on draggables (#70450) (#70737) * back to normal * add unit test * hover issue + indexToAdd issue * fix unit test * review II * fix bug + review * simplification * do not update state when component is unmounted * fix hover action on field name Co-authored-by: Elastic Machine Co-authored-by: Elastic Machine --- .../alerts_histogram_panel/index.tsx | 7 +- .../common/components/charts/barchart.tsx | 5 +- .../charts/draggable_legend_item.tsx | 4 +- .../drag_drop_context_wrapper.tsx | 12 +-- .../drag_and_drop/draggable_wrapper.tsx | 58 +++++++++--- .../draggable_wrapper_hover_content.test.tsx | 34 +++++++ .../draggable_wrapper_hover_content.tsx | 91 ++++++++++++++++--- .../common/components/draggables/index.tsx | 4 +- .../components/event_details/columns.tsx | 1 - .../components/matrix_histogram/index.tsx | 3 + .../components/matrix_histogram/types.ts | 5 +- .../common/components/top_n/index.test.tsx | 25 +++-- .../public/common/components/top_n/index.tsx | 77 ++++++++-------- .../public/common/components/top_n/top_n.tsx | 10 +- .../components/with_hover_actions/index.tsx | 26 +++--- .../public/common/containers/source/index.tsx | 49 ++++------ .../public/common/lib/keury/index.test.ts | 18 ++-- .../public/common/lib/keury/index.ts | 7 +- .../components/events_by_dataset/index.tsx | 3 + .../components/signals_by_category/index.tsx | 3 + .../components/fields_browser/field_items.tsx | 1 - .../fields_browser/field_name.test.tsx | 4 +- .../components/fields_browser/field_name.tsx | 41 +++++++-- .../components/manage_timeline/index.tsx | 47 +++++++--- .../components/timeline/body/index.test.tsx | 15 +++ .../components/timeline/body/index.tsx | 1 + .../timeline/body/stateful_body.tsx | 4 +- .../timelines/components/timeline/styles.tsx | 5 +- .../components/timeline/timeline.tsx | 11 ++- 29 files changed, 399 insertions(+), 172 deletions(-) diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.tsx index b6db6eb93d77f..b002700d7eff0 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.tsx @@ -69,6 +69,7 @@ interface AlertsHistogramPanelProps { showLinkToAlerts?: boolean; showTotalAlertsCount?: boolean; stackByOptions?: AlertsHistogramOption[]; + timelineId?: string; title?: string; to: number; updateDateRange: UpdateDateRange; @@ -98,8 +99,9 @@ export const AlertsHistogramPanel = memo( showLinkToAlerts = false, showTotalAlertsCount = false, stackByOptions, - to, + timelineId, title = i18n.HISTOGRAM_HEADER, + to, updateDateRange, }) => { // create a unique, but stable (across re-renders) query id @@ -163,11 +165,12 @@ export const AlertsHistogramPanel = memo( `draggable-legend-item-${uuid.v4()}-${selectedStackByOption.value}-${bucket.key}` ), field: selectedStackByOption.value, + timelineId, value: bucket.key, })) : NO_LEGEND_DATA, // eslint-disable-next-line react-hooks/exhaustive-deps - [alertsData, selectedStackByOption.value] + [alertsData, selectedStackByOption.value, timelineId] ); useEffect(() => { diff --git a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx index 42fc2ac9b8453..fba8c3faa9237 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx @@ -117,6 +117,7 @@ interface BarChartComponentProps { barChart: ChartSeriesData[] | null | undefined; configs?: ChartSeriesConfigs | undefined; stackByField?: string; + timelineId?: string; } const NO_LEGEND_DATA: LegendItem[] = []; @@ -125,6 +126,7 @@ export const BarChartComponent: React.FC = ({ barChart, configs, stackByField, + timelineId, }) => { const { ref: measureRef, width, height } = useThrottledResizeObserver(); const legendItems: LegendItem[] = useMemo( @@ -135,11 +137,12 @@ export const BarChartComponent: React.FC = ({ dataProviderId: escapeDataProviderId( `draggable-legend-item-${uuid.v4()}-${stackByField}-${d.key}` ), + timelineId, field: stackByField, value: d.key, })) : NO_LEGEND_DATA, - [barChart, stackByField] + [barChart, stackByField, timelineId] ); const customHeight = get('customHeight', configs); diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx index cdda1733932d5..bb71e5e73475d 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx @@ -21,13 +21,14 @@ export interface LegendItem { color?: string; dataProviderId: string; field: string; + timelineId?: string; value: string; } const DraggableLegendItemComponent: React.FC<{ legendItem: LegendItem; }> = ({ legendItem }) => { - const { color, dataProviderId, field, value } = legendItem; + const { color, dataProviderId, field, timelineId, value } = legendItem; return ( @@ -44,6 +45,7 @@ const DraggableLegendItemComponent: React.FC<{ data-test-subj={`legend-item-${dataProviderId}`} field={field} id={dataProviderId} + timelineId={timelineId} value={value} /> ) : ( diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx index 3edc1d0d84b69..74efe2d34fcca 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx @@ -18,11 +18,10 @@ import { IdToDataProvider } from '../../store/drag_and_drop/model'; import { State } from '../../store/types'; import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider'; import { reArrangeProviders } from '../../../timelines/components/timeline/data_providers/helpers'; -import { ACTIVE_TIMELINE_REDUX_ID } from '../top_n'; import { ADDED_TO_TIMELINE_MESSAGE } from '../../hooks/translations'; import { useAddToTimelineSensor } from '../../hooks/use_add_to_timeline'; import { displaySuccessToast, useStateToaster } from '../toasters'; - +import { TimelineId } from '../../../../common/types/timeline'; import { addFieldToTimelineColumns, addProviderToTimeline, @@ -35,7 +34,7 @@ import { userIsReArrangingProviders, } from './helpers'; -// @ts-ignore +// @ts-expect-error window['__react-beautiful-dnd-disable-dev-warnings'] = true; interface Props { @@ -67,7 +66,7 @@ const onDragEndHandler = ({ destination: result.destination, dispatch, source: result.source, - timelineId: ACTIVE_TIMELINE_REDUX_ID, + timelineId: TimelineId.active, }); } else if (providerWasDroppedOnTimeline(result)) { addProviderToTimeline({ @@ -76,7 +75,7 @@ const onDragEndHandler = ({ dispatch, onAddedToTimeline, result, - timelineId: ACTIVE_TIMELINE_REDUX_ID, + timelineId: TimelineId.active, }); } else if (fieldWasDroppedOnTimelineColumns(result)) { addFieldToTimelineColumns({ @@ -130,7 +129,6 @@ export const DragDropContextWrapperComponent = React.memo {children} @@ -152,7 +150,7 @@ const emptyActiveTimelineDataProviders: DataProvider[] = []; // stable reference const mapStateToProps = (state: State) => { const activeTimelineDataProviders = - timelineSelectors.getTimelineByIdSelector()(state, ACTIVE_TIMELINE_REDUX_ID)?.dataProviders ?? + timelineSelectors.getTimelineByIdSelector()(state, TimelineId.active)?.dataProviders ?? emptyActiveTimelineDataProviders; const dataProviders = dragAndDropSelectors.dataProvidersSelector(state) ?? emptyDataProviders; diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx index 22b95f0d0c0e9..e7594365e8103 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { Draggable, DraggableProvided, @@ -22,7 +22,7 @@ import { DataProvider } from '../../../timelines/components/timeline/data_provid import { TruncatableText } from '../truncatable_text'; import { WithHoverActions } from '../with_hover_actions'; -import { DraggableWrapperHoverContent } from './draggable_wrapper_hover_content'; +import { DraggableWrapperHoverContent, useGetTimelineId } from './draggable_wrapper_hover_content'; import { getDraggableId, getDroppableId } from './helpers'; import { ProviderContainer } from './provider_container'; @@ -76,6 +76,7 @@ interface Props { dataProvider: DataProvider; inline?: boolean; render: RenderFunctionProp; + timelineId?: string; truncate?: boolean; onFilterAdded?: () => void; } @@ -100,16 +101,31 @@ export const getStyle = ( }; export const DraggableWrapper = React.memo( - ({ dataProvider, onFilterAdded, render, truncate }) => { + ({ dataProvider, onFilterAdded, render, timelineId, truncate }) => { + const draggableRef = useRef(null); + const [closePopOverTrigger, setClosePopOverTrigger] = useState(false); const [showTopN, setShowTopN] = useState(false); - const toggleTopN = useCallback(() => { - setShowTopN(!showTopN); - }, [setShowTopN, showTopN]); - + const [goGetTimelineId, setGoGetTimelineId] = useState(false); + const timelineIdFind = useGetTimelineId(draggableRef, goGetTimelineId); const [providerRegistered, setProviderRegistered] = useState(false); const dispatch = useDispatch(); + const handleClosePopOverTrigger = useCallback( + () => setClosePopOverTrigger((prevClosePopOverTrigger) => !prevClosePopOverTrigger), + [] + ); + + const toggleTopN = useCallback(() => { + setShowTopN((prevShowTopN) => { + const newShowTopN = !prevShowTopN; + if (newShowTopN === false) { + handleClosePopOverTrigger(); + } + return newShowTopN; + }); + }, [handleClosePopOverTrigger]); + const registerProvider = useCallback(() => { if (!providerRegistered) { dispatch(dragAndDropActions.registerProvider({ provider: dataProvider })); @@ -126,17 +142,19 @@ export const DraggableWrapper = React.memo( () => () => { unRegisterProvider(); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [unRegisterProvider] ); const hoverContent = useMemo( () => ( ( } /> ), - [dataProvider, onFilterAdded, showTopN, toggleTopN] + [ + dataProvider, + handleClosePopOverTrigger, + onFilterAdded, + showTopN, + timelineId, + timelineIdFind, + toggleTopN, + ] ); const renderContent = useCallback( @@ -184,7 +210,10 @@ export const DraggableWrapper = React.memo( { + provided.innerRef(e); + draggableRef.current = e; + }} data-test-subj="providerContainer" isDragging={snapshot.isDragging} registerProvider={registerProvider} @@ -214,7 +243,12 @@ export const DraggableWrapper = React.memo( ); return ( - + ); }, (prevProps, nextProps) => diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx index ee1dc73b27fe2..3507b0f8c447d 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx @@ -52,6 +52,7 @@ jest.mock('../../../timelines/components/manage_timeline', () => { return { ...original, useManageTimeline: () => ({ + getManageTimelineById: jest.fn().mockReturnValue({ indexToAdd: [] }), getTimelineFilterManager: mockGetTimelineFilterManager, isManagedTimeline: jest.fn().mockReturnValue(false), }), @@ -63,8 +64,10 @@ const timelineId = TimelineId.active; const field = 'process.name'; const value = 'nice'; const toggleTopN = jest.fn(); +const goGetTimelineId = jest.fn(); const defaultProps = { field, + goGetTimelineId, showTopN: false, timelineId, toggleTopN, @@ -130,6 +133,18 @@ describe('DraggableWrapperHoverContent', () => { wrapper.find(`[data-test-subj="filter-${hoverAction}-value"]`).first().exists() ).toBe(false); }); + + test(`it should call goGetTimelineId when user is over the 'Filter ${hoverAction} value' button`, () => { + const wrapper = mount( + + + + ); + const button = wrapper.find(`[data-test-subj="filter-${hoverAction}-value"]`).first(); + button.simulate('mouseenter'); + expect(goGetTimelineId).toHaveBeenCalledWith(true); + }); + describe('when run in the context of a timeline', () => { let wrapper: ReactWrapper; let onFilterAdded: () => void; @@ -151,6 +166,7 @@ describe('DraggableWrapperHoverContent', () => { ); }); + test('when clicked, it adds a filter to the timeline when running in the context of a timeline', () => { wrapper.find(`[data-test-subj="filter-${hoverAction}-value"]`).first().simulate('click'); wrapper.update(); @@ -459,6 +475,24 @@ describe('DraggableWrapperHoverContent', () => { expect(wrapper.find('[data-test-subj="show-top-field"]').first().exists()).toBe(false); }); + test(`it should invokes goGetTimelineId when user is over the 'Show top field' button`, () => { + const whitelistedField = 'signal.rule.name'; + const wrapper = mount( + + + + ); + const button = wrapper.find(`[data-test-subj="show-top-field"]`).first(); + button.simulate('mouseenter'); + expect(goGetTimelineId).toHaveBeenCalledWith(true); + }); + test(`invokes the toggleTopN function when the 'Show top field' button is clicked`, async () => { const whitelistedField = 'signal.rule.name'; const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx index 4efdea5eee43b..a951bfa98d64b 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx @@ -5,7 +5,7 @@ */ import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState, useEffect } from 'react'; import { DraggableId } from 'react-beautiful-dnd'; import { getAllFieldsByName, useWithSource } from '../../containers/source'; @@ -19,20 +19,25 @@ import { allowTopN } from './helpers'; import * as i18n from './translations'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; import { TimelineId } from '../../../../common/types/timeline'; +import { SELECTOR_TIMELINE_BODY_CLASS_NAME } from '../../../timelines/components/timeline/styles'; interface Props { + closePopOver?: () => void; draggableId?: DraggableId; field: string; + goGetTimelineId?: (args: boolean) => void; onFilterAdded?: () => void; showTopN: boolean; - timelineId?: string; + timelineId?: string | null; toggleTopN: () => void; value?: string[] | string | null; } const DraggableWrapperHoverContentComponent: React.FC = ({ + closePopOver, draggableId, field, + goGetTimelineId, onFilterAdded, showTopN, timelineId, @@ -44,17 +49,37 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ const filterManagerBackup = useMemo(() => kibana.services.data.query.filterManager, [ kibana.services.data.query.filterManager, ]); - const { getTimelineFilterManager } = useManageTimeline(); + const { getManageTimelineById, getTimelineFilterManager } = useManageTimeline(); const filterManager = useMemo( () => - timelineId === TimelineId.active || - (draggableId != null && draggableId?.includes(TimelineId.active)) + timelineId === TimelineId.active ? getTimelineFilterManager(TimelineId.active) : filterManagerBackup, - [draggableId, timelineId, getTimelineFilterManager, filterManagerBackup] + [timelineId, getTimelineFilterManager, filterManagerBackup] ); + // Regarding data from useManageTimeline: + // * `indexToAdd`, which enables the alerts index to be appended to + // the `indexPattern` returned by `useWithSource`, may only be populated when + // this component is rendered in the context of the active timeline. This + // behavior enables the 'All events' view by appending the alerts index + // to the index pattern. + const { indexToAdd } = useMemo( + () => + timelineId === TimelineId.active + ? getManageTimelineById(TimelineId.active) + : { indexToAdd: null }, + [getManageTimelineById, timelineId] + ); + + const handleStartDragToTimeline = useCallback(() => { + startDragToTimeline(); + if (closePopOver != null) { + closePopOver(); + } + }, [closePopOver, startDragToTimeline]); + const filterForValue = useCallback(() => { const filter = value?.length === 0 ? createFilter(field, undefined) : createFilter(field, value); @@ -62,13 +87,15 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ if (activeFilterManager != null) { activeFilterManager.addFilters(filter); - + if (closePopOver != null) { + closePopOver(); + } if (onFilterAdded != null) { onFilterAdded(); } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [field, value, filterManager, onFilterAdded]); + }, [closePopOver, field, value, filterManager, onFilterAdded]); const filterOutValue = useCallback(() => { const filter = @@ -78,14 +105,23 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ if (activeFilterManager != null) { activeFilterManager.addFilters(filter); + if (closePopOver != null) { + closePopOver(); + } if (onFilterAdded != null) { onFilterAdded(); } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [field, value, filterManager, onFilterAdded]); + }, [closePopOver, field, value, filterManager, onFilterAdded]); - const { browserFields } = useWithSource(); + const handleGoGetTimelineId = useCallback(() => { + if (goGetTimelineId != null && timelineId == null) { + goGetTimelineId(true); + } + }, [goGetTimelineId, timelineId]); + + const { browserFields, indexPattern } = useWithSource('default', indexToAdd); return ( <> @@ -97,6 +133,7 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ data-test-subj="filter-for-value" iconType="magnifyWithPlus" onClick={filterForValue} + onMouseEnter={handleGoGetTimelineId} /> )} @@ -109,6 +146,7 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ data-test-subj="filter-out-value" iconType="magnifyWithMinus" onClick={filterOutValue} + onMouseEnter={handleGoGetTimelineId} /> )} @@ -120,7 +158,7 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ color="text" data-test-subj="add-to-timeline" iconType="timeline" - onClick={startDragToTimeline} + onClick={handleStartDragToTimeline} /> )} @@ -139,6 +177,7 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ data-test-subj="show-top-field" iconType="visBarVertical" onClick={toggleTopN} + onMouseEnter={handleGoGetTimelineId} /> )} @@ -147,7 +186,10 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ @@ -172,3 +214,30 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ DraggableWrapperHoverContentComponent.displayName = 'DraggableWrapperHoverContentComponent'; export const DraggableWrapperHoverContent = React.memo(DraggableWrapperHoverContentComponent); + +export const useGetTimelineId = function ( + elem: React.MutableRefObject, + getTimelineId: boolean = false +) { + const [timelineId, setTimelineId] = useState(null); + + useEffect(() => { + let startElem: Element | (Node & ParentNode) | null = elem.current; + if (startElem != null && getTimelineId) { + for (; startElem && startElem !== document; startElem = startElem.parentNode) { + const myElem: Element = startElem as Element; + if ( + myElem != null && + myElem.classList != null && + myElem.classList.contains(SELECTOR_TIMELINE_BODY_CLASS_NAME) && + myElem.hasAttribute('data-timeline-id') + ) { + setTimelineId(myElem.getAttribute('data-timeline-id')); + break; + } + } + } + }, [elem, getTimelineId]); + + return timelineId; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx b/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx index fcf007a4cf1ba..62a07550650aa 100644 --- a/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx @@ -21,6 +21,7 @@ export interface DefaultDraggableType { name?: string | null; queryValue?: string | null; children?: React.ReactNode; + timelineId?: string; tooltipContent?: React.ReactNode; } @@ -83,7 +84,7 @@ Content.displayName = 'Content'; * @param queryValue - defaults to `value`, this query overrides the `queryMatch.value` used by the `DataProvider` that represents the data */ export const DefaultDraggable = React.memo( - ({ id, field, value, name, children, tooltipContent, queryValue }) => + ({ id, field, value, name, children, timelineId, tooltipContent, queryValue }) => value != null ? ( ( ) } + timelineId={timelineId} /> ) : null ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx index e01ccf1e544bb..7b6e9fb21a3e3 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx @@ -147,7 +147,6 @@ export const getColumns = ({ data-test-subj="field-name" fieldId={field} onUpdateColumns={onUpdateColumns} - timelineId={contextId} />
)} diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx index 3e196c4b7bad4..31f7e1b7fac7c 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx @@ -53,6 +53,7 @@ export interface OwnProps extends QueryTemplateProps { showLegend?: boolean; stackByOptions: MatrixHistogramOption[]; subtitle?: string | GetSubTitle; + timelineId?: string; title: string | GetTitle; type: hostsModel.HostsType | networkModel.NetworkType; } @@ -94,6 +95,7 @@ export const MatrixHistogramComponent: React.FC< stackByOptions, startDate, subtitle, + timelineId, title, titleSize, dispatchSetAbsoluteRangeDatePicker, @@ -242,6 +244,7 @@ export const MatrixHistogramComponent: React.FC< barChart={barChartData} configs={barchartConfigs} stackByField={selectedStackByOption.value} + timelineId={timelineId} /> )} diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts index a9e6cdd19bb20..f388409b443db 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts @@ -80,11 +80,12 @@ export interface MatrixHistogramQueryProps { } export interface MatrixHistogramProps extends MatrixHistogramBasicProps { + legendPosition?: Position; scaleType?: ScaleType; - yTickFormatter?: (value: number) => string; showLegend?: boolean; showSpacer?: boolean; - legendPosition?: Position; + timelineId?: string; + yTickFormatter?: (value: number) => string; } export interface HistogramBucket { diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx index 336f906b3bed0..503e9983692f1 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx @@ -15,17 +15,19 @@ import { SUB_PLUGINS_REDUCER, kibanaObservable, createSecuritySolutionStorageMock, + mockIndexPattern, } from '../../mock'; import { createKibanaCoreStartMock } from '../../mock/kibana_core'; import { FilterManager } from '../../../../../../../src/plugins/data/public'; import { createStore, State } from '../../store'; import { Props } from './top_n'; -import { ACTIVE_TIMELINE_REDUX_ID, StatefulTopN } from '.'; +import { StatefulTopN } from '.'; import { ManageGlobalTimeline, timelineDefaults, } from '../../../timelines/components/manage_timeline'; +import { TimelineId } from '../../../../common/types/timeline'; jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); @@ -94,9 +96,9 @@ const state: State = { timeline: { ...mockGlobalState.timeline, timelineById: { - [ACTIVE_TIMELINE_REDUX_ID]: { + [TimelineId.active]: { ...mockGlobalState.timeline.timelineById.test, - id: ACTIVE_TIMELINE_REDUX_ID, + id: TimelineId.active, dataProviders: [ { id: @@ -189,6 +191,9 @@ describe('StatefulTopN', () => { { beforeEach(() => { filterManager = new FilterManager(mockUiSettingsForFilterManager); const manageTimelineForTesting = { - [ACTIVE_TIMELINE_REDUX_ID]: { + [TimelineId.active]: { ...timelineDefaults, - id: ACTIVE_TIMELINE_REDUX_ID, + id: TimelineId.active, filterManager, }, }; @@ -278,6 +283,9 @@ describe('StatefulTopN', () => { { const filterManager = new FilterManager(mockUiSettingsForFilterManager); const manageTimelineForTesting = { - [ACTIVE_TIMELINE_REDUX_ID]: { + [TimelineId.active]: { ...timelineDefaults, - id: ACTIVE_TIMELINE_REDUX_ID, + id: TimelineId.active, filterManager, documentType: 'alerts', }, @@ -356,6 +364,9 @@ describe('StatefulTopN', () => { { // filters that appear at the top of most views in the app, and all the // filters in the active timeline: const mapStateToProps = (state: State) => { - const activeTimeline: TimelineModel = - getTimeline(state, ACTIVE_TIMELINE_REDUX_ID) ?? timelineDefaults; + const activeTimeline: TimelineModel = getTimeline(state, TimelineId.active) ?? timelineDefaults; const activeTimelineFilters = activeTimeline.filters ?? EMPTY_FILTERS; const activeTimelineInput: inputsModel.InputsRange = getInputsTimeline(state); @@ -48,7 +49,7 @@ const makeMapStateToProps = () => { activeTimelineEventType: activeTimeline.eventType, activeTimelineFilters, activeTimelineFrom: activeTimelineInput.timerange.from, - activeTimelineKqlQueryExpression: getKqlQueryTimeline(state, ACTIVE_TIMELINE_REDUX_ID), + activeTimelineKqlQueryExpression: getKqlQueryTimeline(state, TimelineId.active), activeTimelineTo: activeTimelineInput.timerange.to, dataProviders: activeTimeline.dataProviders, globalQuery: getGlobalQuerySelector(state), @@ -64,9 +65,17 @@ const mapDispatchToProps = { setAbsoluteRangeDatePicker: dispatchSetAbsoluteRang const connector = connect(makeMapStateToProps, mapDispatchToProps); +// * `indexToAdd`, which enables the alerts index to be appended to +// the `indexPattern` returned by `useWithSource`, may only be populated when +// this component is rendered in the context of the active timeline. This +// behavior enables the 'All events' view by appending the alerts index +// to the index pattern. interface OwnProps { browserFields: BrowserFields; field: string; + indexPattern: IIndexPattern; + indexToAdd: string[] | null; + timelineId?: string; toggleTopN: () => void; onFilterAdded?: () => void; value?: string[] | string | null; @@ -83,48 +92,29 @@ const StatefulTopNComponent: React.FC = ({ browserFields, dataProviders, field, + indexPattern, + indexToAdd, globalFilters = EMPTY_FILTERS, globalQuery = EMPTY_QUERY, kqlMode, onFilterAdded, setAbsoluteRangeDatePicker, + timelineId, toggleTopN, value, }) => { const kibana = useKibana(); - // Regarding data from useTimelineTypeContext: - // * `documentType` (e.g. 'alerts') may only be populated in some views, - // e.g. the `Alerts` view on the `Detections` page. - // * `id` (`timelineId`) may only be populated when we are rendered in the - // context of the active timeline. - // * `indexToAdd`, which enables the alerts index to be appended to - // the `indexPattern` returned by `useWithSource`, may only be populated when - // this component is rendered in the context of the active timeline. This - // behavior enables the 'All events' view by appending the alerts index - // to the index pattern. - const { isManagedTimeline, getManageTimelineById } = useManageTimeline(); - const { documentType, id: timelineId, indexToAdd } = useMemo( - () => - isManagedTimeline(ACTIVE_TIMELINE_REDUX_ID) - ? getManageTimelineById(ACTIVE_TIMELINE_REDUX_ID) - : { documentType: null, id: null, indexToAdd: null }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [getManageTimelineById] - ); - const options = getOptions( - timelineId === ACTIVE_TIMELINE_REDUX_ID ? activeTimelineEventType : undefined + timelineId === TimelineId.active ? activeTimelineEventType : undefined ); - const { indexPattern } = useWithSource('default', indexToAdd); - return ( {({ from, deleteQuery, setQuery, to }) => ( = ({ : undefined } data-test-subj="top-n" - defaultView={documentType?.toLocaleLowerCase() === 'alerts' ? 'alert' : options[0].value} - deleteQuery={timelineId === ACTIVE_TIMELINE_REDUX_ID ? undefined : deleteQuery} + defaultView={ + timelineId === TimelineId.alertsPage || timelineId === TimelineId.alertsRulesDetailsPage + ? 'alert' + : options[0].value + } + deleteQuery={timelineId === TimelineId.active ? undefined : deleteQuery} field={field} - filters={timelineId === ACTIVE_TIMELINE_REDUX_ID ? EMPTY_FILTERS : globalFilters} - from={timelineId === ACTIVE_TIMELINE_REDUX_ID ? activeTimelineFrom : from} + filters={timelineId === TimelineId.active ? EMPTY_FILTERS : globalFilters} + from={timelineId === TimelineId.active ? activeTimelineFrom : from} indexPattern={indexPattern} indexToAdd={indexToAdd} options={options} - query={timelineId === ACTIVE_TIMELINE_REDUX_ID ? EMPTY_QUERY : globalQuery} + query={timelineId === TimelineId.active ? EMPTY_QUERY : globalQuery} setAbsoluteRangeDatePicker={setAbsoluteRangeDatePicker} setAbsoluteRangeDatePickerTarget={ - timelineId === ACTIVE_TIMELINE_REDUX_ID ? 'timeline' : 'global' + timelineId === TimelineId.active ? 'timeline' : 'global' } setQuery={setQuery} - to={timelineId === ACTIVE_TIMELINE_REDUX_ID ? activeTimelineTo : to} + timelineId={timelineId} + to={timelineId === TimelineId.active ? activeTimelineTo : to} toggleTopN={toggleTopN} onFilterAdded={onFilterAdded} value={value} diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx index 0ccb7e1e72f1f..7d19bf21271aa 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx @@ -5,7 +5,7 @@ */ import { EuiButtonIcon, EuiSuperSelect } from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import { ActionCreator } from 'typescript-fsa'; @@ -67,6 +67,7 @@ export interface Props { refetch: inputsModel.Refetch; }) => void; to: number; + timelineId?: string; toggleTopN: () => void; onFilterAdded?: () => void; value?: string[] | string | null; @@ -89,12 +90,17 @@ const TopNComponent: React.FC = ({ setAbsoluteRangeDatePicker, setAbsoluteRangeDatePickerTarget, setQuery, + timelineId, to, toggleTopN, }) => { const [view, setView] = useState(defaultView); const onViewSelected = useCallback((value: string) => setView(value as EventType), [setView]); + useEffect(() => { + setView(defaultView); + }, [defaultView]); + const headerChildren = useMemo( () => ( = ({ setAbsoluteRangeDatePickerTarget={setAbsoluteRangeDatePickerTarget} setQuery={setQuery} showSpacer={false} + timelineId={timelineId} to={to} /> ) : ( @@ -145,6 +152,7 @@ const TopNComponent: React.FC = ({ setAbsoluteRangeDatePicker={setAbsoluteRangeDatePicker} setAbsoluteRangeDatePickerTarget={setAbsoluteRangeDatePickerTarget} setQuery={setQuery} + timelineId={timelineId} to={to} /> )} diff --git a/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx b/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx index 8679dae448332..361779a4a33b2 100644 --- a/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx @@ -5,7 +5,7 @@ */ import { EuiPopover } from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import { IS_DRAGGING_CLASS_NAME } from '../drag_and_drop/helpers'; @@ -22,6 +22,7 @@ interface Props { * Always show the hover menu contents (default: false) */ alwaysShow?: boolean; + closePopOverTrigger?: boolean; /** * The contents of the hover menu. It is highly recommended you wrap this * content in a `div` with `position: absolute` to prevent it from effecting @@ -47,7 +48,8 @@ interface Props { * provides a signal to the content that the user is in a hover state. */ export const WithHoverActions = React.memo( - ({ alwaysShow = false, hoverContent, render }) => { + ({ alwaysShow = false, closePopOverTrigger, hoverContent, render }) => { + const [isOpen, setIsOpen] = useState(hoverContent != null && alwaysShow); const [showHoverContent, setShowHoverContent] = useState(false); const onMouseEnter = useCallback(() => { // NOTE: the following read from the DOM is expensive, but not as @@ -64,10 +66,16 @@ export const WithHoverActions = React.memo( const content = useMemo(() => <>{render(showHoverContent)}, [render, showHoverContent]); - const isOpen = hoverContent != null && (showHoverContent || alwaysShow); + useEffect(() => { + setIsOpen(hoverContent != null && (showHoverContent || alwaysShow)); + }, [hoverContent, showHoverContent, alwaysShow]); - const popover = useMemo(() => { - return ( + useEffect(() => { + setShowHoverContent(false); + }, [closePopOverTrigger]); + + return ( +
( isOpen={isOpen} panelPaddingSize={!alwaysShow ? 's' : 'none'} > - {isOpen ? hoverContent : null} + {isOpen ? <>{hoverContent} : null} - ); - }, [content, onMouseLeave, isOpen, alwaysShow, hoverContent]); - - return ( -
- {popover}
); } diff --git a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx index 9aa3b007511a1..4f42f20c45ae1 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx @@ -97,7 +97,7 @@ export const useWithSource = ( const [configIndex] = useUiSetting$(DEFAULT_INDEX_KEY); const defaultIndex = useMemo(() => { if (indexToAdd != null && !isEmpty(indexToAdd)) { - return [...(!onlyCheckIndexToAdd ? configIndex : []), ...indexToAdd]; + return onlyCheckIndexToAdd ? indexToAdd : [...configIndex, ...indexToAdd]; } return configIndex; }, [configIndex, indexToAdd, onlyCheckIndexToAdd]); @@ -135,41 +135,32 @@ export const useWithSource = ( }, }, }); - if (!isSubscribed) { - return setState((prevState) => ({ - ...prevState, + + if (isSubscribed) { + setState({ loading: false, - })); + indicesExist: indicesExistOrDataTemporarilyUnavailable( + get('data.source.status.indicesExist', result) + ), + browserFields: getBrowserFields( + defaultIndex.join(), + get('data.source.status.indexFields', result) + ), + indexPattern: getIndexFields( + defaultIndex.join(), + get('data.source.status.indexFields', result) + ), + errorMessage: null, + }); } - - setState({ - loading: false, - indicesExist: indicesExistOrDataTemporarilyUnavailable( - get('data.source.status.indicesExist', result) - ), - browserFields: getBrowserFields( - defaultIndex.join(), - get('data.source.status.indexFields', result) - ), - indexPattern: getIndexFields( - defaultIndex.join(), - get('data.source.status.indexFields', result) - ), - errorMessage: null, - }); } catch (error) { - if (!isSubscribed) { - return setState((prevState) => ({ + if (isSubscribed) { + setState((prevState) => ({ ...prevState, loading: false, + errorMessage: error.message, })); } - - setState((prevState) => ({ - ...prevState, - loading: false, - errorMessage: error.message, - })); } } diff --git a/x-pack/plugins/security_solution/public/common/lib/keury/index.test.ts b/x-pack/plugins/security_solution/public/common/lib/keury/index.test.ts index 4e2b11b24e5a9..e84438581fcde 100644 --- a/x-pack/plugins/security_solution/public/common/lib/keury/index.test.ts +++ b/x-pack/plugins/security_solution/public/common/lib/keury/index.test.ts @@ -26,33 +26,33 @@ describe('Kuery escape', () => { expect(escapeKuery(value)).to.be(expected); }); - it('should escape keywords', () => { + it('should NOT escape keywords', () => { const value = 'foo and bar or baz not qux'; - const expected = 'foo \\and bar \\or baz \\not qux'; + const expected = 'foo and bar or baz not qux'; expect(escapeKuery(value)).to.be(expected); }); - it('should escape keywords next to each other', () => { + it('should NOT escape keywords next to each other', () => { const value = 'foo and bar or not baz'; - const expected = 'foo \\and bar \\or \\not baz'; + const expected = 'foo and bar or not baz'; expect(escapeKuery(value)).to.be(expected); }); it('should not escape keywords without surrounding spaces', () => { const value = 'And this has keywords, or does it not?'; - const expected = 'And this has keywords, \\or does it not?'; + const expected = 'And this has keywords, or does it not?'; expect(escapeKuery(value)).to.be(expected); }); - it('should escape uppercase keywords', () => { + it('should NOT escape uppercase keywords', () => { const value = 'foo AND bar'; - const expected = 'foo \\AND bar'; + const expected = 'foo AND bar'; expect(escapeKuery(value)).to.be(expected); }); - it('should escape both keywords and special characters', () => { + it('should escape special characters and NOT keywords', () => { const value = 'Hello, "world", and to meet you!'; - const expected = 'Hello, \\"world\\", \\and to meet you!'; + const expected = 'Hello, \\"world\\", and to meet you!'; expect(escapeKuery(value)).to.be(expected); }); diff --git a/x-pack/plugins/security_solution/public/common/lib/keury/index.ts b/x-pack/plugins/security_solution/public/common/lib/keury/index.ts index bd4d96a98c815..b06a6ec10f48e 100644 --- a/x-pack/plugins/security_solution/public/common/lib/keury/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/keury/index.ts @@ -75,11 +75,12 @@ const escapeWhitespace = (val: string) => const escapeSpecialCharacters = (val: string) => val.replace(/["]/g, '\\$&'); // $& means the whole matched string // See the Keyword rule in kuery.peg -const escapeAndOr = (val: string) => val.replace(/(\s+)(and|or)(\s+)/gi, '$1\\$2$3'); +// I do not think that we need that anymore since we are doing a full match_phrase all the time now => return `"${escapeKuery(val)}"`; +// const escapeAndOr = (val: string) => val.replace(/(\s+)(and|or)(\s+)/gi, '$1\\$2$3'); -const escapeNot = (val: string) => val.replace(/not(\s+)/gi, '\\$&'); +// const escapeNot = (val: string) => val.replace(/not(\s+)/gi, '\\$&'); -export const escapeKuery = flow(escapeSpecialCharacters, escapeAndOr, escapeNot, escapeWhitespace); +export const escapeKuery = flow(escapeSpecialCharacters, escapeWhitespace); export const convertToBuildEsQuery = ({ config, diff --git a/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx b/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx index fe3f9f8ecda33..7d42f744a2613 100644 --- a/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx @@ -60,6 +60,7 @@ interface Props { refetch: inputsModel.Refetch; }) => void; showSpacer?: boolean; + timelineId?: string; to: number; } @@ -81,6 +82,7 @@ const EventsByDatasetComponent: React.FC = ({ setAbsoluteRangeDatePickerTarget, setQuery, showSpacer = true, + timelineId, to, }) => { // create a unique, but stable (across re-renders) query id @@ -177,6 +179,7 @@ const EventsByDatasetComponent: React.FC = ({ showSpacer={showSpacer} sourceId="default" startDate={from} + timelineId={timelineId} type={HostsType.page} {...eventsByDatasetHistogramConfigs} title={onlyField != null ? i18n.TOP(onlyField) : eventsByDatasetHistogramConfigs.title} diff --git a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx index 5010fd9c06eb7..41152dabe2ad8 100644 --- a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx @@ -37,6 +37,7 @@ interface Props { loading: boolean; refetch: inputsModel.Refetch; }) => void; + timelineId?: string; to: number; } @@ -50,6 +51,7 @@ const SignalsByCategoryComponent: React.FC = ({ setAbsoluteRangeDatePicker, setAbsoluteRangeDatePickerTarget = 'global', setQuery, + timelineId, to, }) => { const { signalIndexName } = useSignalIndex(); @@ -83,6 +85,7 @@ const SignalsByCategoryComponent: React.FC = ({ showLinkToAlerts={onlyField == null ? true : false} stackByOptions={onlyField == null ? alertsHistogramOptions : undefined} legendPosition={'right'} + timelineId={timelineId} to={to} title={i18n.ALERT_COUNT} updateDateRange={updateDateRangeCallback} diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx index aaad9cf145ab7..8922434746234 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx @@ -152,7 +152,6 @@ export const getFieldItems = ({ fieldId={field.name || ''} highlight={highlight} onUpdateColumns={onUpdateColumns} - timelineId={timelineId} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx index da0cbb99b8671..1f917c664e813 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx @@ -24,7 +24,6 @@ const defaultProps = { }), fieldId: timestampFieldId, onUpdateColumns: jest.fn(), - timelineId: 'timeline-id', }; describe('FieldName', () => { @@ -46,8 +45,7 @@ describe('FieldName', () => { ); - - wrapper.simulate('mouseenter'); + wrapper.find('div').at(1).simulate('mouseenter'); wrapper.update(); expect(wrapper.find('[data-test-subj="copy-to-clipboard"]').exists()).toBe(true); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx index 985c8b35094ef..62e41d967cb9a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx @@ -5,13 +5,16 @@ */ import { EuiHighlight, EuiText } from '@elastic/eui'; -import React, { useCallback, useState, useMemo } from 'react'; +import React, { useCallback, useState, useMemo, useRef } from 'react'; import styled from 'styled-components'; import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { OnUpdateColumns } from '../timeline/events'; import { WithHoverActions } from '../../../common/components/with_hover_actions'; -import { DraggableWrapperHoverContent } from '../../../common/components/drag_and_drop/draggable_wrapper_hover_content'; +import { + DraggableWrapperHoverContent, + useGetTimelineId, +} from '../../../common/components/drag_and_drop/draggable_wrapper_hover_content'; /** * The name of a (draggable) field @@ -77,23 +80,34 @@ export const FieldName = React.memo<{ fieldId: string; highlight?: string; onUpdateColumns: OnUpdateColumns; - timelineId: string; -}>(({ fieldId, highlight = '', timelineId }) => { +}>(({ fieldId, highlight = '' }) => { + const containerRef = useRef(null); + const [closePopOverTrigger, setClosePopOverTrigger] = useState(false); const [showTopN, setShowTopN] = useState(false); + const [goGetTimelineId, setGoGetTimelineId] = useState(false); + const timelineIdFind = useGetTimelineId(containerRef, goGetTimelineId); + const toggleTopN = useCallback(() => { - setShowTopN(!showTopN); - }, [setShowTopN, showTopN]); + setShowTopN((prevShowTopN) => !prevShowTopN); + }, []); + + const handleClosePopOverTrigger = useCallback( + () => setClosePopOverTrigger((prevClosePopOverTrigger) => !prevClosePopOverTrigger), + [] + ); const hoverContent = useMemo( () => ( ), - [fieldId, showTopN, toggleTopN, timelineId] + [fieldId, handleClosePopOverTrigger, showTopN, timelineIdFind, toggleTopN] ); const render = useCallback( @@ -109,7 +123,16 @@ export const FieldName = React.memo<{ [fieldId, highlight] ); - return ; + return ( +
+ +
+ ); }); FieldName.displayName = 'FieldName'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx index be655f7465a1b..3b40c36fccd16 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx @@ -57,6 +57,11 @@ type ActionManageTimeline = id: string; payload: boolean; } + | { + type: 'SET_INDEX_TO_ADD'; + id: string; + payload: string[]; + } | { type: 'SET_TIMELINE_ACTIONS'; id: string; @@ -81,7 +86,10 @@ export const timelineDefaults = { title: i18n.EVENTS, unit: (n: number) => i18n.UNIT(n), }; -const reducerManageTimeline = (state: ManageTimelineById, action: ActionManageTimeline) => { +const reducerManageTimeline = ( + state: ManageTimelineById, + action: ActionManageTimeline +): ManageTimelineById => { switch (action.type) { case 'INITIALIZE_TIMELINE': return { @@ -91,7 +99,15 @@ const reducerManageTimeline = (state: ManageTimelineById, action: ActionManageTi ...state[action.id], ...action.payload, }, - }; + } as ManageTimelineById; + case 'SET_INDEX_TO_ADD': + return { + ...state, + [action.id]: { + ...state[action.id], + indexToAdd: action.payload, + }, + } as ManageTimelineById; case 'SET_TIMELINE_ACTIONS': case 'SET_TIMELINE_FILTER_MANAGER': return { @@ -100,7 +116,7 @@ const reducerManageTimeline = (state: ManageTimelineById, action: ActionManageTi ...state[action.id], ...action.payload, }, - }; + } as ManageTimelineById; case 'SET_IS_LOADING': return { ...state, @@ -108,7 +124,7 @@ const reducerManageTimeline = (state: ManageTimelineById, action: ActionManageTi ...state[action.id], isLoading: action.payload, }, - }; + } as ManageTimelineById; default: return state; } @@ -119,6 +135,7 @@ interface UseTimelineManager { getTimelineFilterManager: (id: string) => FilterManager | undefined; initializeTimeline: (newTimeline: ManageTimelineInit) => void; isManagedTimeline: (id: string) => boolean; + setIndexToAdd: (indexToAddArgs: { id: string; indexToAdd: string[] }) => void; setIsTimelineLoading: (isLoadingArgs: { id: string; isLoading: boolean }) => void; setTimelineRowActions: (actionsArgs: { id: string; @@ -129,10 +146,9 @@ interface UseTimelineManager { } const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseTimelineManager => { - const [state, dispatch] = useReducer( - reducerManageTimeline, - manageTimelineForTesting ?? initManageTimeline - ); + const [state, dispatch] = useReducer< + (state: ManageTimelineById, action: ActionManageTimeline) => ManageTimelineById + >(reducerManageTimeline, manageTimelineForTesting ?? initManageTimeline); const initializeTimeline = useCallback((newTimeline: ManageTimelineInit) => { dispatch({ @@ -183,8 +199,16 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT [] ); + const setIndexToAdd = useCallback(({ id, indexToAdd }: { id: string; indexToAdd: string[] }) => { + dispatch({ + type: 'SET_INDEX_TO_ADD', + id, + payload: indexToAdd, + }); + }, []); + const getTimelineFilterManager = useCallback( - (id: string): FilterManager | undefined => state[id].filterManager, + (id: string): FilterManager | undefined => state[id]?.filterManager, [state] ); const getManageTimelineById = useCallback( @@ -195,8 +219,7 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT initializeTimeline({ id }); return { ...timelineDefaults, id }; }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [state] + [initializeTimeline, state] ); const isManagedTimeline = useCallback((id: string): boolean => state[id] != null, [state]); @@ -205,6 +228,7 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT getTimelineFilterManager, initializeTimeline, isManagedTimeline, + setIndexToAdd, setIsTimelineLoading, setTimelineRowActions, setTimelineFilterManager, @@ -214,6 +238,7 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT const init = { getManageTimelineById: (id: string) => ({ ...timelineDefaults, id }), getTimelineFilterManager: () => undefined, + setIndexToAdd: () => undefined, isManagedTimeline: () => false, initializeTimeline: () => noop, setIsTimelineLoading: () => noop, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 51bf883ed2d61..43ea5e905ca8b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -17,6 +17,7 @@ import { columnRenderers, rowRenderers } from './renderers'; import { Sort } from './sort'; import { wait } from '../../../../common/lib/helpers'; import { useMountAppended } from '../../../../common/utils/use_mount_appended'; +import { SELECTOR_TIMELINE_BODY_CLASS_NAME } from '../styles'; const testBodyHeight = 700; const mockGetNotesByIds = (eventId: string[]) => []; @@ -133,6 +134,20 @@ describe('Body', () => { ).toEqual(true); }); }, 20000); + + test(`it add attribute data-timeline-id in ${SELECTOR_TIMELINE_BODY_CLASS_NAME}`, () => { + const wrapper = mount( + + + + ); + expect( + wrapper + .find(`[data-timeline-id="timeline-test"].${SELECTOR_TIMELINE_BODY_CLASS_NAME}`) + .first() + .exists() + ).toEqual(true); + }); }); describe('action on event', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 46895c86de084..6a296170fffde 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -139,6 +139,7 @@ export const Body = React.memo( )} ( pinnedEventIds={pinnedEventIds} rowRenderers={showRowRenderers ? rowRenderers : [plainRowRenderer]} selectedEventIds={selectedEventIds} - show={id === ACTIVE_TIMELINE_REDUX_ID ? show : true} + show={id === TimelineId.active ? show : true} showCheckboxes={showCheckboxes} sort={sort} toggleColumn={toggleColumn} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx index d1a58dafcb328..47d848021ba43 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx @@ -14,16 +14,17 @@ import { IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME } from '../../../common/component /** * TIMELINE BODY */ +export const SELECTOR_TIMELINE_BODY_CLASS_NAME = 'securitySolutionTimeline__body'; // SIDE EFFECT: the following creates a global class selector export const TimelineBodyGlobalStyle = createGlobalStyle` - body.${IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME} .siemTimeline__body { + body.${IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME} .${SELECTOR_TIMELINE_BODY_CLASS_NAME} { overflow: hidden; } `; export const TimelineBody = styled.div.attrs(({ className = '' }) => ({ - className: `siemTimeline__body ${className}`, + className: `${SELECTOR_TIMELINE_BODY_CLASS_NAME} ${className}`, }))<{ bodyHeight?: number; visible: boolean }>` height: ${({ bodyHeight }) => (bodyHeight ? `${bodyHeight}px` : 'auto')}; overflow: auto; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx index 07d4b004d2eda..18deaf0158723 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx @@ -174,6 +174,7 @@ export const TimelineComponent: React.FC = ({ const [isQueryLoading, setIsQueryLoading] = useState(false); const { initializeTimeline, + setIndexToAdd, setIsTimelineLoading, setTimelineFilterManager, setTimelineRowActions, @@ -188,12 +189,14 @@ export const TimelineComponent: React.FC = ({ }, []); useEffect(() => { setIsTimelineLoading({ id, isLoading: isQueryLoading || loadingIndexName }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loadingIndexName, isQueryLoading]); + }, [loadingIndexName, id, isQueryLoading, setIsTimelineLoading]); useEffect(() => { setTimelineFilterManager({ id, filterManager }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filterManager]); + }, [filterManager, id, setTimelineFilterManager]); + + useEffect(() => { + setIndexToAdd({ id, indexToAdd }); + }, [id, indexToAdd, setIndexToAdd]); return ( From 991fdbd95d9db143a47446b4739d20c9905516d6 Mon Sep 17 00:00:00 2001 From: Lee Drengenberg Date: Fri, 3 Jul 2020 15:57:17 -0500 Subject: [PATCH 13/16] [7.x] logout from transform_poweruser user in after method of transform tests (#70644) (#70738) Co-authored-by: Elastic Machine Co-authored-by: Elastic Machine --- x-pack/test/functional/apps/transform/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/transform/index.ts b/x-pack/test/functional/apps/transform/index.ts index 04a751279bf3c..6dd22a1f4a204 100644 --- a/x-pack/test/functional/apps/transform/index.ts +++ b/x-pack/test/functional/apps/transform/index.ts @@ -5,9 +5,10 @@ */ import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, loadTestFile }: FtrProviderContext) { +export default function ({ getService, loadTestFile, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const transform = getService('transform'); + const PageObjects = getPageObjects(['security']); describe('transform', function () { this.tags(['ciGroup9', 'transform']); @@ -30,6 +31,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await esArchiver.unload('ml/ecommerce'); await transform.testResources.resetKibanaTimeZone(); + await PageObjects.security.logout(); }); loadTestFile(require.resolve('./creation_index_pattern')); From 829d37856d3d26e9b2e1c3982ea1b68e5af15807 Mon Sep 17 00:00:00 2001 From: Dmitry Lemeshko Date: Sat, 4 Jul 2020 01:26:12 +0200 Subject: [PATCH 14/16] [functional tests] test url field formatter on dashboard and discover (#70736) (#70747) Co-authored-by: Elastic Machine Co-authored-by: Elastic Machine --- test/functional/apps/dashboard/index.js | 1 + .../apps/dashboard/url_field_formatter.ts | 91 +++++++++++++++++++ .../page_objects/visualize_chart_page.ts | 14 +++ 3 files changed, 106 insertions(+) create mode 100644 test/functional/apps/dashboard/url_field_formatter.ts diff --git a/test/functional/apps/dashboard/index.js b/test/functional/apps/dashboard/index.js index 1e310c1ddd268..5a30456bd59ab 100644 --- a/test/functional/apps/dashboard/index.js +++ b/test/functional/apps/dashboard/index.js @@ -49,6 +49,7 @@ export default function ({ getService, loadTestFile }) { after(unloadCurrentData); loadTestFile(require.resolve('./empty_dashboard')); + loadTestFile(require.resolve('./url_field_formatter')); loadTestFile(require.resolve('./embeddable_rendering')); loadTestFile(require.resolve('./create_and_add_embeddables')); loadTestFile(require.resolve('./edit_embeddable_redirects')); diff --git a/test/functional/apps/dashboard/url_field_formatter.ts b/test/functional/apps/dashboard/url_field_formatter.ts new file mode 100644 index 0000000000000..9b05b9b777b94 --- /dev/null +++ b/test/functional/apps/dashboard/url_field_formatter.ts @@ -0,0 +1,91 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@kbn/expect'; +import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const { common, dashboard, settings, timePicker, visChart } = getPageObjects([ + 'common', + 'dashboard', + 'settings', + 'timePicker', + 'visChart', + ]); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + const fieldName = 'clientip'; + + const clickFieldAndCheckUrl = async (fieldLink: WebElementWrapper) => { + const fieldValue = await fieldLink.getVisibleText(); + await fieldLink.click(); + const windowHandlers = await browser.getAllWindowHandles(); + expect(windowHandlers.length).to.equal(2); + await browser.switchToWindow(windowHandlers[1]); + const currentUrl = await browser.getCurrentUrl(); + const fieldUrl = common.getHostPort() + '/app/' + fieldValue; + expect(currentUrl).to.equal(fieldUrl); + }; + + describe('Changing field formatter to Url', () => { + before(async function () { + await esArchiver.load('dashboard/current/kibana'); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await common.navigateToApp('settings'); + await settings.clickKibanaIndexPatterns(); + await settings.clickIndexPatternLogstash(); + await settings.filterField(fieldName); + await settings.openControlsByName(fieldName); + await settings.setFieldFormat('url'); + await settings.controlChangeSave(); + }); + + it('applied on dashboard', async () => { + await common.navigateToApp('dashboard'); + await dashboard.loadSavedDashboard('dashboard with everything'); + await dashboard.waitForRenderComplete(); + const fieldLink = await visChart.getFieldLinkInVisTable(`${fieldName}: Descending`, 1); + await clickFieldAndCheckUrl(fieldLink); + }); + + it('applied on discover', async () => { + await common.navigateToApp('discover'); + await timePicker.setAbsoluteRange( + 'Sep 19, 2017 @ 06:31:44.000', + 'Sep 23, 2018 @ 18:31:44.000' + ); + await testSubjects.click('docTableExpandToggleColumn'); + const fieldLink = await testSubjects.find(`tableDocViewRow-${fieldName}-value`); + await clickFieldAndCheckUrl(fieldLink); + }); + + afterEach(async function () { + const windowHandlers = await browser.getAllWindowHandles(); + if (windowHandlers.length > 1) { + await browser.closeCurrentWindow(); + await browser.switchToWindow(windowHandlers[0]); + } + }); + }); +} diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index 673fba0c346b8..590631ad48b00 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -302,6 +302,20 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr return element.getVisibleText(); } + public async getFieldLinkInVisTable(fieldName: string, rowIndex: number = 1) { + const tableVis = await testSubjects.find('tableVis'); + const $ = await tableVis.parseDomContent(); + const headers = $('span[ng-bind="::col.title"]') + .toArray() + .map((header: any) => $(header).text()); + const fieldColumnIndex = headers.indexOf(fieldName); + return await find.byCssSelector( + `[data-test-subj="paginated-table-body"] tr:nth-of-type(${rowIndex}) td:nth-of-type(${ + fieldColumnIndex + 1 + }) a` + ); + } + /** * If you are writing new tests, you should rather look into getTableVisContent method instead. * @deprecated Use getTableVisContent instead. From 169d06c55b869a5ffcc52f56313a9abb6e3c76bc Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Sat, 4 Jul 2020 17:31:08 +0200 Subject: [PATCH 15/16] [Ingest Pipelines] Load from json (#70297) (#70751) * WiP load from json modal ready, need to refactor more stuff * First iteration of load from JSON functionality - refactored the pipeline processsors editor components for portability - added CIT for load from json component * added comment * update deserialize with tests and make it more fault tolerant * use flyout footer * remove console.error and make the json editor a lot shorter * address PR feedback - Update form schema and form schema types - simplify the save handler - refactor processors_title to processors_header * remove unused translations Co-authored-by: Elastic Machine Co-authored-by: Elastic Machine --- .../on_failure_processors_title.tsx | 2 +- .../pipeline_form.scss} | 0 .../pipeline_form/pipeline_form.tsx | 65 +++-- .../pipeline_form/pipeline_form_fields.tsx | 78 +++--- .../processors_header.tsx} | 13 +- .../components/pipeline_form/schema.ts | 44 ++++ .../components/pipeline_form/schema.tsx | 138 ---------- .../components/pipeline_form/types.ts | 2 + .../pipeline_processors_editor/README.md | 24 ++ .../pipeline_processors_editor.helpers.tsx | 24 +- .../pipeline_processors_editor.test.tsx | 14 +- .../components/index.ts | 8 +- .../components/load_from_json/button.tsx | 34 +++ .../components/load_from_json/index.ts | 8 + .../load_from_json/modal_provider.test.tsx | 127 ++++++++++ .../load_from_json/modal_provider.tsx | 138 ++++++++++ .../components/pipeline_processors_editor.tsx | 33 +++ .../context_menu.tsx | 10 +- .../{messages.ts => i18n_texts.ts} | 2 +- .../pipeline_processors_editor_item.tsx | 22 +- .../processor_settings_form.tsx | 44 ++-- .../pipeline_processors_editor/constants.ts | 5 + .../pipeline_processors_editor/context.tsx | 221 +++++++++++++++- .../deserialize.test.ts | 74 ++++++ .../pipeline_processors_editor/deserialize.ts | 8 +- .../global_on_failure_processors_editor.tsx | 13 + .../editors/index.ts | 8 + .../editors/processors_editor.tsx | 13 + .../pipeline_processors_editor/index.ts | 8 +- .../pipeline_processors_editor.container.tsx | 74 ------ .../pipeline_processors_editor.tsx | 239 ------------------ .../processors_reducer/index.ts | 2 +- .../processors_reducer/processors_reducer.ts | 14 + .../pipeline_processors_editor/types.ts | 2 + .../ingest_pipelines/public/shared_imports.ts | 2 + .../translations/translations/ja-JP.json | 6 - .../translations/translations/zh-CN.json | 6 - 37 files changed, 931 insertions(+), 594 deletions(-) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor/components => pipeline_form}/on_failure_processors_title.tsx (95%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor/pipeline_processors_editor.scss => pipeline_form/pipeline_form.scss} (100%) rename x-pack/plugins/ingest_pipelines/public/application/components/{pipeline_processors_editor/components/processors_title_and_test_button.tsx => pipeline_form/processors_header.tsx} (84%) create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/schema.ts delete mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/schema.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/README.md create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/button.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/index.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.test.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor.tsx rename x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/{messages.ts => i18n_texts.ts} (98%) create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.test.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/global_on_failure_processors_editor.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/index.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/processors_editor.tsx delete mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx delete mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/on_failure_processors_title.tsx similarity index 95% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/on_failure_processors_title.tsx index 251a2ffe95212..d223653442819 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/on_failure_processors_title.tsx @@ -8,7 +8,7 @@ import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { usePipelineProcessorsContext } from '../context'; +import { usePipelineProcessorsContext } from '../pipeline_processors_editor/context'; export const OnFailureProcessorsTitle: FunctionComponent = () => { const { links } = usePipelineProcessorsContext(); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.scss similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.scss diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx index a68e667f4ab43..341e15132d353 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx @@ -9,19 +9,18 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { useForm, Form, FormConfig } from '../../../shared_imports'; -import { Pipeline } from '../../../../common/types'; +import { Pipeline, Processor } from '../../../../common/types'; -import { - OnUpdateHandlerArg, - OnUpdateHandler, - SerializeResult, -} from '../pipeline_processors_editor'; +import './pipeline_form.scss'; + +import { OnUpdateHandlerArg, OnUpdateHandler } from '../pipeline_processors_editor'; import { PipelineRequestFlyout } from './pipeline_request_flyout'; import { PipelineTestFlyout } from './pipeline_test_flyout'; import { PipelineFormFields } from './pipeline_form_fields'; import { PipelineFormError } from './pipeline_form_error'; import { pipelineFormSchema } from './schema'; +import { PipelineForm as IPipelineForm } from './types'; export interface PipelineFormProps { onSave: (pipeline: Pipeline) => void; @@ -32,14 +31,15 @@ export interface PipelineFormProps { isEditing?: boolean; } +const defaultFormValue: Pipeline = Object.freeze({ + name: '', + description: '', + processors: [], + on_failure: [], +}); + export const PipelineForm: React.FunctionComponent = ({ - defaultValue = { - name: '', - description: '', - processors: [], - on_failure: [], - version: '', - }, + defaultValue = defaultFormValue, onSave, isSaving, saveError, @@ -50,34 +50,42 @@ export const PipelineForm: React.FunctionComponent = ({ const [isTestingPipeline, setIsTestingPipeline] = useState(false); - const processorStateRef = useRef(); + const { + processors: initialProcessors, + on_failure: initialOnFailureProcessors, + ...defaultFormValues + } = defaultValue; + + const [processorsState, setProcessorsState] = useState<{ + processors: Processor[]; + onFailure?: Processor[]; + }>({ + processors: initialProcessors, + onFailure: initialOnFailureProcessors, + }); - const handleSave: FormConfig['onSubmit'] = async (formData, isValid) => { - let override: SerializeResult | undefined; + const processorStateRef = useRef(); + const handleSave: FormConfig['onSubmit'] = async (formData, isValid) => { if (!isValid) { return; } if (processorStateRef.current) { - const processorsState = processorStateRef.current; - if (await processorsState.validate()) { - override = processorsState.getData(); - } else { - return; + const state = processorStateRef.current; + if (await state.validate()) { + onSave({ ...formData, ...state.getData() }); } } - - onSave({ ...formData, ...(override || {}) } as Pipeline); }; const handleTestPipelineClick = () => { setIsTestingPipeline(true); }; - const { form } = useForm({ + const { form } = useForm({ schema: pipelineFormSchema, - defaultValue, + defaultValue: defaultFormValues, onSubmit: handleSave, }); @@ -121,9 +129,12 @@ export const PipelineForm: React.FunctionComponent = ({ {/* All form fields */} { + setProcessorsState({ processors, onFailure }); + }} onEditorFlyoutOpen={onEditorFlyoutOpen} - initialProcessors={defaultValue.processors} - initialOnFailureProcessors={defaultValue.on_failure} + processors={processorsState.processors} + onFailure={processorsState.onFailure} onProcessorsUpdate={onProcessorsChangeHandler} hasVersion={Boolean(defaultValue.version)} isTestButtonDisabled={isTestingPipeline || form.isValid === false} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx index 52d1a77c1df6d..0e7a45e8d07b9 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx @@ -6,17 +6,27 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiSpacer, EuiSwitch } from '@elastic/eui'; +import { EuiSpacer, EuiSwitch, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { Processor } from '../../../../common/types'; -import { FormDataProvider } from '../../../shared_imports'; -import { PipelineProcessorsEditor, OnUpdateHandler } from '../pipeline_processors_editor'; import { getUseField, getFormRow, Field, useKibana } from '../../../shared_imports'; +import { + PipelineProcessorsContextProvider, + GlobalOnFailureProcessorsEditor, + ProcessorsEditor, + OnUpdateHandler, + OnDoneLoadJsonHandler, +} from '../pipeline_processors_editor'; + +import { ProcessorsHeader } from './processors_header'; +import { OnFailureProcessorsTitle } from './on_failure_processors_title'; + interface Props { - initialProcessors: Processor[]; - initialOnFailureProcessors?: Processor[]; + processors: Processor[]; + onFailure?: Processor[]; + onLoadJson: OnDoneLoadJsonHandler; onProcessorsUpdate: OnUpdateHandler; hasVersion: boolean; isTestButtonDisabled: boolean; @@ -29,8 +39,9 @@ const UseField = getUseField({ component: Field }); const FormRow = getFormRow({ titleTag: 'h3' }); export const PipelineFormFields: React.FunctionComponent = ({ - initialProcessors, - initialOnFailureProcessors, + processors, + onFailure, + onLoadJson, onProcessorsUpdate, isEditing, hasVersion, @@ -113,30 +124,37 @@ export const PipelineFormFields: React.FunctionComponent = ({ {/* Pipeline Processors Editor */} - - {({ processors, on_failure: onFailure }) => { - const processorProp = - typeof processors === 'string' && processors - ? JSON.parse(processors) - : initialProcessors ?? []; - - const onFailureProp = - typeof onFailure === 'string' && onFailure - ? JSON.parse(onFailure) - : initialOnFailureProcessors ?? []; - return ( - - ); - }} - + +
+ + + + + + + + + + + + + + + + + +
+
); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_title_and_test_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/processors_header.tsx similarity index 84% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_title_and_test_button.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/processors_header.tsx index 6d1e2610b5c2b..5e5cddbd36b92 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_title_and_test_button.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/processors_header.tsx @@ -9,22 +9,26 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLink, EuiText, EuiTitle } from import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { usePipelineProcessorsContext } from '../context'; +import { usePipelineProcessorsContext } from '../pipeline_processors_editor/context'; + +import { LoadFromJsonButton, OnDoneLoadJsonHandler } from '../pipeline_processors_editor'; export interface Props { onTestPipelineClick: () => void; isTestButtonDisabled: boolean; + onLoadJson: OnDoneLoadJsonHandler; } -export const ProcessorsTitleAndTestButton: FunctionComponent = ({ +export const ProcessorsHeader: FunctionComponent = ({ onTestPipelineClick, isTestButtonDisabled, + onLoadJson, }) => { const { links } = usePipelineProcessorsContext(); return ( @@ -55,6 +59,9 @@ export const ProcessorsTitleAndTestButton: FunctionComponent = ({ /> + + + = { + name: { + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.ingestPipelines.form.nameFieldLabel', { + defaultMessage: 'Name', + }), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.form.pipelineNameRequiredError', { + defaultMessage: 'Name is required.', + }) + ), + }, + ], + }, + description: { + type: FIELD_TYPES.TEXTAREA, + label: i18n.translate('xpack.ingestPipelines.form.descriptionFieldLabel', { + defaultMessage: 'Description (optional)', + }), + }, + version: { + type: FIELD_TYPES.NUMBER, + label: i18n.translate('xpack.ingestPipelines.form.versionFieldLabel', { + defaultMessage: 'Version (optional)', + }), + formatters: [toInt], + }, +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/schema.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/schema.tsx deleted file mode 100644 index 5435f43a78acf..0000000000000 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/schema.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; - -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiCode } from '@elastic/eui'; - -import { FormSchema, FIELD_TYPES, fieldValidators, fieldFormatters } from '../../../shared_imports'; -import { parseJson, stringifyJson } from '../../lib'; - -const { emptyField, isJsonField } = fieldValidators; -const { toInt } = fieldFormatters; - -export const pipelineFormSchema: FormSchema = { - name: { - type: FIELD_TYPES.TEXT, - label: i18n.translate('xpack.ingestPipelines.form.nameFieldLabel', { - defaultMessage: 'Name', - }), - validations: [ - { - validator: emptyField( - i18n.translate('xpack.ingestPipelines.form.pipelineNameRequiredError', { - defaultMessage: 'Name is required.', - }) - ), - }, - ], - }, - description: { - type: FIELD_TYPES.TEXTAREA, - label: i18n.translate('xpack.ingestPipelines.form.descriptionFieldLabel', { - defaultMessage: 'Description (optional)', - }), - }, - processors: { - label: i18n.translate('xpack.ingestPipelines.form.processorsFieldLabel', { - defaultMessage: 'Processors', - }), - helpText: ( - - {JSON.stringify([ - { - set: { - field: 'foo', - value: 'bar', - }, - }, - ])} - - ), - }} - /> - ), - serializer: parseJson, - deserializer: stringifyJson, - validations: [ - { - validator: emptyField( - i18n.translate('xpack.ingestPipelines.form.processorsRequiredError', { - defaultMessage: 'Processors are required.', - }) - ), - }, - { - validator: isJsonField( - i18n.translate('xpack.ingestPipelines.form.processorsJsonError', { - defaultMessage: 'The input is not valid.', - }) - ), - }, - ], - }, - on_failure: { - label: i18n.translate('xpack.ingestPipelines.form.onFailureFieldLabel', { - defaultMessage: 'Failure processors (optional)', - }), - helpText: ( - - {JSON.stringify([ - { - set: { - field: '_index', - value: 'failed-{{ _index }}', - }, - }, - ])} - - ), - }} - /> - ), - serializer: (value) => { - const result = parseJson(value); - // If an empty array was passed, strip out this value entirely. - if (!result.length) { - return undefined; - } - return result; - }, - deserializer: stringifyJson, - validations: [ - { - validator: (validationArg) => { - if (!validationArg.value) { - return; - } - return isJsonField( - i18n.translate('xpack.ingestPipelines.form.onFailureProcessorsJsonError', { - defaultMessage: 'The input is not valid.', - }) - )(validationArg); - }, - }, - ], - }, - version: { - type: FIELD_TYPES.NUMBER, - label: i18n.translate('xpack.ingestPipelines.form.versionFieldLabel', { - defaultMessage: 'Version (optional)', - }), - formatters: [toInt], - }, -}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/types.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/types.ts index bd74f09546ff4..aa52c14e61eae 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/types.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/types.ts @@ -7,3 +7,5 @@ import { Pipeline } from '../../../../common/types'; export type ReadProcessorsFunction = () => Pick; + +export type PipelineForm = Omit; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/README.md b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/README.md new file mode 100644 index 0000000000000..d29af67d3179c --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/README.md @@ -0,0 +1,24 @@ +# Pipeline Processors Editor + +This component provides a way to visually build and manage an ingest +pipeline. + +# API + +## Editor components + +The top-level API consists of 3 pieces that enable the maximum amount +of flexibility for consuming code to determine overall layout. + +- PipelineProcessorsEditorContext +- ProcessorsEditor +- GlobalOnFailureProcessorsEditor + +The editor components must be wrapped inside of the context component +as this is where the shared processors state is contained. + +## Load JSON button + +This component is totally standalone. It gives users a button that +presents a modal for loading a pipeline. It does some basic +validation on the JSON to ensure that it is correct. diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx index 7ad9aed3c44a4..cc3817d92d5e3 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx @@ -6,7 +6,12 @@ import { act } from 'react-dom/test-utils'; import React from 'react'; import { registerTestBed, TestBed } from '../../../../../../../test_utils'; -import { PipelineProcessorsEditor, Props } from '../pipeline_processors_editor.container'; +import { + PipelineProcessorsContextProvider, + Props, + ProcessorsEditor, + GlobalOnFailureProcessorsEditor, +} from '../'; jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); @@ -55,9 +60,16 @@ jest.mock('react-virtualized', () => { }; }); -const testBedSetup = registerTestBed(PipelineProcessorsEditor, { - doMountAsync: false, -}); +const testBedSetup = registerTestBed( + (props: Props) => ( + + + + ), + { + doMountAsync: false, + } +); export interface SetupResult extends TestBed { actions: ReturnType; @@ -146,10 +158,6 @@ const createActions = (testBed: TestBed) => { find(`${processorSelector}.moreMenu.duplicateButton`).simulate('click'); }); }, - - toggleOnFailure() { - find('pipelineEditorOnFailureToggle').simulate('click'); - }, }; }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx index 15121cc71c321..a4bbf840dff71 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx @@ -43,9 +43,9 @@ describe('Pipeline Editor', () => { }, onFlyoutOpen: jest.fn(), onUpdate, - isTestButtonDisabled: false, - onTestPipelineClick: jest.fn(), - esDocsBasePath: 'test', + links: { + esDocsBasePath: 'test', + }, }); }); @@ -57,13 +57,6 @@ describe('Pipeline Editor', () => { expect(arg.getData()).toEqual(testProcessors); }); - it('toggles the on-failure processors tree', () => { - const { actions, exists } = testBed; - expect(exists('pipelineEditorOnFailureTree')).toBe(false); - actions.toggleOnFailure(); - expect(exists('pipelineEditorOnFailureTree')).toBe(true); - }); - describe('processors', () => { it('adds a new processor', async () => { const { actions } = testBed; @@ -169,7 +162,6 @@ describe('Pipeline Editor', () => { it('moves to and from the global on-failure tree', async () => { const { actions } = testBed; - actions.toggleOnFailure(); await actions.addProcessor('onFailure', 'test', { if: '1 == 5' }); actions.moveProcessor('processors>0', 'dropButtonBelow-onFailure>0'); const [onUpdateResult1] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts index 2d512a6bfa2ed..de0621b187230 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts @@ -12,10 +12,10 @@ export { export { ProcessorsTree, ProcessorInfo, OnActionHandler } from './processors_tree'; -export { PipelineProcessorsEditorItem } from './pipeline_processors_editor_item/pipeline_processors_editor_item'; +export { PipelineProcessorsEditor } from './pipeline_processors_editor'; -export { ProcessorRemoveModal } from './processor_remove_modal'; +export { PipelineProcessorsEditorItem } from './pipeline_processors_editor_item'; -export { ProcessorsTitleAndTestButton } from './processors_title_and_test_button'; +export { ProcessorRemoveModal } from './processor_remove_modal'; -export { OnFailureProcessorsTitle } from './on_failure_processors_title'; +export { OnDoneLoadJsonHandler, LoadFromJsonButton } from './load_from_json'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/button.tsx new file mode 100644 index 0000000000000..482878d1bda58 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/button.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import React, { FunctionComponent } from 'react'; +import { EuiButton } from '@elastic/eui'; + +import { ModalProvider, OnDoneLoadJsonHandler } from './modal_provider'; + +interface Props { + onDone: OnDoneLoadJsonHandler; +} + +const i18nTexts = { + buttonLabel: i18n.translate('xpack.ingestPipelines.pipelineEditor.loadFromJson.buttonLabel', { + defaultMessage: 'Load JSON', + }), +}; + +export const LoadFromJsonButton: FunctionComponent = ({ onDone }) => { + return ( + + {(openModal) => { + return ( + + {i18nTexts.buttonLabel} + + ); + }} + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/index.ts new file mode 100644 index 0000000000000..c1c49f251d518 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/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 { LoadFromJsonButton } from './button'; +export { OnDoneLoadJsonHandler } from './modal_provider'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.test.tsx new file mode 100644 index 0000000000000..2f4cdce1edd0b --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.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 React from 'react'; +import { ModalProvider, OnDoneLoadJsonHandler } from './modal_provider'; + +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + + return { + ...original, + // Mocking EuiCodeEditor, which uses React Ace under the hood + EuiCodeEditor: (props: any) => ( + { + props.onChange(syntheticEvent.jsonString); + }} + /> + ), + }; +}); + +jest.mock('lodash', () => { + const original = jest.requireActual('lodash'); + + return { + ...original, + debounce: (fn: any) => fn, + }; +}); + +import { registerTestBed, TestBed } from '../../../../../../../../test_utils/testbed'; + +const setup = ({ onDone }: { onDone: OnDoneLoadJsonHandler }) => { + return registerTestBed( + () => ( + + {(openModal) => { + return ( + + ); + }} + + ), + { + memoryRouter: { + wrapComponent: false, + }, + } + )(); +}; + +describe('Load from JSON ModalProvider', () => { + let testBed: TestBed; + let onDone: jest.Mock; + + beforeEach(async () => { + onDone = jest.fn(); + testBed = await setup({ onDone }); + }); + + it('displays errors', () => { + const { find, exists } = testBed; + find('button').simulate('click'); + expect(exists('loadJsonConfirmationModal')); + const invalidPipeline = '{}'; + find('mockCodeEditor').simulate('change', { jsonString: invalidPipeline }); + find('confirmModalConfirmButton').simulate('click'); + const errorCallout = find('loadJsonConfirmationModal.errorCallOut'); + expect(errorCallout.text()).toContain('Please ensure the JSON is a valid pipeline object.'); + expect(onDone).toHaveBeenCalledTimes(0); + }); + + it('passes through a valid pipeline object', () => { + const { find, exists } = testBed; + find('button').simulate('click'); + expect(exists('loadJsonConfirmationModal')); + const validPipeline = JSON.stringify({ + processors: [{ set: { field: 'test', value: 123 } }, { badType1: null }, { badType2: 1 }], + on_failure: [ + { + gsub: { + field: '_index', + pattern: '(.monitoring-\\w+-)6(-.+)', + replacement: '$17$2', + }, + }, + ], + }); + find('mockCodeEditor').simulate('change', { jsonString: validPipeline }); + find('confirmModalConfirmButton').simulate('click'); + expect(!exists('loadJsonConfirmationModal')); + expect(onDone).toHaveBeenCalledTimes(1); + expect(onDone.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "on_failure": Array [ + Object { + "gsub": Object { + "field": "_index", + "pattern": "(.monitoring-\\\\w+-)6(-.+)", + "replacement": "$17$2", + }, + }, + ], + "processors": Array [ + Object { + "set": Object { + "field": "test", + "value": 123, + }, + }, + Object { + "badType1": null, + }, + Object { + "badType2": 1, + }, + ], + } + `); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.tsx new file mode 100644 index 0000000000000..f183386d5927d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { FunctionComponent, useRef, useState } from 'react'; +import { EuiConfirmModal, EuiOverlayMask, EuiSpacer, EuiText, EuiCallOut } from '@elastic/eui'; + +import { JsonEditor, OnJsonEditorUpdateHandler } from '../../../../../shared_imports'; + +import { Processor } from '../../../../../../common/types'; + +import { deserialize } from '../../deserialize'; + +export type OnDoneLoadJsonHandler = (json: { + processors: Processor[]; + on_failure?: Processor[]; +}) => void; + +export interface Props { + onDone: OnDoneLoadJsonHandler; + children: (openModal: () => void) => React.ReactNode; +} + +const i18nTexts = { + modalTitle: i18n.translate('xpack.ingestPipelines.pipelineEditor.loadFromJson.modalTitle', { + defaultMessage: 'Load JSON', + }), + buttons: { + cancel: i18n.translate('xpack.ingestPipelines.pipelineEditor.loadFromJson.buttons.cancel', { + defaultMessage: 'Cancel', + }), + confirm: i18n.translate('xpack.ingestPipelines.pipelineEditor.loadFromJson.buttons.confirm', { + defaultMessage: 'Load and overwrite', + }), + }, + editor: { + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.loadFromJson.editor', { + defaultMessage: 'Pipeline object', + }), + }, + error: { + title: i18n.translate('xpack.ingestPipelines.pipelineEditor.loadFromJson.error.title', { + defaultMessage: 'Invalid pipeline', + }), + body: i18n.translate('xpack.ingestPipelines.pipelineEditor.loadFromJson.error.body', { + defaultMessage: 'Please ensure the JSON is a valid pipeline object.', + }), + }, +}; + +const defaultValue = {}; +const defaultValueRaw = JSON.stringify(defaultValue, null, 2); + +export const ModalProvider: FunctionComponent = ({ onDone, children }) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const [isValidJson, setIsValidJson] = useState(true); + const [error, setError] = useState(); + const jsonContent = useRef['0']>({ + isValid: true, + validate: () => true, + data: { + format: () => defaultValue, + raw: defaultValueRaw, + }, + }); + const onJsonUpdate: OnJsonEditorUpdateHandler = (jsonUpdateData) => { + setIsValidJson(jsonUpdateData.validate()); + jsonContent.current = jsonUpdateData; + }; + return ( + <> + {children(() => setIsModalVisible(true))} + {isModalVisible ? ( + + { + setIsModalVisible(false); + }} + onConfirm={async () => { + try { + const json = jsonContent.current.data.format(); + const { processors, on_failure: onFailure } = json; + // This function will throw if it cannot parse the pipeline object + deserialize({ processors, onFailure }); + onDone(json as any); + setIsModalVisible(false); + } catch (e) { + setError(e); + } + }} + cancelButtonText={i18nTexts.buttons.cancel} + confirmButtonDisabled={!isValidJson} + confirmButtonText={i18nTexts.buttons.confirm} + maxWidth={600} + > +
+ + + + + + + {error && ( + <> + + {i18nTexts.error.body} + + + + )} + + +
+
+
+ ) : undefined} + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor.tsx new file mode 100644 index 0000000000000..c89ff1d3d99ac --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent, memo, useMemo } from 'react'; +import { ProcessorsTree } from '.'; +import { usePipelineProcessorsContext } from '../context'; + +import { ON_FAILURE_STATE_SCOPE, PROCESSOR_STATE_SCOPE } from '../processors_reducer'; + +export interface Props { + stateSlice: typeof ON_FAILURE_STATE_SCOPE | typeof PROCESSOR_STATE_SCOPE; +} + +export const PipelineProcessorsEditor: FunctionComponent = memo( + function PipelineProcessorsEditor({ stateSlice }) { + const { + onTreeAction, + state: { editor, processors }, + } = usePipelineProcessorsContext(); + const baseSelector = useMemo(() => [stateSlice], [stateSlice]); + return ( + + ); + } +); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx index 5bbea4b89b053..5cee5311c62a9 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx @@ -9,7 +9,7 @@ import React, { FunctionComponent, useState } from 'react'; import { EuiContextMenuItem, EuiContextMenuPanel, EuiPopover, EuiButtonIcon } from '@elastic/eui'; -import { editorItemMessages } from './messages'; +import { i18nTexts } from './i18n_texts'; interface Props { disabled: boolean; @@ -39,7 +39,7 @@ export const ContextMenu: FunctionComponent = (props) => { onDuplicate(); }} > - {editorItemMessages.duplicateButtonLabel} + {i18nTexts.duplicateButtonLabel} , showAddOnFailure ? ( = (props) => { onAddOnFailure(); }} > - {editorItemMessages.addOnFailureButtonLabel} + {i18nTexts.addOnFailureButtonLabel} ) : undefined, = (props) => { onDelete(); }} > - {editorItemMessages.deleteButtonLabel} + {i18nTexts.deleteButtonLabel} , ].filter(Boolean) as JSX.Element[]; @@ -82,7 +82,7 @@ export const ContextMenu: FunctionComponent = (props) => { disabled={disabled} onClick={() => setIsOpen((v) => !v)} iconType="boxesHorizontal" - aria-label={editorItemMessages.moreButtonAriaLabel} + aria-label={i18nTexts.moreButtonAriaLabel} /> } > diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/messages.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/i18n_texts.ts similarity index 98% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/messages.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/i18n_texts.ts index 913902d295503..ab080767b6029 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/messages.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/i18n_texts.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; -export const editorItemMessages = { +export const i18nTexts = { moveButtonLabel: i18n.translate('xpack.ingestPipelines.pipelineEditor.item.moveButtonLabel', { defaultMessage: 'Move this processor', }), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx index 0fe804adaeb48..09c047d1d51b7 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx @@ -25,7 +25,7 @@ import './pipeline_processors_editor_item.scss'; import { InlineTextInput } from './inline_text_input'; import { ContextMenu } from './context_menu'; -import { editorItemMessages } from './messages'; +import { i18nTexts } from './i18n_texts'; import { ProcessorInfo } from '../processors_tree'; export interface Handlers { @@ -52,7 +52,7 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( renderOnFailureHandlers, }) => { const { - state: { editor, processorsDispatch }, + state: { editor, processors }, } = usePipelineProcessorsContext(); const isDisabled = editor.mode.id !== 'idle'; @@ -115,7 +115,7 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( description: nextDescription, }; } - processorsDispatch({ + processors.dispatch({ type: 'updateProcessor', payload: { processor: { @@ -126,17 +126,17 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( }, }); }} - ariaLabel={editorItemMessages.processorTypeLabel({ type: processor.type })} + ariaLabel={i18nTexts.processorTypeLabel({ type: processor.type })} text={description} - placeholder={editorItemMessages.descriptionPlaceholder} + placeholder={i18nTexts.descriptionPlaceholder} />
{!isInMoveMode && ( - + { @@ -151,12 +151,12 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( {!isInMoveMode && ( - + @@ -165,7 +165,7 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( - {editorItemMessages.cancelMoveButtonLabel} + {i18nTexts.cancelMoveButtonLabel}
@@ -183,7 +183,7 @@ export const PipelineProcessorsEditorItem: FunctionComponent = memo( editor.setMode({ id: 'removingProcessor', arg: { selector } }); }} onDuplicate={() => { - processorsDispatch({ + processors.dispatch({ type: 'duplicateProcessor', payload: { source: selector, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx index 9d284748a3d15..3eccda55fbb3a 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx @@ -9,11 +9,13 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { FunctionComponent, memo, useEffect } from 'react'; import { EuiButton, + EuiButtonEmpty, EuiHorizontalRule, EuiFlyout, EuiFlyoutHeader, - EuiTitle, EuiFlyoutBody, + EuiFlyoutFooter, + EuiTitle, EuiFlexGroup, EuiFlexItem, } from '@elastic/eui'; @@ -44,6 +46,11 @@ const addButtonLabel = i18n.translate( { defaultMessage: 'Add' } ); +const cancelButtonLabel = i18n.translate( + 'xpack.ingestPipelines.settingsFormOnFailureFlyout.cancelButtonLabel', + { defaultMessage: 'Cancel' } +); + export const ProcessorSettingsForm: FunctionComponent = memo( ({ processor, form, isOnFailure, onClose, onOpen }) => { const { @@ -71,7 +78,7 @@ export const ProcessorSettingsForm: FunctionComponent = memo( return (
- + @@ -109,30 +116,19 @@ export const ProcessorSettingsForm: FunctionComponent = memo( {(arg: any) => { const { type } = arg; - let formContent: React.ReactNode | undefined; if (type?.length) { const formDescriptor = getProcessorFormDescriptor(type as any); if (formDescriptor?.FieldsComponent) { - formContent = ( + return ( <> ); - } else { - formContent = ; } - - return ( - <> - {formContent} - - {processor ? updateButtonLabel : addButtonLabel} - - - ); + return ; } // If the user has not yet defined a type, we do not show any settings fields @@ -140,6 +136,24 @@ export const ProcessorSettingsForm: FunctionComponent = memo( }} + + + + {cancelButtonLabel} + + + { + form.submit(); + }} + > + {processor ? updateButtonLabel : addButtonLabel} + + + + ); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/constants.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/constants.ts index 46e3d1c803fd5..87e6eb7f642a6 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/constants.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/constants.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ON_FAILURE_STATE_SCOPE, PROCESSOR_STATE_SCOPE } from './processors_reducer'; + export enum DropSpecialLocations { top = 'TOP', bottom = 'BOTTOM', } + +export const PROCESSORS_BASE_SELECTOR = [PROCESSOR_STATE_SCOPE]; +export const ON_FAILURE_BASE_SELECTOR = [ON_FAILURE_STATE_SCOPE]; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx index fbc06f41208fe..ec864d31d1986 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx @@ -4,41 +4,242 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { createContext, Dispatch, FunctionComponent, useContext, useState } from 'react'; -import { EditorMode } from './types'; -import { ProcessorsDispatch } from './processors_reducer'; +import React, { + createContext, + Dispatch, + FunctionComponent, + useCallback, + useContext, + useEffect, + useMemo, + useState, + useRef, +} from 'react'; + +import { Processor } from '../../../../common/types'; + +import { EditorMode, FormValidityState, OnFormUpdateArg, OnUpdateHandlerArg } from './types'; + +import { + ProcessorsDispatch, + useProcessorsState, + State as ProcessorsState, + isOnFailureSelector, +} from './processors_reducer'; + +import { deserialize } from './deserialize'; + +import { serialize } from './serialize'; + +import { OnSubmitHandler, ProcessorSettingsForm } from './components/processor_settings_form'; + +import { OnActionHandler } from './components/processors_tree'; + +import { ProcessorRemoveModal } from './components'; + +import { getValue } from './utils'; interface Links { esDocsBasePath: string; } -const PipelineProcessorsContext = createContext<{ +interface ContextValue { links: Links; + onTreeAction: OnActionHandler; state: { - processorsDispatch: ProcessorsDispatch; + processors: { + state: ProcessorsState; + dispatch: ProcessorsDispatch; + }; editor: { mode: EditorMode; setMode: Dispatch; }; }; -}>({} as any); +} -interface Props { +const PipelineProcessorsContext = createContext({} as any); + +export interface Props { links: Links; - processorsDispatch: ProcessorsDispatch; + value: { + processors: Processor[]; + onFailure?: Processor[]; + }; + /** + * Give users a way to react to this component opening a flyout + */ + onFlyoutOpen: () => void; + onUpdate: (arg: OnUpdateHandlerArg) => void; } export const PipelineProcessorsContextProvider: FunctionComponent = ({ links, + value: { processors: originalProcessors, onFailure: originalOnFailureProcessors }, + onUpdate, + onFlyoutOpen, children, - processorsDispatch, }) => { + const initRef = useRef(false); const [mode, setMode] = useState({ id: 'idle' }); + const deserializedResult = useMemo( + () => + deserialize({ + processors: originalProcessors, + onFailure: originalOnFailureProcessors, + }), + [originalProcessors, originalOnFailureProcessors] + ); + const [processorsState, processorsDispatch] = useProcessorsState(deserializedResult); + + useEffect(() => { + if (initRef.current) { + processorsDispatch({ + type: 'loadProcessors', + payload: { + newState: deserializedResult, + }, + }); + } else { + initRef.current = true; + } + }, [deserializedResult, processorsDispatch]); + + const { onFailure: onFailureProcessors, processors } = processorsState; + + const [formState, setFormState] = useState({ + validate: () => Promise.resolve(true), + }); + + const onFormUpdate = useCallback<(arg: OnFormUpdateArg) => void>( + ({ isValid, validate }) => { + setFormState({ + validate: async () => { + if (isValid === undefined) { + return validate(); + } + return isValid; + }, + }); + }, + [setFormState] + ); + + useEffect(() => { + onUpdate({ + validate: async () => { + const formValid = await formState.validate(); + return formValid && mode.id === 'idle'; + }, + getData: () => + serialize({ + onFailure: onFailureProcessors, + processors, + }), + }); + }, [processors, onFailureProcessors, onUpdate, formState, mode]); + + const onSubmit = useCallback( + (processorTypeAndOptions) => { + switch (mode.id) { + case 'creatingProcessor': + processorsDispatch({ + type: 'addProcessor', + payload: { + processor: { ...processorTypeAndOptions }, + targetSelector: mode.arg.selector, + }, + }); + break; + case 'editingProcessor': + processorsDispatch({ + type: 'updateProcessor', + payload: { + processor: { + ...mode.arg.processor, + ...processorTypeAndOptions, + }, + selector: mode.arg.selector, + }, + }); + break; + default: + } + setMode({ id: 'idle' }); + }, + [processorsDispatch, mode, setMode] + ); + + const onCloseSettingsForm = useCallback(() => { + setMode({ id: 'idle' }); + setFormState({ validate: () => Promise.resolve(true) }); + }, [setFormState, setMode]); + + const onTreeAction = useCallback( + (action) => { + switch (action.type) { + case 'addProcessor': + setMode({ id: 'creatingProcessor', arg: { selector: action.payload.target } }); + break; + case 'move': + setMode({ id: 'idle' }); + processorsDispatch({ + type: 'moveProcessor', + payload: action.payload, + }); + break; + case 'selectToMove': + setMode({ id: 'movingProcessor', arg: action.payload.info }); + break; + case 'cancelMove': + setMode({ id: 'idle' }); + break; + } + }, + [processorsDispatch, setMode] + ); + return ( {children} + + {mode.id === 'editingProcessor' || mode.id === 'creatingProcessor' ? ( + + ) : undefined} + {mode.id === 'removingProcessor' && ( + { + if (confirmed) { + processorsDispatch({ + type: 'removeProcessor', + payload: { selector }, + }); + } + setMode({ id: 'idle' }); + }} + /> + )} ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.test.ts new file mode 100644 index 0000000000000..9b7c2069fcddd --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { deserialize } from './deserialize'; + +describe('deserialize', () => { + it('tolerates certain bad values correctly', () => { + expect( + deserialize({ + processors: [ + { set: { field: 'test', value: 123 } }, + { badType1: null } as any, + { badType2: 1 } as any, + ], + onFailure: [ + { + gsub: { + field: '_index', + pattern: '(.monitoring-\\w+-)6(-.+)', + replacement: '$17$2', + }, + }, + ], + }) + ).toEqual({ + processors: [ + { + id: expect.any(String), + type: 'set', + options: { + field: 'test', + value: 123, + }, + }, + { + id: expect.any(String), + onFailure: undefined, + type: 'badType1', + options: {}, + }, + { + id: expect.any(String), + onFailure: undefined, + type: 'badType2', + options: {}, + }, + ], + onFailure: [ + { + id: expect.any(String), + type: 'gsub', + onFailure: undefined, + options: { + field: '_index', + pattern: '(.monitoring-\\w+-)6(-.+)', + replacement: '$17$2', + }, + }, + ], + }); + }); + + it('throws for unacceptable values', () => { + expect(() => { + deserialize({ + processors: [{ reallyBad: undefined } as any, 1 as any], + onFailure: [], + }); + }).toThrow('Invalid processor type'); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.ts index fa1d041bdaba3..1e9a97e189a5e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.ts @@ -22,12 +22,16 @@ const getProcessorType = (processor: Processor): string => { * See the definition of {@link ProcessorInternal} for why this works to extract the * processor type. */ - return Object.keys(processor)[0]!; + const type: unknown = Object.keys(processor)[0]; + if (typeof type !== 'string') { + throw new Error(`Invalid processor type. Received "${type}"`); + } + return type; }; const convertToPipelineInternalProcessor = (processor: Processor): ProcessorInternal => { const type = getProcessorType(processor); - const { on_failure: originalOnFailure, ...options } = processor[type]; + const { on_failure: originalOnFailure, ...options } = processor[type] ?? {}; const onFailure = originalOnFailure?.length ? convertProcessors(originalOnFailure) : (originalOnFailure as ProcessorInternal[] | undefined); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/global_on_failure_processors_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/global_on_failure_processors_editor.tsx new file mode 100644 index 0000000000000..7c62383024cfe --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/global_on_failure_processors_editor.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; + +import { PipelineProcessorsEditor } from '../components'; + +export const GlobalOnFailureProcessorsEditor: FunctionComponent = () => { + return ; +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/index.ts new file mode 100644 index 0000000000000..6c544b31df439 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/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 { GlobalOnFailureProcessorsEditor } from './global_on_failure_processors_editor'; +export { ProcessorsEditor } from './processors_editor'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/processors_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/processors_editor.tsx new file mode 100644 index 0000000000000..108b22be43ca0 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/processors_editor.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; + +import { PipelineProcessorsEditor } from '../components'; + +export const ProcessorsEditor: FunctionComponent = () => { + return ; +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/index.ts index 58d6e492b85e5..89bc50fc0600a 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/index.ts @@ -4,8 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -export { PipelineProcessorsEditor, OnUpdateHandler } from './pipeline_processors_editor.container'; +export { PipelineProcessorsContextProvider, Props } from './context'; -export { OnUpdateHandlerArg } from './types'; +export { ProcessorsEditor, GlobalOnFailureProcessorsEditor } from './editors'; + +export { OnUpdateHandlerArg, OnUpdateHandler } from './types'; export { SerializeResult } from './serialize'; + +export { LoadFromJsonButton, OnDoneLoadJsonHandler } from './components'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx deleted file mode 100644 index 7257677c08fc2..0000000000000 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { FunctionComponent, useMemo } from 'react'; - -import { Processor } from '../../../../common/types'; - -import { deserialize } from './deserialize'; - -import { useProcessorsState } from './processors_reducer'; - -import { PipelineProcessorsContextProvider } from './context'; - -import { OnUpdateHandlerArg } from './types'; - -import { PipelineProcessorsEditor as PipelineProcessorsEditorUI } from './pipeline_processors_editor'; - -export interface Props { - value: { - processors: Processor[]; - onFailure?: Processor[]; - }; - onUpdate: (arg: OnUpdateHandlerArg) => void; - isTestButtonDisabled: boolean; - onTestPipelineClick: () => void; - esDocsBasePath: string; - /** - * Give users a way to react to this component opening a flyout - */ - onFlyoutOpen: () => void; -} - -export type OnUpdateHandler = (arg: OnUpdateHandlerArg) => void; - -export const PipelineProcessorsEditor: FunctionComponent = ({ - value: { processors: originalProcessors, onFailure: originalOnFailureProcessors }, - onFlyoutOpen, - onUpdate, - isTestButtonDisabled, - esDocsBasePath, - onTestPipelineClick, -}) => { - const deserializedResult = useMemo( - () => - deserialize({ - processors: originalProcessors, - onFailure: originalOnFailureProcessors, - }), - // TODO: Re-add the dependency on the props and make the state set-able - // when new props come in so that this component will be controllable - [] // eslint-disable-line react-hooks/exhaustive-deps - ); - const [processorsState, processorsDispatch] = useProcessorsState(deserializedResult); - const { processors, onFailure } = processorsState; - - return ( - - - - ); -}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.tsx deleted file mode 100644 index 09e77c5107754..0000000000000 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.tsx +++ /dev/null @@ -1,239 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { FormattedMessage } from '@kbn/i18n/react'; -import React, { FunctionComponent, useCallback, memo, useState, useEffect } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiSwitch } from '@elastic/eui'; - -import './pipeline_processors_editor.scss'; - -import { - ProcessorsTitleAndTestButton, - OnFailureProcessorsTitle, - ProcessorsTree, - ProcessorRemoveModal, - OnActionHandler, - OnSubmitHandler, - ProcessorSettingsForm, -} from './components'; - -import { ProcessorInternal, OnUpdateHandlerArg, FormValidityState, OnFormUpdateArg } from './types'; - -import { - ON_FAILURE_STATE_SCOPE, - PROCESSOR_STATE_SCOPE, - isOnFailureSelector, -} from './processors_reducer'; - -const PROCESSORS_BASE_SELECTOR = [PROCESSOR_STATE_SCOPE]; -const ON_FAILURE_BASE_SELECTOR = [ON_FAILURE_STATE_SCOPE]; - -import { serialize } from './serialize'; -import { getValue } from './utils'; -import { usePipelineProcessorsContext } from './context'; - -export interface Props { - processors: ProcessorInternal[]; - onFailureProcessors: ProcessorInternal[]; - onUpdate: (arg: OnUpdateHandlerArg) => void; - isTestButtonDisabled: boolean; - onTestPipelineClick: () => void; - onFlyoutOpen: () => void; -} - -export const PipelineProcessorsEditor: FunctionComponent = memo( - function PipelineProcessorsEditor({ - processors, - onFailureProcessors, - onTestPipelineClick, - isTestButtonDisabled, - onUpdate, - onFlyoutOpen, - }) { - const { - state: { editor, processorsDispatch }, - } = usePipelineProcessorsContext(); - - const { mode: editorMode, setMode: setEditorMode } = editor; - - const [formState, setFormState] = useState({ - validate: () => Promise.resolve(true), - }); - - const onFormUpdate = useCallback<(arg: OnFormUpdateArg) => void>( - ({ isValid, validate }) => { - setFormState({ - validate: async () => { - if (isValid === undefined) { - return validate(); - } - return isValid; - }, - }); - }, - [setFormState] - ); - - const [showGlobalOnFailure, setShowGlobalOnFailure] = useState( - Boolean(onFailureProcessors.length) - ); - - useEffect(() => { - onUpdate({ - validate: async () => { - const formValid = await formState.validate(); - return formValid && editorMode.id === 'idle'; - }, - getData: () => - serialize({ - onFailure: showGlobalOnFailure ? onFailureProcessors : undefined, - processors, - }), - }); - }, [processors, onFailureProcessors, onUpdate, formState, editorMode, showGlobalOnFailure]); - - const onSubmit = useCallback( - (processorTypeAndOptions) => { - switch (editorMode.id) { - case 'creatingProcessor': - processorsDispatch({ - type: 'addProcessor', - payload: { - processor: { ...processorTypeAndOptions }, - targetSelector: editorMode.arg.selector, - }, - }); - break; - case 'editingProcessor': - processorsDispatch({ - type: 'updateProcessor', - payload: { - processor: { - ...editorMode.arg.processor, - ...processorTypeAndOptions, - }, - selector: editorMode.arg.selector, - }, - }); - break; - default: - } - setEditorMode({ id: 'idle' }); - }, - [processorsDispatch, editorMode, setEditorMode] - ); - - const onCloseSettingsForm = useCallback(() => { - setEditorMode({ id: 'idle' }); - setFormState({ validate: () => Promise.resolve(true) }); - }, [setFormState, setEditorMode]); - - const onTreeAction = useCallback( - (action) => { - switch (action.type) { - case 'addProcessor': - setEditorMode({ id: 'creatingProcessor', arg: { selector: action.payload.target } }); - break; - case 'move': - setEditorMode({ id: 'idle' }); - processorsDispatch({ - type: 'moveProcessor', - payload: action.payload, - }); - break; - case 'selectToMove': - setEditorMode({ id: 'movingProcessor', arg: action.payload.info }); - break; - case 'cancelMove': - setEditorMode({ id: 'idle' }); - break; - } - }, - [processorsDispatch, setEditorMode] - ); - - const movingProcessor = editorMode.id === 'movingProcessor' ? editorMode.arg : undefined; - - return ( -
- - - - - - - - - - - - - - - - } - checked={showGlobalOnFailure} - onChange={(e) => setShowGlobalOnFailure(e.target.checked)} - data-test-subj="pipelineEditorOnFailureToggle" - /> - - {showGlobalOnFailure ? ( - - - - ) : undefined} - - {editorMode.id === 'editingProcessor' || editorMode.id === 'creatingProcessor' ? ( - - ) : undefined} - {editorMode.id === 'removingProcessor' && ( - { - if (confirmed) { - processorsDispatch({ - type: 'removeProcessor', - payload: { selector }, - }); - } - setEditorMode({ id: 'idle' }); - }} - /> - )} -
- ); - } -); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/index.ts index 7265f63f45a5d..0e06b8d55d379 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/index.ts @@ -12,6 +12,6 @@ export { Action, } from './processors_reducer'; -export { ON_FAILURE_STATE_SCOPE, PROCESSOR_STATE_SCOPE } from './constants'; +export * from './constants'; export { isChildPath, isOnFailureSelector } from './utils'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.ts index 4e069aab8bdd1..295e7ff141117 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.ts @@ -38,6 +38,12 @@ export type Action = payload: { source: ProcessorSelector; }; + } + | { + type: 'loadProcessors'; + payload: { + newState: DeserializeResult; + }; }; export type ProcessorsDispatch = Dispatch; @@ -124,6 +130,14 @@ export const reducer: Reducer = (state, action) => { return setValue(sourceProcessorsArraySelector, state, sourceProcessorsArray); } + if (action.type === 'loadProcessors') { + return { + ...action.payload.newState, + onFailure: action.payload.newState.onFailure ?? [], + isRoot: true, + }; + } + return state; }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/types.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/types.ts index aa39fca29fa8b..aea8f0f0910f4 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/types.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/types.ts @@ -38,6 +38,8 @@ export interface OnUpdateHandlerArg extends FormValidityState { getData: () => SerializeResult; } +export type OnUpdateHandler = (arg: OnUpdateHandlerArg) => void; + /** * The editor can be in different modes. This enables us to hold * a reference to data dispatch to the reducer (like the {@link ProcessorSelector} diff --git a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts index 05e7d1e41c5fa..d2c4b73a48767 100644 --- a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts +++ b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts @@ -22,6 +22,8 @@ export { UseRequestConfig, WithPrivileges, Monaco, + JsonEditor, + OnJsonEditorUpdateHandler, } from '../../../../src/plugins/es_ui_shared/public/'; export { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 6064e044015b7..f7f2f4a44ee11 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8478,13 +8478,7 @@ "xpack.ingestPipelines.form.nameFieldLabel": "名前", "xpack.ingestPipelines.form.nameTitle": "名前", "xpack.ingestPipelines.form.onFailureFieldHelpText": "JSONフォーマットを使用:{code}", - "xpack.ingestPipelines.form.onFailureFieldLabel": "障害プロセッサー(任意)", - "xpack.ingestPipelines.form.onFailureProcessorsJsonError": "入力が無効です。", "xpack.ingestPipelines.form.pipelineNameRequiredError": "名前が必要です。", - "xpack.ingestPipelines.form.processorsFieldHelpText": "JSONフォーマットを使用:{code}", - "xpack.ingestPipelines.form.processorsFieldLabel": "プロセッサー", - "xpack.ingestPipelines.form.processorsJsonError": "入力が無効です。", - "xpack.ingestPipelines.form.processorsRequiredError": "プロセッサーが必要です。", "xpack.ingestPipelines.form.saveButtonLabel": "パイプラインを保存", "xpack.ingestPipelines.form.savePipelineError": "パイプラインを作成できません", "xpack.ingestPipelines.form.savingButtonLabel": "保存中…", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ed57e2061e32e..1917a22c2e8f8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8483,13 +8483,7 @@ "xpack.ingestPipelines.form.nameFieldLabel": "名称", "xpack.ingestPipelines.form.nameTitle": "名称", "xpack.ingestPipelines.form.onFailureFieldHelpText": "使用 JSON 格式:{code}", - "xpack.ingestPipelines.form.onFailureFieldLabel": "失败处理器(可选)", - "xpack.ingestPipelines.form.onFailureProcessorsJsonError": "输入无效。", "xpack.ingestPipelines.form.pipelineNameRequiredError": "“名称”必填。", - "xpack.ingestPipelines.form.processorsFieldHelpText": "使用 JSON 格式:{code}", - "xpack.ingestPipelines.form.processorsFieldLabel": "处理器", - "xpack.ingestPipelines.form.processorsJsonError": "输入无效。", - "xpack.ingestPipelines.form.processorsRequiredError": "需要指定处理器。", "xpack.ingestPipelines.form.saveButtonLabel": "保存管道", "xpack.ingestPipelines.form.savePipelineError": "无法创建管道", "xpack.ingestPipelines.form.savingButtonLabel": "正在保存......", From a999009bbd26b999fd6bd0a2adf85aadacc25b46 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Sun, 5 Jul 2020 23:00:13 +0100 Subject: [PATCH 16/16] skip flaky suite (#70762) --- .../src/integration_tests/basic_optimization.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts index b2e5f6257669c..44c9a58b35068 100644 --- a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts +++ b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts @@ -63,7 +63,8 @@ afterAll(async () => { await del(TMP_DIR); }); -it('builds expected bundles, saves bundle counts to metadata', async () => { +// FLAKY: https://github.com/elastic/kibana/issues/70762 +it.skip('builds expected bundles, saves bundle counts to metadata', async () => { const config = OptimizerConfig.create({ repoRoot: MOCK_REPO_DIR, pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins')],