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 (
+
+ );
+ },
+ (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 =