From b71edab44a29b7c12ee418dd4dfc0af1928fb6a0 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Tue, 16 Jun 2020 16:17:30 +0200 Subject: [PATCH] [Ingest Node Pipelines] Pipeline Processors Editor (#66021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial plugin setup * add smoke test * fix license check * refactor plugin setup * Server-side create/update ingest pipelines (#62744) * List pipelines (#62785) * First iteration of ingest table * Add action placeholders * Refactor list and address PR feedback Refactored the list into smaller pieces and assemble in main.tsx Also addressed feedback on copy, removed unused notifications dep * WiP on flyout Showing name in title * Add reload button * Finish first version of flyout * Slight update to copy * `delete` -> `edit` * Address PR feedback Copy and a11y updates * Add on failure JSON to flyout if it is available * Add details json block file and remove ununsed import Co-authored-by: Elastic Machine * [Ingest pipelines] Create pipeline UI (#63017) * First vertical slice of pipeline editor component * Made a space for common parameters * [Ingest pipelines] Edit pipeline page (#63522) * First iteration of CRUD functionality working * WiP on moving the pipeline editor to pipeline processor editor * Finish refactor to work with passing state out * Refactor and fix tests * [Ingest pipelines] Polish details panel and empty list (#63926) * Address some early feedback and use FormDataProvider FormDataProvider gives a more declarative way to listen to form state updates. Also refactored the state reader mechanism. * [Ingest pipelines] Delete pipeline (#63635) * [Ingest Node Pipelines] Clone Pipeline (#64049) * First iteration of clone functionality Wired up for both the list table and the details flyout in the list section. * satisfy eslint * Turn on sorting for the list table * Clean up const declarations * Address PR feedback Sentence-casify and update some other copy. * Mark edit and delete as primary actions in list table * Handle URI encoded chars in pipeline name when cloning * Update to using the more flexible controlled component pattern To make this component and mappings editor more consistent, both will expose a more traditional controlled component interface to consumers. In the current implementation this requires that consumers use `useCallback` for `onUpdate` handlers to avoid an infinite rendering cycle and to avoid staleness bugs in their `onUpdate` handlers they need to think about what might make the handler stale. This approach comes with the benefits and flexibility of controlled components at the cost of slightly more complex consumption of the components. In future, we can explore adding the uncontrolled component interface too so that consumers have the option to more simply render and pull data out only when needed * Handle sub-form validity The pipelines processor editor not emits overall validity to consumers * Fix Jest test * Refactor some names prepareDataOut -> serialize prepareDataIn -> deserialize EditorProcessor -> ProcessorInternal * Mark as private * Major WiP Started working on the drag-and-drop-tree and updated some typings stuff * [Ingest node pipelines] Privileges (#63850) * Create privileges check for ingest pipelines app Also moved the public side logic for checking and rendering privilege related messages to es_ui_shared/public following the new __packages_do_not_import__ convention. * Add , * Fix import paths * Address PR feedback Fix i18n strings (remove reference to snapshot and restore) and fix copy referencing snapshot and restore - all copy-pasta errors. Also remove unused field from missing privileges object. * Fix issue from resolving merge conflicts * Add missing app privilege * Use non-deprecated privilige name * First iteration of drag and drop tree on feature parity * First steps toward add on failure handler Updated reducer logic to create the next copy value using immer. * First iteration of nested tree with add on failure working * Refactor and some UI layout updates - Remove the "id" field on processors for now - Implement the nested remove and update functionality again * Remove immer (not call stack safe) Refac to remove immer and reimplemented the immutable set and get functions. * [Ingest Node Pipelines] More lenient treatment of on-failure value (#64411) * Move file to components folder * [Ingest pipelines] Simulate pipeline (#64223) * Updated tree rendering - turn off dropzones for children - fixed up some padding and margins - fixed integration with pipeline_form.tsx The current implementation still has a lot of jank stemming from the UX with DnD. Unfortunately the nesting has opened a number of issues here. * [Ingest Node Pipelines] Show flyout after editing or creating a pipeline (#64409) * Show flyout after editing or creating a pipeline * JSX comment * Show not found flyout Copied from CCR * update not found flyout and fix behavior when viewing details from table * Reset pipeline name in URI when closing flyout * Remove encodeURI Already using encodingURIComponent for unsafe string. Co-authored-by: Alison Goryachev * Clarification of terms - addProcessor -> addTopLevelProcessor (same in editor modes) - Expanded comment on ProcessorSelector * Implement move between lists functionality Added tests to the reducer for moving in and out of a recusrsive structure. * fix TS * Prevent nesting a parent inside of its own child * Add comment * [Ingest pipelines] Cleanup (#64794) * address review feedback * remove unused import * Big refactor to tree rendering structure DnD tree now converts the nested tree to a flat one and only consists of _1_ droppable area with a flat array of draggables that can be combined. Using the existing logic in the reducer combined with translating the flat structure changes to a format the nested reducer can understand looks like a really promising avenue. There still seems to be a bug with a longer list where items do not interact properly. * Remove unused component * A number of NB changes - Fixed a subtle serialisation bug with on_failure in options - Fixed a performance bug by using useRef in pipeline form - Memoized the drag and drop tree - Removed use of "isValid" from form lib... I think this should be removed entirely. * fix bad conflict resolution * Implemented a slightly better destination resolution algo. Also added tests. * Fix subtle staleness bug, whitelist keys for setValue * NB styling fix!! Due to a parent's setting of overflow: hidden the drag and drop tree had a dead zone that would equal the page overflow limit, unsetting on the component itself (overflow: auto) relaxes this limitation. * Fix stale delete bug too * Update naming of editor modes and update comments * Use field types combo box * Add delete confirmation modal * Refactor delete modal component file name * Better visual integration with existing form * Update layout and styling of form - added some padding around the new processors editor field - Updated use of flex box in the pipeline form fields * Move pipeline processor copy into pipeline processor component The test button is also now inside of the pipeline processor editor. Eventually all of this functionality should be moved into the pipeline processor editor. * First step of refactor to moving between trees * First iteration of x-tree drag and drop * Remove unused import * Fix jest test types * Fix up minor i18n issues and fix up layout of on failure * Remove unnecessary prop * Update spacing above add processor button to make it more center * Fix destination resolution algo * Update dragging resolver unit tests and add a lot more comments * Use one sorting algo (removed use of euiReorder for now) * Add placeholder tests and update comments * Quite a big refactor - Remove DraggableLocation entirely, only use ProcessorSelector this simplifies mapping back to reducer instructions quite a lot - Add tests for special case dragging behaviour * Fix off by one bug in tests and implementation 🤦🏼‍♂ - Also move processor reducer to it's own folder * Update behaviour for dragging up across trees and add tests * Fix combine instruction * Fix test and i18n issues * Remove background color * Fix selector after selector refactor * A major performance - Do not re-render the entire tree when editing settings * Fix component smoke test * Fix reading value from processor state using processor selector * [Ingest pipelines] Custom processor form (#66022) * Re add background color and refactor name of processor item * Fix file naming and refactor 🚜 dnd tree rendering - deserialze and serialize were backwards, fixed - the dnd tree was rendering a flat tree which needed to be mapped back to the nested tree for updates. This is still the case but we do use recursive rendering. This enables tree nodes to better hold local state like whether they are collapsed or not. * Fix getting of initial on failure processors value * Update padding styles for containers * A lot of styling updates to get closer to look of mockup * A WiP version of th click-tree an alternative to dnd As a response to really long pipelines we may pivot away from dnd. * Remove dnd tree * clean up reamining dnd tree references * Clean up and refactor of tree component To simplify the logic of the tree component processor id's were added. Also had to update the jest spec to support this * Added the ability to duplicate a processor * Fix types in test * Added duplicate functionality to ui * Memoize tree components * address es lint issues * remove unused import * Fix editing of custom json * Address form performance issues * Add all known missing processors * Add ability to cancel move * Fix staleness in test and view request flyouts * fix type issue * Remove unused translations and skip funcitonal test for now * add todo comment * Fix type issues * remove poc styles * disable other move buttons if we have a selected processor * Refactor drop zone pin to button and add some styling * Refactor processor editor item * Update styling and use icon for cancel move action too * fix nasty integration bug * some minor optimizations * prevent parent from being placed in own on failure handler * Re-add cancel button * Re-introduce failure processors toggle * Fix typo * Add Handler types for processor editor item * Fix staleness bug for type, refactor classname and fix duplicate bug not immutably copying values * Experimenting with padels (revert this to undo if no further changes have been made) * Add description and unique ids * Share links via component-wide context rather than props * Virtualized list and back to outline dropzones * Refactor id getter to a service and make it an incrementing number * Temporary fix for double rendering issue. This means the pipeline processors component is not controllable but fixes the double rendering even when processors have not changed. This is a problem coming from the ingest pipelines plugin form system outside of this component * add todo comment * remove euicode element * properly handle duplicate flow * attempt to fix i18n * split private_tree into it's own component and add comments * refactor 🚜. rename Tree to ProcessorsTree and move things around * do not delete the top level arrays for processors and onfailures * fix typescript error * Move duplicate, addOnFailure and delete actions into ctx menu * remove unused import * add support for pressing esc key to cancel move * Add outside click listener * always prompt before deleting a processor * refactor remove distinction between adding top level and on fail * add processor button to tree * Hide the add on failure context menu item for processors with failure handlers * Reinstated x-tree moving and highlight and disable for buttons on move and on edit * removing ids step 1: remove idGenerator * remove ids step 2: added inline text input Also refactored a lot of the tree actions away. Now using context and the processors editor tree item component for dispatching actions. The tree item has access to the dispatch and to the selector which makes it a well positioned component. Also saves on some props drilling. * Slight improvement to styling of text input (border) * Re-implement missing failure toggle test * address type todo * Address many type issues and fix yarn.lock * re-enable create pipeline functional test * prevent multiple flyouts from opening * change flyout title when editing an on-failure processor * absolutely position the failure handlers label when we render a dropzone on the label, then we move the label up without affecting overall component height * fix description behaviour not removing tag if empty * some minor clean up * add onflyoutopen cb to tests * refactor processors editor item to multiple files also refactored i18n into it's own file. would be good to come up with a pttaern for doing this more broadly. * fix add on-failure handler in context menu after refactor * tag -> new description field Co-authored-by: Alison Goryachev Co-authored-by: Elastic Machine Co-authored-by: Alison Goryachev --- x-pack/package.json | 1 + .../ingest_pipelines_create.test.tsx | 15 - .../plugins/ingest_pipelines/common/types.ts | 14 +- .../pipeline_form/pipeline_form.tsx | 50 ++- .../pipeline_form/pipeline_form_fields.tsx | 167 ++------ .../pipeline_request_flyout_provider.tsx | 22 +- .../pipeline_test_flyout_provider.tsx | 14 +- .../components/pipeline_form/types.ts | 9 + .../pipeline_processors_editor.helpers.ts | 36 ++ .../pipeline_processors_editor.test.tsx | 67 ++++ .../components/add_processor_button.tsx | 32 ++ .../components/index.ts | 19 + .../on_failure_processors_title.tsx | 52 +++ .../context_menu.tsx | 84 ++++ .../pipeline_processors_editor_item/index.ts | 7 + .../inline_text_input.tsx | 75 ++++ .../messages.ts | 58 +++ .../pipeline_processors_editor_item.scss | 17 + .../pipeline_processors_editor_item.tsx | 145 +++++++ .../components/processor_remove_modal.tsx | 54 +++ .../processor_settings_form/index.ts | 10 + .../map_processor_type_to_form.tsx | 56 +++ .../processor_settings_form.container.tsx | 56 +++ .../processor_settings_form.tsx | 73 ++++ .../common_fields/common_processor_fields.tsx | 55 +++ .../processors/common_fields/index.ts | 9 + .../common_fields/processor_type_field.tsx | 67 ++++ .../processors/custom.tsx | 90 +++++ .../processors/gsub.tsx | 98 +++++ .../processors/set.tsx | 74 ++++ .../processors_title_and_test_button.tsx | 73 ++++ .../components/drop_zone_button.tsx | 42 ++ .../processors_tree/components/index.ts | 11 + .../components/private_tree.tsx | 210 ++++++++++ .../processors_tree/components/tree_node.tsx | 115 ++++++ .../components/processors_tree/index.ts | 7 + .../processors_tree/processors_tree.scss | 74 ++++ .../processors_tree/processors_tree.tsx | 110 +++++ .../components/processors_tree/utils.ts | 41 ++ .../components/settings_form_flyout.tsx | 67 ++++ .../pipeline_processors_editor/constants.ts | 10 + .../pipeline_processors_editor/context.tsx | 55 +++ .../pipeline_processors_editor/deserialize.ts | 56 +++ .../pipeline_processors_editor/index.ts | 11 + .../pipeline_processors_editor.container.tsx | 76 ++++ .../pipeline_processors_editor.scss | 3 + .../pipeline_processors_editor.tsx | 239 +++++++++++ .../processors_reducer/index.ts | 15 + .../processors_reducer.test.ts | 376 ++++++++++++++++++ .../processors_reducer/processors_reducer.ts | 136 +++++++ .../processors_reducer/utils.ts | 100 +++++ .../pipeline_processors_editor/serialize.ts | 49 +++ .../pipeline_processors_editor/types.ts | 51 +++ .../pipeline_processors_editor/utils.test.ts | 36 ++ .../pipeline_processors_editor/utils.ts | 101 +++++ .../ingest_pipelines/public/shared_imports.ts | 9 + .../translations/translations/ja-JP.json | 10 - .../translations/translations/zh-CN.json | 10 - .../apps/ingest_pipelines/ingest_pipelines.ts | 16 - 59 files changed, 3438 insertions(+), 197 deletions(-) create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/types.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/index.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/messages.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_remove_modal.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/index.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/map_processor_type_to_form.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.container.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/common_processor_fields.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/index.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/processor_type_field.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/custom.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/gsub.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/set.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_title_and_test_button.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/index.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/tree_node.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/index.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/utils.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/settings_form_flyout.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/constants.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/index.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.tsx create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/index.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.test.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/utils.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/serialize.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/types.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.test.ts create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts diff --git a/x-pack/package.json b/x-pack/package.json index 6639aca0219fc..d9d0382452260 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -332,6 +332,7 @@ "react-syntax-highlighter": "^5.7.0", "react-tiny-virtual-list": "^2.2.0", "react-use": "^13.27.0", + "react-virtualized": "^9.21.2", "react-vis": "^1.8.1", "react-visibility-sensor": "^5.1.1", "recompose": "^0.26.0", diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx index 6acb6369e2e90..4318062491df8 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx @@ -68,21 +68,6 @@ describe('', () => { expect(exists('versionField')).toBe(true); }); - test('should toggle the on-failure processors editor', async () => { - const { actions, component, exists } = testBed; - - // On-failure editor should be hidden by default - expect(exists('onFailureEditor')).toBe(false); - - await act(async () => { - actions.toggleOnFailureSwitch(); - await nextTick(); - component.update(); - }); - - expect(exists('onFailureEditor')).toBe(true); - }); - test('should show the request flyout', async () => { const { actions, component, find, exists } = testBed; diff --git a/x-pack/plugins/ingest_pipelines/common/types.ts b/x-pack/plugins/ingest_pipelines/common/types.ts index 8d77359a7c3c5..ad6d1cc8aa928 100644 --- a/x-pack/plugins/ingest_pipelines/common/types.ts +++ b/x-pack/plugins/ingest_pipelines/common/types.ts @@ -4,10 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -interface Processor { - [key: string]: { - [key: string]: unknown; - }; +export interface ESProcessorConfig { + on_failure?: Processor[]; + ignore_failure?: boolean; + if?: string; + tag?: string; + [key: string]: any; +} + +export interface Processor { + [typeName: string]: ESProcessorConfig; } export interface Pipeline { 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 73bc1cfaa2cf5..ec065a74abca0 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React, { useState, useCallback, useRef } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; @@ -16,6 +16,11 @@ import { PipelineTestFlyout } from './pipeline_test_flyout'; import { PipelineFormFields } from './pipeline_form_fields'; import { PipelineFormError } from './pipeline_form_error'; import { pipelineFormSchema } from './schema'; +import { + OnUpdateHandlerArg, + OnUpdateHandler, + SerializeResult, +} from '../pipeline_processors_editor'; export interface PipelineFormProps { onSave: (pipeline: Pipeline) => void; @@ -30,8 +35,8 @@ export const PipelineForm: React.FunctionComponent = ({ defaultValue = { name: '', description: '', - processors: '', - on_failure: '', + processors: [], + on_failure: [], version: '', }, onSave, @@ -44,10 +49,25 @@ export const PipelineForm: React.FunctionComponent = ({ const [isTestingPipeline, setIsTestingPipeline] = useState(false); + const processorStateRef = useRef(); + const handleSave: FormConfig['onSubmit'] = async (formData, isValid) => { - if (isValid) { - onSave(formData as Pipeline); + let override: SerializeResult | undefined; + + if (!isValid) { + return; + } + + if (processorStateRef.current) { + const processorsState = processorStateRef.current; + if (await processorsState.validate()) { + override = processorsState.getData(); + } else { + return; + } } + + onSave({ ...formData, ...(override || {}) } as Pipeline); }; const handleTestPipelineClick = () => { @@ -60,6 +80,10 @@ export const PipelineForm: React.FunctionComponent = ({ onSubmit: handleSave, }); + const onEditorFlyoutOpen = useCallback(() => { + setIsRequestVisible(false); + }, [setIsRequestVisible]); + const saveButtonLabel = isSaving ? ( = ({ /> ); + const onProcessorsChangeHandler = useCallback( + (arg) => (processorStateRef.current = arg), + [] + ); + return ( <>
= ({ {/* All form fields */} @@ -147,6 +179,9 @@ export const PipelineForm: React.FunctionComponent = ({ {/* ES request flyout */} {isRequestVisible ? ( + processorStateRef.current?.getData() || { processors: [], on_failure: [] } + } closeFlyout={() => setIsRequestVisible((prevIsRequestVisible) => !prevIsRequestVisible)} /> ) : null} @@ -154,6 +189,9 @@ export const PipelineForm: React.FunctionComponent = ({ {/* Test pipeline flyout */} {isTestingPipeline ? ( + processorStateRef.current?.getData() || { processors: [], on_failure: [] } + } closeFlyout={() => { setIsTestingPipeline((prevIsTestingPipeline) => !prevIsTestingPipeline); }} 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 9fb5ab55a34ce..a0e7c8fd8bcd7 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,22 +6,22 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { EuiButton, EuiSpacer, EuiSwitch, EuiLink } from '@elastic/eui'; +import { EuiSpacer, EuiSwitch } from '@elastic/eui'; -import { - getUseField, - getFormRow, - Field, - JsonEditorField, - useKibana, -} from '../../../shared_imports'; +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'; interface Props { + initialProcessors: Processor[]; + initialOnFailureProcessors?: Processor[]; + onProcessorsUpdate: OnUpdateHandler; hasVersion: boolean; - hasOnFailure: boolean; isTestButtonDisabled: boolean; onTestPipelineClick: () => void; + onEditorFlyoutOpen: () => void; isEditing?: boolean; } @@ -29,16 +29,18 @@ const UseField = getUseField({ component: Field }); const FormRow = getFormRow({ titleTag: 'h3' }); export const PipelineFormFields: React.FunctionComponent = ({ + initialProcessors, + initialOnFailureProcessors, + onProcessorsUpdate, isEditing, hasVersion, - hasOnFailure, isTestButtonDisabled, onTestPipelineClick, + onEditorFlyoutOpen, }) => { const { services } = useKibana(); const [isVersionVisible, setIsVersionVisible] = useState(hasVersion); - const [isOnFailureEditorVisible, setIsOnFailureEditorVisible] = useState(hasOnFailure); return ( <> @@ -110,127 +112,32 @@ export const PipelineFormFields: React.FunctionComponent = ({ /> - {/* Processors field */} - - } - description={ - <> - - {i18n.translate('xpack.ingestPipelines.form.processorsDocumentionLink', { - defaultMessage: 'Learn more', - })} - - ), - }} - /> - - + {/* 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 ?? []; - {/* On-failure field */} - - } - description={ - <> - - {i18n.translate('xpack.ingestPipelines.form.onFailureDocumentionLink', { - defaultMessage: 'Learn more', - })} - - ), - }} - /> - - - } - checked={isOnFailureEditorVisible} - onChange={(e) => setIsOnFailureEditorVisible(e.target.checked)} - data-test-subj="onFailureToggle" + return ( + - - } - > - {isOnFailureEditorVisible ? ( - - ) : ( - // requires children or a field - // For now, we return an empty
if the editor is not visible -
- )} - + ); + }} + ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout_provider.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout_provider.tsx index 6dcedca6085af..dd2439433fc41 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout_provider.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout_provider.tsx @@ -4,13 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, FunctionComponent } from 'react'; import { Pipeline } from '../../../../../common/types'; import { useFormContext } from '../../../../shared_imports'; + +import { ReadProcessorsFunction } from '../types'; + import { PipelineRequestFlyout } from './pipeline_request_flyout'; -export const PipelineRequestFlyoutProvider = ({ closeFlyout }: { closeFlyout: () => void }) => { +interface Props { + closeFlyout: () => void; + readProcessors: ReadProcessorsFunction; +} + +export const PipelineRequestFlyoutProvider: FunctionComponent = ({ + closeFlyout, + readProcessors, +}) => { const form = useFormContext(); const [formData, setFormData] = useState({} as Pipeline); @@ -25,5 +36,10 @@ export const PipelineRequestFlyoutProvider = ({ closeFlyout }: { closeFlyout: () return subscription.unsubscribe; }, [form]); - return ; + return ( + + ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/pipeline_test_flyout_provider.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/pipeline_test_flyout_provider.tsx index 351478394595a..7f91672d64df4 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/pipeline_test_flyout_provider.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/pipeline_test_flyout_provider.tsx @@ -8,11 +8,19 @@ import React, { useState, useEffect } from 'react'; import { Pipeline } from '../../../../../common/types'; import { useFormContext } from '../../../../shared_imports'; + +import { ReadProcessorsFunction } from '../types'; + import { PipelineTestFlyout, PipelineTestFlyoutProps } from './pipeline_test_flyout'; -type Props = Omit; +interface Props extends Omit { + readProcessors: ReadProcessorsFunction; +} -export const PipelineTestFlyoutProvider: React.FunctionComponent = ({ closeFlyout }) => { +export const PipelineTestFlyoutProvider: React.FunctionComponent = ({ + closeFlyout, + readProcessors, +}) => { const form = useFormContext(); const [formData, setFormData] = useState({} as Pipeline); const [isFormDataValid, setIsFormDataValid] = useState(false); @@ -31,7 +39,7 @@ export const PipelineTestFlyoutProvider: React.FunctionComponent = ({ clo return ( 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 new file mode 100644 index 0000000000000..bd74f09546ff4 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/types.ts @@ -0,0 +1,9 @@ +/* + * 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 { Pipeline } from '../../../../common/types'; + +export type ReadProcessorsFunction = () => Pick; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.ts new file mode 100644 index 0000000000000..acd61a9bbd01e --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.ts @@ -0,0 +1,36 @@ +/* + * 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 { registerTestBed, TestBed } from '../../../../../../../test_utils'; +import { PipelineProcessorsEditor, Props } from '../pipeline_processors_editor.container'; + +const testBedSetup = registerTestBed(PipelineProcessorsEditor, { + doMountAsync: false, +}); + +export interface SetupResult extends TestBed { + actions: { + toggleOnFailure: () => void; + }; +} + +export const setup = async (props: Props): Promise => { + const testBed = await testBedSetup(props); + const toggleOnFailure = () => { + const { find } = testBed; + find('pipelineEditorOnFailureToggle').simulate('click'); + }; + + return { + ...testBed, + actions: { toggleOnFailure }, + }; +}; + +type TestSubject = + | 'pipelineEditorDoneButton' + | 'pipelineEditorOnFailureToggle' + | 'pipelineEditorOnFailureTree'; 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 new file mode 100644 index 0000000000000..758d6f5e620ce --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setup } from './pipeline_processors_editor.helpers'; +import { Pipeline } from '../../../../../common/types'; + +const testProcessors: Pick = { + processors: [ + { + script: { + source: 'ctx._type = null', + }, + }, + { + gsub: { + field: '_index', + pattern: '(.monitoring-\\w+-)6(-.+)', + replacement: '$17$2', + }, + }, + ], +}; + +describe('Pipeline Editor', () => { + it('provides the same data out it got in if nothing changes', async () => { + const onUpdate = jest.fn(); + + await setup({ + value: { + ...testProcessors, + }, + onFlyoutOpen: jest.fn(), + onUpdate, + isTestButtonDisabled: false, + onTestPipelineClick: jest.fn(), + learnMoreAboutProcessorsUrl: 'test', + learnMoreAboutOnFailureProcessorsUrl: 'test', + }); + + const { + calls: [[arg]], + } = onUpdate.mock; + + expect(arg.getData()).toEqual(testProcessors); + }); + + it('toggles the on-failure processors', async () => { + const { actions, exists } = await setup({ + value: { + ...testProcessors, + }, + onFlyoutOpen: jest.fn(), + onUpdate: jest.fn(), + isTestButtonDisabled: false, + onTestPipelineClick: jest.fn(), + learnMoreAboutProcessorsUrl: 'test', + learnMoreAboutOnFailureProcessorsUrl: 'test', + }); + + expect(exists('pipelineEditorOnFailureTree')).toBe(false); + actions.toggleOnFailure(); + expect(exists('pipelineEditorOnFailureTree')).toBe(true); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx new file mode 100644 index 0000000000000..5f9bf87ceca1e --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx @@ -0,0 +1,32 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { usePipelineProcessorsContext } from '../context'; + +export interface Props { + onClick: () => void; +} + +export const AddProcessorButton: FunctionComponent = ({ onClick }) => { + const { + state: { editor }, + } = usePipelineProcessorsContext(); + return ( + + {i18n.translate('xpack.ingestPipelines.pipelineEditor.addProcessorButtonLabel', { + defaultMessage: 'Add a processor', + })} + + ); +}; 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 new file mode 100644 index 0000000000000..cb5d5a10e9f42 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts @@ -0,0 +1,19 @@ +/* + * 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 { SettingsFormFlyout, OnSubmitHandler } from './settings_form_flyout'; + +export { ProcessorSettingsForm, ProcessorSettingsFromOnSubmitArg } from './processor_settings_form'; + +export { ProcessorsTree, ProcessorInfo, OnActionHandler } from './processors_tree'; + +export { PipelineProcessorsEditorItem } from './pipeline_processors_editor_item/pipeline_processors_editor_item'; + +export { ProcessorRemoveModal } from './processor_remove_modal'; + +export { ProcessorsTitleAndTestButton } from './processors_title_and_test_button'; + +export { OnFailureProcessorsTitle } from './on_failure_processors_title'; 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_processors_editor/components/on_failure_processors_title.tsx new file mode 100644 index 0000000000000..1c8edac7cfd64 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx @@ -0,0 +1,52 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText, EuiTitle } from '@elastic/eui'; +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { usePipelineProcessorsContext } from '../context'; + +export const OnFailureProcessorsTitle: FunctionComponent = () => { + const { links } = usePipelineProcessorsContext(); + return ( + + + +

+ +

+
+ + + {i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.onFailureProcessorsDocumentationLink', + { + defaultMessage: 'Learn more.', + } + )} + + ), + }} + /> + +
+
+ ); +}; 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 new file mode 100644 index 0000000000000..bc7d6fdcff357 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx @@ -0,0 +1,84 @@ +/* + * 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, useState } from 'react'; + +import { EuiContextMenuItem, EuiContextMenuPanel, EuiPopover, EuiButtonIcon } from '@elastic/eui'; + +import { editorItemMessages } from './messages'; + +interface Props { + disabled: boolean; + showAddOnFailure: boolean; + onDuplicate: () => void; + onDelete: () => void; + onAddOnFailure: () => void; +} + +export const ContextMenu: FunctionComponent = ({ + showAddOnFailure, + onDuplicate, + onAddOnFailure, + onDelete, + disabled, +}) => { + const [isOpen, setIsOpen] = useState(false); + + const contextMenuItems = [ + { + setIsOpen(false); + onDuplicate(); + }} + > + {editorItemMessages.duplicateButtonLabel} + , + showAddOnFailure ? ( + { + setIsOpen(false); + onAddOnFailure(); + }} + > + {editorItemMessages.addOnFailureButtonLabel} + + ) : undefined, + { + setIsOpen(false); + onDelete(); + }} + > + {editorItemMessages.deleteButtonLabel} + , + ].filter(Boolean) as JSX.Element[]; + + return ( + setIsOpen(false)} + button={ + setIsOpen((v) => !v)} + iconType="boxesHorizontal" + aria-label={editorItemMessages.moreButtonAriaLabel} + /> + } + > + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/index.ts new file mode 100644 index 0000000000000..02bafdb326024 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/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 { PipelineProcessorsEditorItem, Handlers } from './pipeline_processors_editor_item'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx new file mode 100644 index 0000000000000..e0b67bc907ca9 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx @@ -0,0 +1,75 @@ +/* + * 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, useState, useEffect, useCallback } from 'react'; +import { EuiFieldText, EuiText, keyCodes } from '@elastic/eui'; + +export interface Props { + placeholder: string; + ariaLabel: string; + onChange: (value: string) => void; + text?: string; +} + +export const InlineTextInput: FunctionComponent = ({ + placeholder, + text, + ariaLabel, + onChange, +}) => { + const [isShowingTextInput, setIsShowingTextInput] = useState(false); + const [textValue, setTextValue] = useState(text ?? ''); + + const content = isShowingTextInput ? ( + el?.focus()} + onChange={(event) => setTextValue(event.target.value)} + /> + ) : ( + + {text || {placeholder}} + + ); + + const submitChange = useCallback(() => { + setIsShowingTextInput(false); + onChange(textValue); + }, [setIsShowingTextInput, onChange, textValue]); + + useEffect(() => { + const keyboardListener = (event: KeyboardEvent) => { + if (event.keyCode === keyCodes.ESCAPE || event.code === 'Escape') { + setIsShowingTextInput(false); + } + if (event.keyCode === keyCodes.ENTER || event.code === 'Enter') { + submitChange(); + } + }; + if (isShowingTextInput) { + window.addEventListener('keyup', keyboardListener); + } + return () => { + window.removeEventListener('keyup', keyboardListener); + }; + }, [isShowingTextInput, submitChange, setIsShowingTextInput]); + + return ( +
setIsShowingTextInput(true)} + onBlur={submitChange} + > + {content} +
+ ); +}; 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/messages.ts new file mode 100644 index 0000000000000..67dbf2708d665 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/messages.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const editorItemMessages = { + moveButtonLabel: i18n.translate('xpack.ingestPipelines.pipelineEditor.item.moveButtonLabel', { + defaultMessage: 'Move this processor', + }), + editorButtonLabel: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.item.editButtonAriaLabel', + { + defaultMessage: 'Edit this processor', + } + ), + duplicateButtonLabel: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.item.moreMenu.duplicateButtonLabel', + { + defaultMessage: 'Duplicate this processor', + } + ), + addOnFailureButtonLabel: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.item.moreMenu.addOnFailureHandlerButtonLabel', + { + defaultMessage: 'Add on failure handler', + } + ), + cancelMoveButtonLabel: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.item.cancelMoveButtonAriaLabel', + { + defaultMessage: 'Cancel moving this processor', + } + ), + deleteButtonLabel: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.item.moreMenu.deleteButtonLabel', + { + defaultMessage: 'Delete', + } + ), + moreButtonAriaLabel: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.item.moreButtonAriaLabel', + { + defaultMessage: 'Show more actions for this processor', + } + ), + processorTypeLabel: ({ type }: { type: string }) => + i18n.translate('xpack.ingestPipelines.pipelineEditor.item.textInputAriaLabel', { + defaultMessage: 'Provide a description for this {type} processor', + values: { type }, + }), + descriptionPlaceholder: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.item.descriptionPlaceholder', + { defaultMessage: 'No description' } + ), +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss new file mode 100644 index 0000000000000..a17e644853847 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss @@ -0,0 +1,17 @@ +.pipelineProcessorsEditor__item { + &__textContainer { + padding: 4px; + border-radius: 2px; + + transition: border-color .3s; + border: 2px solid #FFF; + + &:hover { + border: 2px solid $euiColorLightShade; + } + } + &__textInput { + height: 21px; + min-width: 100px; + } +} 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 new file mode 100644 index 0000000000000..0e47b3ef7cf88 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx @@ -0,0 +1,145 @@ +/* + * 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 } from 'react'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui'; + +import { ProcessorInternal, ProcessorSelector } from '../../types'; + +import { usePipelineProcessorsContext } from '../../context'; + +import './pipeline_processors_editor_item.scss'; + +import { InlineTextInput } from './inline_text_input'; +import { ContextMenu } from './context_menu'; +import { editorItemMessages } from './messages'; + +export interface Handlers { + onMove: () => void; + onCancelMove: () => void; +} + +export interface Props { + processor: ProcessorInternal; + selected: boolean; + handlers: Handlers; + selector: ProcessorSelector; + description?: string; +} + +export const PipelineProcessorsEditorItem: FunctionComponent = memo( + ({ processor, description, handlers: { onCancelMove, onMove }, selector, selected }) => { + const { + state: { editor, processorsDispatch }, + } = usePipelineProcessorsContext(); + + const disabled = editor.mode.id !== 'idle'; + const isDarkBold = + editor.mode.id !== 'editingProcessor' || processor.id === editor.mode.arg.processor.id; + + return ( + + + + + + {processor.type} + + + + { + let nextOptions: Record; + if (!nextDescription) { + const { description: __, ...restOptions } = processor.options; + nextOptions = restOptions; + } else { + nextOptions = { + ...processor.options, + description: nextDescription, + }; + } + processorsDispatch({ + type: 'updateProcessor', + payload: { + processor: { + ...processor, + options: nextOptions, + }, + selector, + }, + }); + }} + ariaLabel={editorItemMessages.processorTypeLabel({ type: processor.type })} + text={description} + placeholder={editorItemMessages.descriptionPlaceholder} + /> + + + { + editor.setMode({ + id: 'editingProcessor', + arg: { processor, selector }, + }); + }} + /> + + + {selected ? ( + + ) : ( + + + + )} + + + + + { + editor.setMode({ id: 'creatingProcessor', arg: { selector } }); + }} + onDelete={() => { + editor.setMode({ id: 'removingProcessor', arg: { selector } }); + }} + onDuplicate={() => { + processorsDispatch({ + type: 'duplicateProcessor', + payload: { + source: selector, + }, + }); + }} + /> + + + ); + } +); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_remove_modal.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_remove_modal.tsx new file mode 100644 index 0000000000000..c38e470b36699 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_remove_modal.tsx @@ -0,0 +1,54 @@ +/* + * 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 from 'react'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { ProcessorInternal, ProcessorSelector } from '../types'; + +interface Props { + processor: ProcessorInternal; + selector: ProcessorSelector; + onResult: (arg: { confirmed: boolean; selector: ProcessorSelector }) => void; +} + +export const ProcessorRemoveModal = ({ processor, onResult, selector }: Props) => { + return ( + + + } + onCancel={() => onResult({ confirmed: false, selector })} + onConfirm={() => onResult({ confirmed: true, selector })} + cancelButtonText={ + + } + confirmButtonText={ + + } + > +

+ +

+
+
+ ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/index.ts new file mode 100644 index 0000000000000..60a1aa0a96fb1 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + ProcessorSettingsForm, + ProcessorSettingsFromOnSubmitArg, +} from './processor_settings_form.container'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/map_processor_type_to_form.tsx new file mode 100644 index 0000000000000..e8164a0057d39 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/map_processor_type_to_form.tsx @@ -0,0 +1,56 @@ +/* + * 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 { FunctionComponent } from 'react'; + +// import { SetProcessor } from './processors/set'; +// import { Gsub } from './processors/gsub'; + +const mapProcessorTypeToForm = { + append: undefined, // TODO: Implement + bytes: undefined, // TODO: Implement + circle: undefined, // TODO: Implement + convert: undefined, // TODO: Implement + csv: undefined, // TODO: Implement + date: undefined, // TODO: Implement + date_index_name: undefined, // TODO: Implement + dissect: undefined, // TODO: Implement + dot_expander: undefined, // TODO: Implement + drop: undefined, // TODO: Implement + enrich: undefined, // TODO: Implement + fail: undefined, // TODO: Implement + foreach: undefined, // TODO: Implement + geoip: undefined, // TODO: Implement + grok: undefined, // TODO: Implement + html_strip: undefined, // TODO: Implement + inference: undefined, // TODO: Implement + join: undefined, // TODO: Implement + json: undefined, // TODO: Implement + kv: undefined, // TODO: Implement + lowercase: undefined, // TODO: Implement + pipeline: undefined, // TODO: Implement + remove: undefined, // TODO: Implement + rename: undefined, // TODO: Implement + script: undefined, // TODO: Implement + set_security_user: undefined, // TODO: Implement + split: undefined, // TODO: Implement + sort: undefined, // TODO: Implement + trim: undefined, // TODO: Implement + uppercase: undefined, // TODO: Implement + urldecode: undefined, // TODO: Implement + user_agent: undefined, // TODO: Implement + + gsub: undefined, + set: undefined, +}; + +export const types = Object.keys(mapProcessorTypeToForm); + +export type ProcessorType = keyof typeof mapProcessorTypeToForm; + +export const getProcessorForm = (type: ProcessorType | string): FunctionComponent | undefined => { + return mapProcessorTypeToForm[type as ProcessorType]; +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.container.tsx new file mode 100644 index 0000000000000..29b52ef84600a --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.container.tsx @@ -0,0 +1,56 @@ +/* + * 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, useCallback, useEffect } from 'react'; + +import { useForm, OnFormUpdateArg, FormData } from '../../../../../shared_imports'; +import { ProcessorInternal } from '../../types'; + +import { ProcessorSettingsForm as ViewComponent } from './processor_settings_form'; + +export type ProcessorSettingsFromOnSubmitArg = Omit; + +interface Props { + onFormUpdate: (form: OnFormUpdateArg) => void; + onSubmit: (processor: ProcessorSettingsFromOnSubmitArg) => void; + processor?: ProcessorInternal; +} + +export const ProcessorSettingsForm: FunctionComponent = ({ + processor, + onFormUpdate, + onSubmit, +}) => { + const handleSubmit = useCallback( + async (data: FormData, isValid: boolean) => { + if (isValid) { + const { type, customOptions, ...options } = data; + onSubmit({ + type, + options: customOptions ? customOptions : options, + }); + } + }, + [onSubmit] + ); + + const { form } = useForm({ + defaultValue: processor?.options, + onSubmit: handleSubmit, + }); + + useEffect(() => { + const subscription = form.subscribe(onFormUpdate); + return subscription.unsubscribe; + + // TODO: Address this issue + // For some reason adding `form` object to the dependencies array here is causing an + // infinite update loop. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onFormUpdate]); + + return ; +}; 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 new file mode 100644 index 0000000000000..49bde2129aab6 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import React, { FunctionComponent, memo } from 'react'; +import { EuiButton, EuiHorizontalRule } from '@elastic/eui'; + +import { Form, useForm, FormDataProvider } from '../../../../../shared_imports'; + +import { ProcessorInternal } from '../../types'; + +import { getProcessorForm } from './map_processor_type_to_form'; +import { CommonProcessorFields, ProcessorTypeField } from './processors/common_fields'; +import { Custom } from './processors/custom'; + +export interface Props { + processor?: ProcessorInternal; + form: ReturnType['form']; +} + +export const ProcessorSettingsForm: FunctionComponent = memo( + ({ processor, form }) => { + return ( + + + + + + + {(arg: any) => { + const { type } = arg; + let formContent: React.ReactNode | undefined; + + if (type?.length) { + const ProcessorFormFields = getProcessorForm(type as any); + + if (ProcessorFormFields) { + formContent = ( + <> + + + + ); + } else { + formContent = ; + } + + return ( + <> + {formContent} + + {i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.settingsForm.submitButtonLabel', + { defaultMessage: 'Submit' } + )} + + + ); + } + + // If the user has not yet defined a type, we do not show any settings fields + return null; + }} + + + ); + }, + (previous, current) => { + return previous.processor === current.processor; + } +); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/common_processor_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/common_processor_fields.tsx new file mode 100644 index 0000000000000..4802653f9e680 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/common_processor_fields.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { + FieldConfig, + UseField, + FIELD_TYPES, + Field, + ToggleField, +} from '../../../../../../../shared_imports'; + +const ignoreFailureConfig: FieldConfig = { + defaultValue: false, + label: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.commonFields.ignoreFailureFieldLabel', + { + defaultMessage: 'Ignore failure', + } + ), + type: FIELD_TYPES.TOGGLE, +}; + +const ifConfig: FieldConfig = { + defaultValue: undefined, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.commonFields.ifFieldLabel', { + defaultMessage: 'Condition (optional)', + }), + type: FIELD_TYPES.TEXT, +}; + +const tagConfig: FieldConfig = { + defaultValue: undefined, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.commonFields.tagFieldLabel', { + defaultMessage: 'Tag (optional)', + }), + type: FIELD_TYPES.TEXT, +}; + +export const CommonProcessorFields: FunctionComponent = () => { + return ( + <> + + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/index.ts new file mode 100644 index 0000000000000..f3fa0e028faaa --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { ProcessorTypeField } from './processor_type_field'; + +export { CommonProcessorFields } from './common_processor_fields'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/processor_type_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/processor_type_field.tsx new file mode 100644 index 0000000000000..6c86fc16bcdd0 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/common_fields/processor_type_field.tsx @@ -0,0 +1,67 @@ +/* + * 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 { + FIELD_TYPES, + FieldConfig, + UseField, + fieldValidators, + ComboBoxField, +} from '../../../../../../../shared_imports'; +import { types } from '../../map_processor_type_to_form'; + +interface Props { + initialType?: string; +} + +const { emptyField } = fieldValidators; + +const typeConfig: FieldConfig = { + type: FIELD_TYPES.COMBO_BOX, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.typeField.typeFieldLabel', { + defaultMessage: 'Processor', + }), + deserializer: (value: string | undefined) => { + if (value) { + return [value]; + } + return []; + }, + serializer: (value: string[]) => { + return value[0]; + }, + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.pipelineEditor.typeField.fieldRequiredError', { + defaultMessage: 'A type is required.', + }) + ), + }, + ], +}; + +export const ProcessorTypeField: FunctionComponent = ({ initialType }) => { + return ( + ({ label: type, value: type })), + noSuggestions: false, + singleSelection: { + asPlainText: true, + }, + }, + }} + /> + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/custom.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/custom.tsx new file mode 100644 index 0000000000000..61fc31a7b472a --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/custom.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { + FieldConfig, + FIELD_TYPES, + fieldValidators, + UseField, + JsonEditorField, +} from '../../../../../../shared_imports'; + +const { emptyField, isJsonField } = fieldValidators; + +const customConfig: FieldConfig = { + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.customForm.optionsFieldLabel', { + defaultMessage: 'Configuration options', + }), + serializer: (value: string) => { + try { + return JSON.parse(value); + } catch (error) { + // swallow error and return non-parsed value; + return value; + } + }, + deserializer: (value: any) => { + if (value === '') { + return '{\n\n}'; + } + return JSON.stringify(value, null, 2); + }, + validations: [ + { + validator: emptyField( + i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.customForm.configurationRequiredError', + { + defaultMessage: 'Configuration options are required.', + } + ) + ), + }, + { + validator: isJsonField( + i18n.translate('xpack.ingestPipelines.pipelineEditor.customForm.invalidJsonError', { + defaultMessage: 'The input is not valid.', + }) + ), + }, + ], +}; + +interface Props { + defaultOptions?: any; +} + +/** + * This is a catch-all component to support settings for custom processors + * or existing processors not yet supported by the UI. + * + * We store the settings in a field called "customOptions" + **/ +export const Custom: FunctionComponent = ({ defaultOptions }) => { + return ( + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/gsub.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/gsub.tsx new file mode 100644 index 0000000000000..77f85e61eff6b --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/gsub.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { + FieldConfig, + FIELD_TYPES, + fieldValidators, + ToggleField, + UseField, + Field, +} from '../../../../../../shared_imports'; + +const { emptyField } = fieldValidators; + +const fieldConfig: FieldConfig = { + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.fieldFieldLabel', { + defaultMessage: 'Field', + }), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.fieldRequiredError', { + defaultMessage: 'A field value is required.', + }) + ), + }, + ], +}; + +const patternConfig: FieldConfig = { + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.patternFieldLabel', { + defaultMessage: 'Pattern', + }), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.patternRequiredError', { + defaultMessage: 'A pattern value is required.', + }) + ), + }, + ], +}; + +const replacementConfig: FieldConfig = { + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.replacementFieldLabel', { + defaultMessage: 'Replacement', + }), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.replacementRequiredError', { + defaultMessage: 'A replacement value is required.', + }) + ), + }, + ], +}; + +const targetConfig: FieldConfig = { + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.targetFieldLabel', { + defaultMessage: 'Target field (optional)', + }), +}; + +const ignoreMissingConfig: FieldConfig = { + defaultValue: false, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.ignoreMissingFieldLabel', { + defaultMessage: 'Ignore missing', + }), + type: FIELD_TYPES.TOGGLE, +}; + +export const Gsub: FunctionComponent = () => { + return ( + <> + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/set.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/set.tsx new file mode 100644 index 0000000000000..1ba6a14d0448d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processors/set.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, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { + FieldConfig, + FIELD_TYPES, + fieldValidators, + ToggleField, + UseField, + Field, +} from '../../../../../../shared_imports'; + +const { emptyField } = fieldValidators; + +const fieldConfig: FieldConfig = { + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.fieldFieldLabel', { + defaultMessage: 'Field', + }), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.fieldRequiredError', { + defaultMessage: 'A field value is required.', + }) + ), + }, + ], +}; + +const valueConfig: FieldConfig = { + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.valueFieldLabel', { + defaultMessage: 'Value', + }), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.valueRequiredError', { + defaultMessage: 'A value to set is required.', + }) + ), + }, + ], +}; + +const overrideConfig: FieldConfig = { + defaultValue: false, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.overrideFieldLabel', { + defaultMessage: 'Override', + }), + type: FIELD_TYPES.TOGGLE, +}; + +/** + * Disambiguate name from the Set data structure + */ +export const SetProcessor: FunctionComponent = () => { + 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_processors_editor/components/processors_title_and_test_button.tsx new file mode 100644 index 0000000000000..bc646c9eefa55 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_title_and_test_button.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLink, EuiText, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { usePipelineProcessorsContext } from '../context'; + +export interface Props { + onTestPipelineClick: () => void; + isTestButtonDisabled: boolean; +} + +export const ProcessorsTitleAndTestButton: FunctionComponent = ({ + onTestPipelineClick, + isTestButtonDisabled, +}) => { + const { links } = usePipelineProcessorsContext(); + return ( + + + +

+ {i18n.translate('xpack.ingestPipelines.pipelineEditor.processorsTreeTitle', { + defaultMessage: 'Processors', + })} +

+
+ + + {i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.processorsDocumentationLink', + { + defaultMessage: 'Learn more.', + } + )} + + ), + }} + /> + +
+ + + + + +
+ ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx new file mode 100644 index 0000000000000..a47886292cf32 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import React, { FunctionComponent } from 'react'; +import classNames from 'classnames'; +import { EuiButtonIcon, EuiFlexItem } from '@elastic/eui'; + +export interface Props { + isDisabled: boolean; + onClick: (event: React.MouseEvent) => void; +} + +const MOVE_HERE_LABEL = i18n.translate('xpack.ingestPipelines.pipelineEditor.moveTargetLabel', { + defaultMessage: 'Move here', +}); + +export const DropZoneButton: FunctionComponent = ({ onClick, isDisabled }) => { + const containerClasses = classNames({ + 'pipelineProcessorsEditor__tree__dropZoneContainer--active': !isDisabled, + }); + const buttonClasses = classNames({ + 'pipelineProcessorsEditor__tree__dropZoneButton--active': !isDisabled, + }); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/index.ts new file mode 100644 index 0000000000000..e9548624d2cef --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DropZoneButton } from './drop_zone_button'; + +export { PrivateTree } from './private_tree'; + +export { TreeNode } from './tree_node'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx new file mode 100644 index 0000000000000..bdc6b2eb44e2d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx @@ -0,0 +1,210 @@ +/* + * 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, MutableRefObject, useEffect } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { AutoSizer, List, WindowScroller } from 'react-virtualized'; + +import { DropSpecialLocations } from '../../../constants'; +import { ProcessorInternal, ProcessorSelector } from '../../../types'; +import { isChildPath } from '../../../processors_reducer'; + +import { DropZoneButton } from '.'; +import { TreeNode } from '.'; +import { calculateItemHeight } from '../utils'; +import { OnActionHandler, ProcessorInfo } from '../processors_tree'; + +export interface PrivateProps { + processors: ProcessorInternal[]; + selector: ProcessorSelector; + onAction: OnActionHandler; + level: number; + movingProcessor?: ProcessorInfo; + // Only passed into the top level list + windowScrollerRef?: MutableRefObject; + listRef?: MutableRefObject; +} + +const isDropZoneAboveDisabled = (processor: ProcessorInfo, selectedProcessor: ProcessorInfo) => { + return Boolean( + // Is the selected node first in a list? + (!selectedProcessor.aboveId && selectedProcessor.id === processor.id) || + isChildPath(selectedProcessor.selector, processor.selector) + ); +}; + +const isDropZoneBelowDisabled = (processor: ProcessorInfo, selectedProcessor: ProcessorInfo) => { + return ( + processor.id === selectedProcessor.id || + processor.belowId === selectedProcessor.id || + isChildPath(selectedProcessor.selector, processor.selector) + ); +}; + +/** + * Recursively rendering tree component for ingest pipeline processors. + * + * Note: this tree should start at level 1. It is the only level at + * which we render the optimised virtual component. This gives a + * massive performance boost to this component which can get very tall. + * + * The first level list also contains the outside click listener which + * enables users to click outside of the tree and cancel moving a + * processor. + */ +export const PrivateTree: FunctionComponent = ({ + processors, + selector, + movingProcessor, + onAction, + level, + windowScrollerRef, + listRef, +}) => { + const renderRow = ({ + idx, + info, + processor, + }: { + idx: number; + info: ProcessorInfo; + processor: ProcessorInternal; + }) => { + return ( + <> + {idx === 0 ? ( + { + event.preventDefault(); + onAction({ + type: 'move', + payload: { + destination: selector.concat(DropSpecialLocations.top), + source: movingProcessor!.selector, + }, + }); + }} + isDisabled={Boolean( + !movingProcessor || isDropZoneAboveDisabled(info, movingProcessor!) + )} + /> + ) : undefined} + + + + { + event.preventDefault(); + onAction({ + type: 'move', + payload: { + destination: selector.concat(String(idx + 1)), + source: movingProcessor!.selector, + }, + }); + }} + /> + + ); + }; + + useEffect(() => { + if (windowScrollerRef && windowScrollerRef.current) { + windowScrollerRef.current.updatePosition(); + } + if (listRef && listRef.current) { + listRef.current.recomputeRowHeights(); + } + }, [processors, listRef, windowScrollerRef, movingProcessor]); + + // A list optimized to handle very many items. + const renderVirtualList = () => { + return ( + + {({ height, registerChild, isScrolling, onChildScroll, scrollTop }: any) => { + return ( + + + {({ width }) => { + return ( +
+ { + const processor = processors[index]; + return calculateItemHeight({ + processor, + isFirstInArray: index === 0, + }); + }} + rowRenderer={({ index: idx, style }) => { + const processor = processors[idx]; + const above = processors[idx - 1]; + const below = processors[idx + 1]; + const info: ProcessorInfo = { + id: processor.id, + selector: selector.concat(String(idx)), + aboveId: above?.id, + belowId: below?.id, + }; + + return ( +
+ {renderRow({ processor, info, idx })} +
+ ); + }} + processors={processors} + /> +
+ ); + }} +
+
+ ); + }} +
+ ); + }; + + if (level === 1) { + // Only render the optimised list for the top level list because that is the list + // that will almost certainly be the tallest + return renderVirtualList(); + } + + return ( + + {processors.map((processor, idx) => { + const above = processors[idx - 1]; + const below = processors[idx + 1]; + const info: ProcessorInfo = { + id: processor.id, + selector: selector.concat(String(idx)), + aboveId: above?.id, + belowId: below?.id, + }; + + return
{renderRow({ processor, idx, info })}
; + })} +
+ ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/tree_node.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/tree_node.tsx new file mode 100644 index 0000000000000..ebe4ca4962b4c --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/tree_node.tsx @@ -0,0 +1,115 @@ +/* + * 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 classNames from 'classnames'; +import { i18n } from '@kbn/i18n'; +import { EuiPanel, EuiText } from '@elastic/eui'; + +import { ProcessorInternal } from '../../../types'; + +import { ProcessorInfo, OnActionHandler } from '../processors_tree'; + +import { PipelineProcessorsEditorItem, Handlers } from '../../pipeline_processors_editor_item'; +import { AddProcessorButton } from '../../add_processor_button'; + +import { PrivateTree } from './private_tree'; + +export interface Props { + processor: ProcessorInternal; + processorInfo: ProcessorInfo; + onAction: OnActionHandler; + level: number; + movingProcessor?: ProcessorInfo; +} + +const INDENTATION_PX = 34; + +export const TreeNode: FunctionComponent = ({ + processor, + processorInfo, + onAction, + movingProcessor, + level, +}) => { + const stringSelector = processorInfo.selector.join('.'); + const handlers = useMemo((): Handlers => { + return { + onMove: () => { + onAction({ type: 'selectToMove', payload: { info: processorInfo } }); + }, + onCancelMove: () => { + onAction({ type: 'cancelMove' }); + }, + }; + }, [onAction, stringSelector, processor]); // eslint-disable-line react-hooks/exhaustive-deps + + const selected = movingProcessor?.id === processor.id; + + const panelClasses = classNames({ + 'pipelineProcessorsEditor__tree__item--selected': selected, + }); + + const renderOnFailureHandlersTree = () => { + if (!processor.onFailure?.length) { + return; + } + + const onFailureHandlerLabelClasses = classNames({ + 'pipelineProcessorsEditor__tree__onFailureHandlerLabel--withDropZone': + movingProcessor != null && + movingProcessor.id !== processor.onFailure[0].id && + movingProcessor.id !== processor.id, + }); + + return ( +
+
+ + {i18n.translate('xpack.ingestPipelines.pipelineEditor.onFailureProcessorsLabel', { + defaultMessage: 'Failure handlers', + })} + +
+ + + onAction({ + type: 'addProcessor', + payload: { target: processorInfo.selector.concat('onFailure') }, + }) + } + /> +
+ ); + }; + + return ( + + + {renderOnFailureHandlersTree()} + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/index.ts new file mode 100644 index 0000000000000..5a09794fd4bee --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/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 { ProcessorsTree, OnActionHandler, ProcessorInfo } from './processors_tree'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss new file mode 100644 index 0000000000000..ad9058cea5e18 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss @@ -0,0 +1,74 @@ +@import '@elastic/eui/src/global_styling/variables/size'; + +.pipelineProcessorsEditor__tree { + + &__container { + background-color: $euiColorLightestShade; + padding: $euiSizeS; + } + + &__dropZoneContainer { + margin: 2px; + visibility: hidden; + border: 2px dashed $euiColorLightShade; + height: 12px; + border-radius: 2px; + + transition: border .5s; + + &--active { + &:hover { + border: 2px dashed $euiColorPrimary; + } + visibility: visible; + } + } + + &__dropZoneButton { + height: 8px; + opacity: 0; + text-decoration: none !important; + + &--active { + &:hover { + transform: none !important; + } + } + + &:disabled { + cursor: default !important; + & > * { + cursor: default !important; + } + } + } + + &__onFailureHandlerLabelContainer { + position: relative; + height: 14px; + } + &__onFailureHandlerLabel { + position: absolute; + bottom: -16px; + &--withDropZone { + bottom: -4px; + } + } + + + &__onFailureHandlerContainer { + margin-top: $euiSizeS; + margin-bottom: $euiSizeS; + & > * { + overflow: visible; + } + } + + &__item { + transition: border-color 1s; + min-height: 50px; + &--selected { + border: 1px solid $euiColorPrimary; + } + } +} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx new file mode 100644 index 0000000000000..d0661913515b2 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { FunctionComponent, memo, useRef, useEffect } from 'react'; +import { EuiFlexGroup, EuiFlexItem, keyCodes } from '@elastic/eui'; +import { List, WindowScroller } from 'react-virtualized'; + +import { ProcessorInternal, ProcessorSelector } from '../../types'; + +import './processors_tree.scss'; +import { AddProcessorButton } from '../add_processor_button'; +import { PrivateTree } from './components'; + +export interface ProcessorInfo { + id: string; + selector: ProcessorSelector; + aboveId?: string; + belowId?: string; +} + +export type Action = + | { type: 'move'; payload: { source: ProcessorSelector; destination: ProcessorSelector } } + | { type: 'selectToMove'; payload: { info: ProcessorInfo } } + | { type: 'cancelMove' } + | { type: 'addProcessor'; payload: { target: ProcessorSelector } }; + +export type OnActionHandler = (action: Action) => void; + +export interface Props { + processors: ProcessorInternal[]; + baseSelector: ProcessorSelector; + onAction: OnActionHandler; + movingProcessor?: ProcessorInfo; + 'data-test-subj'?: string; +} + +/** + * This component is the public interface to our optimised tree rendering private components and + * also contains top-level state concerns for an instance of the component + */ +export const ProcessorsTree: FunctionComponent = memo((props) => { + const { processors, baseSelector, onAction, movingProcessor } = props; + // These refs are created here so they can be shared with all + // recursively rendered trees. Their values should come from react-virtualized + // List component and WindowScroller component. + const windowScrollerRef = useRef(null); + const listRef = useRef(null); + + useEffect(() => { + const cancelMoveKbListener = (event: KeyboardEvent) => { + // x-browser support per https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode + if (event.keyCode === keyCodes.ESCAPE || event.code === 'Escape') { + onAction({ type: 'cancelMove' }); + } + }; + const cancelMoveClickListener = (ev: any) => { + onAction({ type: 'cancelMove' }); + }; + // Give the browser a chance to flush any click events including the click + // event that triggered any state transition into selecting a processor to move + setTimeout(() => { + if (movingProcessor) { + window.addEventListener('keyup', cancelMoveKbListener); + window.addEventListener('click', cancelMoveClickListener); + } else { + window.removeEventListener('keyup', cancelMoveKbListener); + window.removeEventListener('click', cancelMoveClickListener); + } + }); + return () => { + window.removeEventListener('keyup', cancelMoveKbListener); + window.removeEventListener('click', cancelMoveClickListener); + }; + }, [movingProcessor, onAction]); + + return ( + + + + + + + + { + onAction({ type: 'addProcessor', payload: { target: baseSelector } }); + }} + /> + + + + + ); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/utils.ts new file mode 100644 index 0000000000000..457e335602b9b --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/utils.ts @@ -0,0 +1,41 @@ +/* + * 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 { ProcessorInternal } from '../../types'; + +// These values are tied to the style and heights following components: +// Do not change these numbers without testing the component for visual +// regressions! +// - ./components/tree_node.tsx +// - ./components/drop_zone_button.tsx +// - ./components/pipeline_processors_editor_item.tsx +const itemHeightsPx = { + WITHOUT_NESTED_ITEMS: 67, + WITH_NESTED_ITEMS: 137, + TOP_PADDING: 16, +}; + +export const calculateItemHeight = ({ + processor, + isFirstInArray, +}: { + processor: ProcessorInternal; + isFirstInArray: boolean; +}): number => { + const padding = isFirstInArray ? itemHeightsPx.TOP_PADDING : 0; + + if (!processor.onFailure?.length) { + return padding + itemHeightsPx.WITHOUT_NESTED_ITEMS; + } + + return ( + padding + + itemHeightsPx.WITH_NESTED_ITEMS + + processor.onFailure.reduce((acc, p, idx) => { + return acc + calculateItemHeight({ processor: p, isFirstInArray: idx === 0 }); + }, 0) + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/settings_form_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/settings_form_flyout.tsx new file mode 100644 index 0000000000000..94d5f0eda6454 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/settings_form_flyout.tsx @@ -0,0 +1,67 @@ +/* + * 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 { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; + +import React, { FunctionComponent, memo, useEffect } from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; + +import { OnFormUpdateArg } from '../../../../shared_imports'; + +import { ProcessorInternal } from '../types'; + +import { ProcessorSettingsForm, ProcessorSettingsFromOnSubmitArg } from '.'; + +export type OnSubmitHandler = (processor: ProcessorSettingsFromOnSubmitArg) => void; + +export interface Props { + processor: ProcessorInternal | undefined; + onFormUpdate: (form: OnFormUpdateArg) => void; + onSubmit: OnSubmitHandler; + isOnFailureProcessor: boolean; + onOpen: () => void; + onClose: () => void; +} + +export const SettingsFormFlyout: FunctionComponent = memo( + ({ onClose, processor, onSubmit, onFormUpdate, onOpen, isOnFailureProcessor }) => { + useEffect( + () => { + onOpen(); + }, + [] /* eslint-disable-line react-hooks/exhaustive-deps */ + ); + const flyoutTitleContent = isOnFailureProcessor ? ( + + ) : ( + + ); + + return ( + + + +

{flyoutTitleContent}

+
+
+ + + +
+ ); + } +); 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 new file mode 100644 index 0000000000000..46e3d1c803fd5 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/constants.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export enum DropSpecialLocations { + top = 'TOP', + bottom = 'BOTTOM', +} 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 new file mode 100644 index 0000000000000..150a52f1a5fe0 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { createContext, Dispatch, FunctionComponent, useContext, useState } from 'react'; +import { EditorMode } from './types'; +import { ProcessorsDispatch } from './processors_reducer'; + +interface Links { + learnMoreAboutProcessorsUrl: string; + learnMoreAboutOnFailureProcessorsUrl: string; +} + +const PipelineProcessorsContext = createContext<{ + links: Links; + state: { + processorsDispatch: ProcessorsDispatch; + editor: { + mode: EditorMode; + setMode: Dispatch; + }; + }; +}>({} as any); + +interface Props { + links: Links; + processorsDispatch: ProcessorsDispatch; +} + +export const PipelineProcessorsContextProvider: FunctionComponent = ({ + links, + children, + processorsDispatch, +}) => { + const [mode, setMode] = useState({ id: 'idle' }); + return ( + + {children} + + ); +}; + +export const usePipelineProcessorsContext = () => { + const ctx = useContext(PipelineProcessorsContext); + if (!ctx) { + throw new Error( + 'usePipelineProcessorsContext can only be used inside of PipelineProcessorsContextProvider' + ); + } + return ctx; +}; 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 new file mode 100644 index 0000000000000..fa1d041bdaba3 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.ts @@ -0,0 +1,56 @@ +/* + * 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 uuid from 'uuid'; +import { Processor } from '../../../../common/types'; +import { ProcessorInternal } from './types'; + +export interface DeserializeArgs { + processors: Processor[]; + onFailure?: Processor[]; +} + +export interface DeserializeResult { + processors: ProcessorInternal[]; + onFailure?: ProcessorInternal[]; +} + +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 convertToPipelineInternalProcessor = (processor: Processor): ProcessorInternal => { + const type = getProcessorType(processor); + const { on_failure: originalOnFailure, ...options } = processor[type]; + const onFailure = originalOnFailure?.length + ? convertProcessors(originalOnFailure) + : (originalOnFailure as ProcessorInternal[] | undefined); + return { + id: uuid.v4(), + type, + onFailure, + options, + }; +}; + +const convertProcessors = (processors: Processor[]) => { + const convertedProcessors = []; + + for (const processor of processors) { + convertedProcessors.push(convertToPipelineInternalProcessor(processor)); + } + return convertedProcessors; +}; + +export const deserialize = ({ processors, onFailure }: DeserializeArgs): DeserializeResult => { + return { + processors: convertProcessors(processors), + onFailure: onFailure ? convertProcessors(onFailure) : undefined, + }; +}; 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 new file mode 100644 index 0000000000000..58d6e492b85e5 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { PipelineProcessorsEditor, OnUpdateHandler } from './pipeline_processors_editor.container'; + +export { OnUpdateHandlerArg } from './types'; + +export { SerializeResult } from './serialize'; 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 new file mode 100644 index 0000000000000..057f8638700a4 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx @@ -0,0 +1,76 @@ +/* + * 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; + learnMoreAboutProcessorsUrl: string; + learnMoreAboutOnFailureProcessorsUrl: 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, + learnMoreAboutOnFailureProcessorsUrl, + learnMoreAboutProcessorsUrl, + 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.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss new file mode 100644 index 0000000000000..ee7421d7dbfa8 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss @@ -0,0 +1,3 @@ +.pipelineProcessorsEditor { + margin-bottom: $euiSize; +} 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 new file mode 100644 index 0000000000000..24b9598a74d47 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.tsx @@ -0,0 +1,239 @@ +/* + * 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, + SettingsFormFlyout, + ProcessorRemoveModal, + OnActionHandler, + OnSubmitHandler, +} from './components'; + +import { + ProcessorInternal, + ProcessorSelector, + OnUpdateHandlerArg, + FormValidityState, + OnFormUpdateArg, +} from './types'; + +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; +} + +const PROCESSOR_STATE_SCOPE: ProcessorSelector = ['processors']; +const ON_FAILURE_STATE_SCOPE: ProcessorSelector = ['onFailure']; + +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' ? ( + 1} + processor={editorMode.id === 'editingProcessor' ? editorMode.arg.processor : undefined} + onOpen={onFlyoutOpen} + onFormUpdate={onFormUpdate} + onSubmit={onSubmit} + onClose={onCloseSettingsForm} + /> + ) : 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 new file mode 100644 index 0000000000000..b43d94e19bf9f --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + State, + reducer, + useProcessorsState, + ProcessorsDispatch, + Action, +} from './processors_reducer'; + +export { isChildPath } from './utils'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.test.ts new file mode 100644 index 0000000000000..43072d65bac4e --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.test.ts @@ -0,0 +1,376 @@ +/* + * 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 { reducer, State } from './processors_reducer'; +import { DropSpecialLocations } from '../constants'; +import { PARENT_CHILD_NEST_ERROR } from './utils'; + +const initialState: State = { + processors: [], + onFailure: [], + isRoot: true, +}; + +describe('Processors reducer', () => { + it('reorders processors', () => { + const processor1 = { id: expect.any(String), type: 'test1', options: {} }; + const processor2 = { id: expect.any(String), type: 'test2', options: {} }; + const processor3 = { id: expect.any(String), type: 'test3', options: {} }; + + const s1 = reducer(initialState, { + type: 'addProcessor', + payload: { processor: processor1, targetSelector: ['processors'] }, + }); + const s2 = reducer(s1, { + type: 'addProcessor', + payload: { processor: processor2, targetSelector: ['processors'] }, + }); + const s3 = reducer(s2, { + type: 'addProcessor', + payload: { processor: processor3, targetSelector: ['processors'] }, + }); + + expect(s3.processors).toEqual([processor1, processor2, processor3]); + + // Move the second processor to the first + const s4 = reducer(s3, { + type: 'moveProcessor', + payload: { + source: ['processors', '1'], + destination: ['processors', '0'], + }, + }); + + expect(s4.processors).toEqual([processor2, processor1, processor3]); + }); + + it('moves and orders processors out of lists', () => { + const processor1 = { id: expect.any(String), type: 'test1', options: {} }; + const processor2 = { id: expect.any(String), type: 'test2', options: {} }; + const processor3 = { id: expect.any(String), type: 'test3', options: {} }; + const processor4 = { id: expect.any(String), type: 'test4', options: {} }; + + const s1 = reducer(initialState, { + type: 'addProcessor', + payload: { processor: processor1, targetSelector: ['processors'] }, + }); + const s2 = reducer(s1, { + type: 'addProcessor', + payload: { processor: processor2, targetSelector: ['processors'] }, + }); + + const s3 = reducer(s2, { + type: 'addProcessor', + payload: { processor: processor3, targetSelector: ['processors', '1'] }, + }); + + const s4 = reducer(s3, { + type: 'addProcessor', + payload: { + processor: processor4, + targetSelector: ['processors', '1', 'onFailure', '0'], + }, + }); + + expect(s4.processors).toEqual([ + processor1, + { ...processor2, onFailure: [{ ...processor3, onFailure: [processor4] }] }, + ]); + + // Move the first on failure processor of the second processors on failure processor + // to the second position of the root level. + const s5 = reducer(s4, { + type: 'moveProcessor', + payload: { + source: ['processors', '1', 'onFailure', '0'], + destination: ['processors', '1'], + }, + }); + + expect(s5.processors).toEqual([ + processor1, + { ...processor3, onFailure: [processor4] }, + { ...processor2, onFailure: undefined }, + ]); + }); + + it('moves and orders processors into lists', () => { + const processor1 = { id: expect.any(String), type: 'test1', options: {} }; + const processor2 = { id: expect.any(String), type: 'test2', options: {} }; + const processor3 = { id: expect.any(String), type: 'test3', options: {} }; + const processor4 = { id: expect.any(String), type: 'test4', options: {} }; + + const s1 = reducer(initialState, { + type: 'addProcessor', + payload: { processor: processor1, targetSelector: ['processors'] }, + }); + const s2 = reducer(s1, { + type: 'addProcessor', + payload: { processor: processor2, targetSelector: ['processors'] }, + }); + + const s3 = reducer(s2, { + type: 'addProcessor', + payload: { processor: processor3, targetSelector: ['processors', '1'] }, + }); + + const s4 = reducer(s3, { + type: 'addProcessor', + payload: { + processor: processor4, + targetSelector: ['processors', '1', 'onFailure', '0'], + }, + }); + + expect(s4.processors).toEqual([ + processor1, + { ...processor2, onFailure: [{ ...processor3, onFailure: [processor4] }] }, + ]); + + // Move the first processor to the deepest most on-failure processor's failure processor + const s5 = reducer(s4, { + type: 'moveProcessor', + payload: { + source: ['processors', '0'], + destination: ['processors', '1', 'onFailure', '0', 'onFailure', '0', 'onFailure', '0'], + }, + }); + + expect(s5.processors).toEqual([ + { + ...processor2, + onFailure: [{ ...processor3, onFailure: [{ ...processor4, onFailure: [processor1] }] }], + }, + ]); + }); + + it('handles sending processor to bottom correctly', () => { + const processor1 = { id: expect.any(String), type: 'test1', options: {} }; + const processor2 = { id: expect.any(String), type: 'test2', options: {} }; + const processor3 = { id: expect.any(String), type: 'test3', options: {} }; + + const s1 = reducer(initialState, { + type: 'addProcessor', + payload: { processor: processor1, targetSelector: ['processors'] }, + }); + + const s2 = reducer(s1, { + type: 'addProcessor', + payload: { processor: processor2, targetSelector: ['processors'] }, + }); + + const s3 = reducer(s2, { + type: 'addProcessor', + payload: { processor: processor3, targetSelector: ['processors'] }, + }); + + // Move the parent into a child list + const s4 = reducer(s3, { + type: 'moveProcessor', + payload: { + source: ['processors', '0'], + destination: ['processors', DropSpecialLocations.bottom], + }, + }); + + // Assert nothing changed + expect(s4.processors).toEqual([processor2, processor3, processor1]); + }); + + it('will not set the root "onFailure" to "undefined" if it is empty', () => { + const processor1 = { id: expect.any(String), type: 'test1', options: {} }; + const processor2 = { id: expect.any(String), type: 'test2', options: {} }; + + const s1 = reducer(initialState, { + type: 'addProcessor', + payload: { processor: processor1, targetSelector: ['processors'] }, + }); + + const s2 = reducer(s1, { + type: 'addProcessor', + payload: { processor: processor2, targetSelector: ['onFailure'] }, + }); + + // Move the parent into a child list + const s3 = reducer(s2, { + type: 'moveProcessor', + payload: { + source: ['onFailure', '0'], + destination: ['processors', '1'], + }, + }); + + expect(s3).toEqual({ + processors: [processor1, processor2], + onFailure: [], + isRoot: true, + }); + }); + + it('places copies and places the copied processor below the original', () => { + const processor1 = { id: expect.any(String), type: 'test1', options: {} }; + const processor2 = { id: expect.any(String), type: 'test2', options: {} }; + const processor3 = { id: expect.any(String), type: 'test3', options: {} }; + const processor4 = { + id: expect.any(String), + type: 'test4', + options: { field: 'field_name', value: 'field_value' }, + }; + + const s1 = reducer(initialState, { + type: 'addProcessor', + payload: { processor: processor1, targetSelector: ['processors'] }, + }); + const s2 = reducer(s1, { + type: 'addProcessor', + payload: { processor: processor2, targetSelector: ['processors'] }, + }); + + const s3 = reducer(s2, { + type: 'addProcessor', + payload: { processor: processor3, targetSelector: ['processors', '1'] }, + }); + + const s4 = reducer(s3, { + type: 'addProcessor', + payload: { + processor: processor4, + targetSelector: ['processors', '1', 'onFailure', '0'], + }, + }); + + const s5 = reducer(s4, { + type: 'duplicateProcessor', + payload: { source: ['processors', '1', 'onFailure', '0', 'onFailure', '0'] }, + }); + + const s6 = reducer(s5, { + type: 'duplicateProcessor', + payload: { source: ['processors', '1', 'onFailure', '0', 'onFailure', '0'] }, + }); + + expect(s6.processors).toEqual([ + processor1, + { + ...processor2, + onFailure: [ + { + ...processor3, + onFailure: [processor4, processor4, processor4], + }, + ], + }, + ]); + }); + + describe('Error conditions', () => { + let originalErrorLogger: any; + beforeEach(() => { + // eslint-disable-next-line no-console + originalErrorLogger = console.error; + // eslint-disable-next-line no-console + console.error = jest.fn(); + }); + + afterEach(() => { + // eslint-disable-next-line no-console + console.error = originalErrorLogger; + }); + + it('prevents moving a parent into child list', () => { + const processor1 = { id: expect.any(String), type: 'test1', options: {} }; + const processor2 = { id: expect.any(String), type: 'test2', options: {} }; + const processor3 = { id: expect.any(String), type: 'test3', options: {} }; + const processor4 = { id: expect.any(String), type: 'test4', options: {} }; + + const s1 = reducer(initialState, { + type: 'addProcessor', + payload: { processor: processor1, targetSelector: ['processors'] }, + }); + + const s2 = reducer(s1, { + type: 'addProcessor', + payload: { processor: processor2, targetSelector: ['processors'] }, + }); + + const s3 = reducer(s2, { + type: 'addProcessor', + payload: { processor: processor3, targetSelector: ['processors', '1'] }, + }); + + const s4 = reducer(s3, { + type: 'addProcessor', + payload: { + processor: processor4, + targetSelector: ['processors', '1', 'onFailure', '0'], + }, + }); + + expect(s4.processors).toEqual([ + processor1, + { ...processor2, onFailure: [{ ...processor3, onFailure: [processor4] }] }, + ]); + + // Move the parent into a child list + const s5 = reducer(s4, { + type: 'moveProcessor', + payload: { + source: ['processors', '1'], + destination: ['processors', '1', 'onFailure', '0', 'onFailure', '0', 'onFailure', '0'], + }, + }); + + // eslint-disable-next-line no-console + expect(console.error).toHaveBeenCalledWith(new Error(PARENT_CHILD_NEST_ERROR)); + + // Assert nothing changed + expect(s5.processors).toEqual(s4.processors); + }); + + it('does not remove top level processor and onFailure arrays if they are emptied', () => { + const processor1 = { id: expect.any(String), type: 'test1', options: {} }; + const s1 = reducer(initialState, { + type: 'addProcessor', + payload: { processor: processor1, targetSelector: ['processors'] }, + }); + const s2 = reducer(s1, { + type: 'removeProcessor', + payload: { selector: ['processors', '0'] }, + }); + expect(s2.processors).not.toBe(undefined); + }); + + it('throws for bad move processor', () => { + const processor1 = { id: expect.any(String), type: 'test1', options: {} }; + const processor2 = { id: expect.any(String), type: 'test2', options: {} }; + + const s1 = reducer(initialState, { + type: 'addProcessor', + payload: { processor: processor1, targetSelector: ['processors'] }, + }); + + const s2 = reducer(s1, { + type: 'addProcessor', + payload: { processor: processor2, targetSelector: ['onFailure'] }, + }); + + const s3 = reducer(s2, { + type: 'moveProcessor', + payload: { + source: ['onFailure'], + destination: ['processors'], + }, + }); + + // eslint-disable-next-line no-console + expect(console.error).toHaveBeenCalledWith( + new Error('Expected number but received "processors"') + ); + + expect(s3.processors).toEqual(s2.processors); + }); + }); +}); 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 new file mode 100644 index 0000000000000..4e069aab8bdd1 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import uuid from 'uuid'; +import { Reducer, useReducer, Dispatch } from 'react'; +import { DeserializeResult } from '../deserialize'; +import { getValue, setValue } from '../utils'; +import { ProcessorInternal, ProcessorSelector } from '../types'; + +import { unsafeProcessorMove, duplicateProcessor } from './utils'; + +export type State = Omit & { + onFailure: ProcessorInternal[]; + isRoot: true; +}; + +export type Action = + | { + type: 'addProcessor'; + payload: { processor: Omit; targetSelector: ProcessorSelector }; + } + | { + type: 'updateProcessor'; + payload: { processor: ProcessorInternal; selector: ProcessorSelector }; + } + | { + type: 'removeProcessor'; + payload: { selector: ProcessorSelector }; + } + | { + type: 'moveProcessor'; + payload: { source: ProcessorSelector; destination: ProcessorSelector }; + } + | { + type: 'duplicateProcessor'; + payload: { + source: ProcessorSelector; + }; + }; + +export type ProcessorsDispatch = Dispatch; + +export const reducer: Reducer = (state, action) => { + if (action.type === 'moveProcessor') { + const { destination, source } = action.payload; + try { + return unsafeProcessorMove(state, source, destination); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + return { ...state }; + } + } + + if (action.type === 'removeProcessor') { + const { selector } = action.payload; + const processorsSelector = selector.slice(0, -1); + const parentProcessorSelector = processorsSelector.slice(0, -1); + const idx = parseInt(selector[selector.length - 1], 10); + const processors = getValue(processorsSelector, state); + processors.splice(idx, 1); + const parentProcessor = getValue(parentProcessorSelector, state); + if (!processors.length && selector.length && !(parentProcessor as State).isRoot) { + return setValue(processorsSelector, state, undefined); + } + return setValue(processorsSelector, state, [...processors]); + } + + if (action.type === 'addProcessor') { + const { processor, targetSelector } = action.payload; + if (!targetSelector.length) { + throw new Error('Expected target selector to contain a path, but received an empty array.'); + } + const targetProcessor = getValue( + targetSelector, + state + ); + if (!targetProcessor) { + throw new Error( + `Could not find processor or processors array at ${targetSelector.join('.')}` + ); + } + if (Array.isArray(targetProcessor)) { + return setValue( + targetSelector, + state, + targetProcessor.concat({ ...processor, id: uuid.v4() }) + ); + } else { + const processorWithId = { ...processor, id: uuid.v4() }; + targetProcessor.onFailure = targetProcessor.onFailure + ? targetProcessor.onFailure.concat(processorWithId) + : [processorWithId]; + return setValue(targetSelector, state, targetProcessor); + } + } + + if (action.type === 'updateProcessor') { + const { processor, selector } = action.payload; + const processorsSelector = selector.slice(0, -1); + const idx = parseInt(selector[selector.length - 1], 10); + + if (isNaN(idx)) { + throw new Error(`Expected numeric value, received ${idx}`); + } + + const processors = getValue(processorsSelector, state); + processors[idx] = processor; + return setValue(processorsSelector, state, [...processors]); + } + + if (action.type === 'duplicateProcessor') { + const sourceSelector = action.payload.source; + const sourceProcessor = getValue(sourceSelector, state); + const sourceIdx = parseInt(sourceSelector[sourceSelector.length - 1], 10); + const sourceProcessorsArraySelector = sourceSelector.slice(0, -1); + const sourceProcessorsArray = [ + ...getValue(sourceProcessorsArraySelector, state), + ]; + const copy = duplicateProcessor(sourceProcessor); + sourceProcessorsArray.splice(sourceIdx + 1, 0, copy); + return setValue(sourceProcessorsArraySelector, state, sourceProcessorsArray); + } + + return state; +}; + +export const useProcessorsState = (initialState: DeserializeResult) => { + const state = { + ...initialState, + onFailure: initialState.onFailure ?? [], + }; + return useReducer(reducer, { ...state, isRoot: true }); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/utils.ts new file mode 100644 index 0000000000000..7cb7d076623aa --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/utils.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import uuid from 'uuid'; +import { State } from './processors_reducer'; +import { ProcessorInternal, ProcessorSelector } from '../types'; +import { DropSpecialLocations } from '../constants'; +import { checkIfSamePath, getValue } from '../utils'; + +export const PARENT_CHILD_NEST_ERROR = 'PARENT_CHILD_NEST_ERROR'; + +export const duplicateProcessor = (sourceProcessor: ProcessorInternal): ProcessorInternal => { + const onFailure = sourceProcessor.onFailure + ? sourceProcessor.onFailure.map((p) => duplicateProcessor(p)) + : undefined; + return { + ...sourceProcessor, + onFailure, + id: uuid.v4(), + options: { + ...sourceProcessor.options, + }, + }; +}; + +export const isChildPath = (a: ProcessorSelector, b: ProcessorSelector) => { + return a.every((pathSegment, idx) => pathSegment === b[idx]); +}; + +/** + * Unsafe! + * + * This function takes a data structure and mutates it in place. + * + * It is convenient for updating the processors (see {@link ProcessorInternal}) + * structure in this way because the structure is recursive. We are moving processors between + * different arrays, removing in one, and adding to another. The end result should be consistent + * with these actions. + * + * @remark + * This function assumes parents cannot be moved into themselves. + */ +export const unsafeProcessorMove = ( + state: State, + source: ProcessorSelector, + destination: ProcessorSelector +): State => { + const pathToSourceArray = source.slice(0, -1); + const pathToDestArray = destination.slice(0, -1); + if (isChildPath(source, destination)) { + throw new Error(PARENT_CHILD_NEST_ERROR); + } + const isXArrayMove = !checkIfSamePath(pathToSourceArray, pathToDestArray); + + // Start by setting up references to objects of interest using our selectors + // At this point, our selectors are consistent with the data passed in. + const sourceProcessors = getValue(pathToSourceArray, state); + const destinationProcessors = getValue(pathToDestArray, state); + const sourceIndex = parseInt(source[source.length - 1], 10); + const sourceProcessor = getValue(pathToSourceArray.slice(0, -1), state); + const processor = sourceProcessors[sourceIndex]; + + const lastDestItem = destination[destination.length - 1]; + let destIndex: number; + if (lastDestItem === DropSpecialLocations.top) { + destIndex = 0; + } else if (lastDestItem === DropSpecialLocations.bottom) { + destIndex = Infinity; + } else if (/^-?[0-9]+$/.test(lastDestItem)) { + destIndex = parseInt(lastDestItem, 10); + } else { + throw new Error(`Expected number but received "${lastDestItem}"`); + } + + if (isXArrayMove) { + // First perform the add operation. + if (destinationProcessors) { + destinationProcessors.splice(destIndex, 0, processor); + } else { + const targetProcessor = getValue(pathToDestArray.slice(0, -1), state); + targetProcessor.onFailure = [processor]; + } + // !! Beyond this point, selectors are no longer usable because we have mutated the data structure! + // Second, we perform the deletion operation + sourceProcessors.splice(sourceIndex, 1); + + // If onFailure is empty, delete the array. + if (!sourceProcessors.length && !((sourceProcessor as unknown) as State).isRoot) { + delete sourceProcessor.onFailure; + } + } else { + destinationProcessors.splice(destIndex, 0, processor); + const targetIdx = sourceIndex > destIndex ? sourceIndex + 1 : sourceIndex; + sourceProcessors.splice(targetIdx, 1); + } + + return { ...state, processors: [...state.processors], onFailure: [...state.onFailure] }; +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/serialize.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/serialize.ts new file mode 100644 index 0000000000000..153c9e252ccc0 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/serialize.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Processor } from '../../../../common/types'; + +import { DeserializeResult } from './deserialize'; +import { ProcessorInternal } from './types'; + +type SerializeArgs = DeserializeResult; + +export interface SerializeResult { + processors: Processor[]; + on_failure?: Processor[]; +} + +const convertProcessorInternalToProcessor = (processor: ProcessorInternal): Processor => { + const { options, onFailure, type } = processor; + const outProcessor = { + [type]: { + ...options, + }, + }; + + if (onFailure?.length) { + outProcessor[type].on_failure = convertProcessors(onFailure); + } else if (onFailure) { + outProcessor[type].on_failure = []; + } + + return outProcessor; +}; + +const convertProcessors = (processors: ProcessorInternal[]) => { + const convertedProcessors = []; + + for (const processor of processors) { + convertedProcessors.push(convertProcessorInternalToProcessor(processor)); + } + return convertedProcessors; +}; + +export const serialize = ({ processors, onFailure }: SerializeArgs): SerializeResult => { + return { + processors: convertProcessors(processors), + on_failure: onFailure?.length ? convertProcessors(onFailure) : undefined, + }; +}; 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 new file mode 100644 index 0000000000000..aa39fca29fa8b --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/types.ts @@ -0,0 +1,51 @@ +/* + * 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 { OnFormUpdateArg } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; +import { SerializeResult } from './serialize'; +import { ProcessorInfo } from './components/processors_tree'; + +/** + * An array of keys that map to a value in an object + * structure. + * + * For instance: + * ['a', 'b', '0', 'c'] given { a: { b: [ { c: [] } ] } } => [] + * + * Additionally, an empty selector `[]`, is a special indicator + * for the root level. + */ +export type ProcessorSelector = string[]; + +/** @private */ +export interface ProcessorInternal { + id: string; + type: string; + options: { [key: string]: any }; + onFailure?: ProcessorInternal[]; +} + +export { OnFormUpdateArg }; + +export interface FormValidityState { + validate: OnFormUpdateArg['validate']; +} + +export interface OnUpdateHandlerArg extends FormValidityState { + getData: () => SerializeResult; +} + +/** + * The editor can be in different modes. This enables us to hold + * a reference to data dispatch to the reducer (like the {@link ProcessorSelector} + * which will be used to update the in-memory processors data structure. + */ +export type EditorMode = + | { id: 'creatingProcessor'; arg: { selector: ProcessorSelector } } + | { id: 'movingProcessor'; arg: ProcessorInfo } + | { id: 'editingProcessor'; arg: { processor: ProcessorInternal; selector: ProcessorSelector } } + | { id: 'removingProcessor'; arg: { selector: ProcessorSelector } } + | { id: 'idle' }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.test.ts new file mode 100644 index 0000000000000..0b7620f517161 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { getValue, setValue } from './utils'; + +describe('get and set values', () => { + const testObject = Object.freeze([{ onFailure: [{ onFailure: 1 }] }]); + describe('#getValue', () => { + it('gets a deeply nested value', () => { + expect(getValue(['0', 'onFailure', '0', 'onFailure'], testObject)).toBe(1); + }); + + it('empty array for path returns "root" value', () => { + const result = getValue([], testObject); + expect(result).toEqual(testObject); + // Getting does not create a copy + expect(result).toBe(testObject); + }); + }); + + describe('#setValue', () => { + it('sets a deeply nested value', () => { + const result = setValue(['0', 'onFailure', '0', 'onFailure'], testObject, 2); + expect(result).toEqual([{ onFailure: [{ onFailure: 2 }] }]); + expect(result).not.toBe(testObject); + }); + + it('returns value if no path was provided', () => { + setValue([], testObject, 2); + expect(testObject).toEqual([{ onFailure: [{ onFailure: 1 }] }]); + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts new file mode 100644 index 0000000000000..49d24e8dc35c3 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts @@ -0,0 +1,101 @@ +/* + * 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 { ProcessorSelector } from './types'; + +type Path = string[]; + +/** + * The below get and set functions are built with an API to make setting + * and getting and setting values more simple. + * + * @remark + * NEVER use these with objects that contain keys created by user input. + */ + +/** + * Given a path, get the value at the path + * + * @remark + * If path is an empty array, return the source. + */ +export const getValue = (path: Path, source: any) => { + let current = source; + for (const key of path) { + current = (current as any)[key]; + } + return (current as unknown) as Result; +}; + +const ARRAY_TYPE = Object.prototype.toString.call([]); +const OBJECT_TYPE = Object.prototype.toString.call({}); + +const dumbCopy = (value: R): R => { + const objectType = Object.prototype.toString.call(value); + if (objectType === ARRAY_TYPE) { + return ([...(value as any)] as unknown) as R; + } else if (objectType === OBJECT_TYPE) { + return { ...(value as any) } as R; + } + + throw new Error(`Expected (${ARRAY_TYPE}|${OBJECT_TYPE}) but received ${objectType}`); +}; + +const WHITELISTED_KEYS_REGEX = /^([0-9]+|onFailure|processors)$/; +/** + * Given a path, value and an object (array or object) set + * the value at the path and copy objects values on the + * path only. This is a partial copy mechanism that is best + * effort for providing state updates to the UI, could break down + * if other updates are made to non-copied parts of state in external + * references - but this should not happen. + * + * @remark + * If path is empty, just shallow copy source. + */ +export const setValue = ( + path: Path, + source: Target, + value: Value +): Target => { + if (!path.length) { + return dumbCopy(source); + } + + let current: any; + let result: Target; + + for (let idx = 0; idx < path.length; ++idx) { + const key = path[idx]; + if (!WHITELISTED_KEYS_REGEX.test(key)) { + // eslint-disable-next-line no-console + console.error( + `Received non-whitelisted key "${key}". Aborting set value operation; returning original.` + ); + return dumbCopy(source); + } + const atRoot = !current; + + if (atRoot) { + result = dumbCopy(source); + current = result; + } + + if (idx + 1 === path.length) { + current[key] = value; + } else { + current[key] = dumbCopy(current[key]); + current = current[key]; + } + } + + return result!; +}; + +export const checkIfSamePath = (pathA: ProcessorSelector, pathB: ProcessorSelector) => { + if (pathA.length !== pathB.length) return false; + return pathA.join('.') === pathB.join('.'); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts index ab56ae427120b..9ddb953c71978 100644 --- a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts +++ b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts @@ -29,7 +29,13 @@ export { Form, getUseField, ValidationFuncArg, + FormData, + UseField, + FormHook, useFormContext, + FormDataProvider, + OnFormUpdateArg, + FieldConfig, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; export { @@ -41,6 +47,9 @@ export { getFormRow, Field, JsonEditorField, + FormRow, + ToggleField, + ComboBoxField, } from '../../../../src/plugins/es_ui_shared/static/forms/components'; export { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9f2a040314c69..b597779926ade 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8527,28 +8527,18 @@ "xpack.ingestPipelines.form.nameDescription": "このパイプラインの固有の識別子です。", "xpack.ingestPipelines.form.nameFieldLabel": "名前", "xpack.ingestPipelines.form.nameTitle": "名前", - "xpack.ingestPipelines.form.onFailureDescription": "プロセッサーが失敗した後に実行する代替プロセッサー。{learnMoreLink}", - "xpack.ingestPipelines.form.onFailureDocumentionLink": "詳細", - "xpack.ingestPipelines.form.onFailureFieldAriaLabel": "障害プロセッサーJSONエディター", "xpack.ingestPipelines.form.onFailureFieldHelpText": "JSONフォーマットを使用:{code}", "xpack.ingestPipelines.form.onFailureFieldLabel": "障害プロセッサー(任意)", "xpack.ingestPipelines.form.onFailureProcessorsJsonError": "入力が無効です。", - "xpack.ingestPipelines.form.onFailureTitle": "障害プロセッサー", - "xpack.ingestPipelines.form.onFailureToggleDescription": "障害プロセッサーを追加", "xpack.ingestPipelines.form.pipelineNameRequiredError": "名前が必要です。", - "xpack.ingestPipelines.form.processorsDocumentionLink": "詳細", - "xpack.ingestPipelines.form.processorsFieldAriaLabel": "プロセッサーJSONエディター", - "xpack.ingestPipelines.form.processorsFieldDescription": "インデックスの前にドキュメントを変換するために使用するプロセッサー。{learnMoreLink}", "xpack.ingestPipelines.form.processorsFieldHelpText": "JSONフォーマットを使用:{code}", "xpack.ingestPipelines.form.processorsFieldLabel": "プロセッサー", - "xpack.ingestPipelines.form.processorsFieldTitle": "プロセッサー", "xpack.ingestPipelines.form.processorsJsonError": "入力が無効です。", "xpack.ingestPipelines.form.processorsRequiredError": "プロセッサーが必要です。", "xpack.ingestPipelines.form.saveButtonLabel": "パイプラインを保存", "xpack.ingestPipelines.form.savePipelineError": "パイプラインを作成できません", "xpack.ingestPipelines.form.savingButtonLabel": "保存中…", "xpack.ingestPipelines.form.showRequestButtonLabel": "リクエストを表示", - "xpack.ingestPipelines.form.testPipelineButtonLabel": "パイプラインをテスト", "xpack.ingestPipelines.form.versionFieldLabel": "バージョン(任意)", "xpack.ingestPipelines.form.versionToggleDescription": "バージョン番号を追加", "xpack.ingestPipelines.licenseCheckErrorMessage": "ライセンス確認失敗", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 1e0a67541474b..6382caeaaec03 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8531,28 +8531,18 @@ "xpack.ingestPipelines.form.nameDescription": "此管道的唯一标识符。", "xpack.ingestPipelines.form.nameFieldLabel": "名称", "xpack.ingestPipelines.form.nameTitle": "名称", - "xpack.ingestPipelines.form.onFailureDescription": "处理器失败后要执行的备用处理器。{learnMoreLink}", - "xpack.ingestPipelines.form.onFailureDocumentionLink": "了解详情", - "xpack.ingestPipelines.form.onFailureFieldAriaLabel": "失败处理器 JSON 编辑器", "xpack.ingestPipelines.form.onFailureFieldHelpText": "使用 JSON 格式:{code}", "xpack.ingestPipelines.form.onFailureFieldLabel": "失败处理器(可选)", "xpack.ingestPipelines.form.onFailureProcessorsJsonError": "输入无效。", - "xpack.ingestPipelines.form.onFailureTitle": "失败处理器", - "xpack.ingestPipelines.form.onFailureToggleDescription": "添加失败处理器", "xpack.ingestPipelines.form.pipelineNameRequiredError": "“名称”必填。", - "xpack.ingestPipelines.form.processorsDocumentionLink": "了解详情", - "xpack.ingestPipelines.form.processorsFieldAriaLabel": "处理器 JSON 编辑器", - "xpack.ingestPipelines.form.processorsFieldDescription": "用于在索引之前转换文档的处理器。{learnMoreLink}", "xpack.ingestPipelines.form.processorsFieldHelpText": "使用 JSON 格式:{code}", "xpack.ingestPipelines.form.processorsFieldLabel": "处理器", - "xpack.ingestPipelines.form.processorsFieldTitle": "处理器", "xpack.ingestPipelines.form.processorsJsonError": "输入无效。", "xpack.ingestPipelines.form.processorsRequiredError": "需要指定处理器。", "xpack.ingestPipelines.form.saveButtonLabel": "保存管道", "xpack.ingestPipelines.form.savePipelineError": "无法创建管道", "xpack.ingestPipelines.form.savingButtonLabel": "正在保存......", "xpack.ingestPipelines.form.showRequestButtonLabel": "显示请求", - "xpack.ingestPipelines.form.testPipelineButtonLabel": "测试管道", "xpack.ingestPipelines.form.versionFieldLabel": "版本(可选)", "xpack.ingestPipelines.form.versionToggleDescription": "添加版本号", "xpack.ingestPipelines.licenseCheckErrorMessage": "许可证检查失败", diff --git a/x-pack/test/functional/apps/ingest_pipelines/ingest_pipelines.ts b/x-pack/test/functional/apps/ingest_pipelines/ingest_pipelines.ts index e926bcd6ef997..353902f4265a0 100644 --- a/x-pack/test/functional/apps/ingest_pipelines/ingest_pipelines.ts +++ b/x-pack/test/functional/apps/ingest_pipelines/ingest_pipelines.ts @@ -11,22 +11,6 @@ const PIPELINE = { name: 'test_pipeline', description: 'My pipeline description.', version: 1, - processors: JSON.stringify([ - { - set: { - field: 'foo', - value: 'new', - }, - }, - ]), - onFailureProcessors: JSON.stringify([ - { - set: { - field: '_index', - value: 'failed-{{ _index }}', - }, - }, - ]), }; export default ({ getPageObjects, getService }: FtrProviderContext) => {