From f224eb5dea29487929d17beb80ed2b969bf373c3 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Mon, 23 Oct 2023 14:56:10 -0700 Subject: [PATCH] Persist form state and validation in Workspace (#61) (#62) Signed-off-by: Tyler Ohlsen (cherry picked from commit 10810e3f10a74da2aa711763ad202e53ba79725e) Co-authored-by: Tyler Ohlsen --- common/interfaces.ts | 2 +- package.json | 4 +- public/component_types/indices/knn_index.ts | 3 + public/component_types/interfaces.ts | 22 +- .../processors/text_embedding_processor.ts | 3 + .../component_details/component_details.tsx | 42 ++++ .../component_details/component_inputs.tsx | 25 +++ .../empty_component_inputs.tsx | 25 +++ .../component_details/index.ts | 6 + .../input_field_list.tsx | 26 ++- .../input_fields/index.ts | 0 .../input_fields/json_field.tsx | 1 + .../input_fields/select_field.tsx | 74 +++++++ .../input_fields/text_field.tsx | 55 +++++ .../pages/workflow_detail/workflow_detail.tsx | 15 +- .../workspace/component_details.tsx | 99 --------- .../workspace/reactflow-styles.scss | 11 + .../workspace/resizable_workspace.tsx | 189 ++++++++++++++---- .../workspace/workspace-styles.scss | 6 - .../workflow_detail/workspace/workspace.tsx | 21 +- .../input_fields/select_field.tsx | 46 ----- .../input_fields/text_field.tsx | 25 --- .../workspace_edge/deletable_edge.tsx | 5 + .../context/react_flow_context_provider.tsx | 1 - public/utils/utils.ts | 103 +++++++++- yarn.lock | 85 ++++++++ 26 files changed, 650 insertions(+), 244 deletions(-) create mode 100644 public/pages/workflow_detail/component_details/component_details.tsx create mode 100644 public/pages/workflow_detail/component_details/component_inputs.tsx create mode 100644 public/pages/workflow_detail/component_details/empty_component_inputs.tsx create mode 100644 public/pages/workflow_detail/component_details/index.ts rename public/pages/workflow_detail/{workspace_component => component_details}/input_field_list.tsx (75%) rename public/pages/workflow_detail/{workspace_component => component_details}/input_fields/index.ts (100%) rename public/pages/workflow_detail/{workspace_component => component_details}/input_fields/json_field.tsx (94%) create mode 100644 public/pages/workflow_detail/component_details/input_fields/select_field.tsx create mode 100644 public/pages/workflow_detail/component_details/input_fields/text_field.tsx delete mode 100644 public/pages/workflow_detail/workspace/component_details.tsx delete mode 100644 public/pages/workflow_detail/workspace_component/input_fields/select_field.tsx delete mode 100644 public/pages/workflow_detail/workspace_component/input_fields/text_field.tsx diff --git a/common/interfaces.ts b/common/interfaces.ts index 783c8ae7..fd93cfbd 100644 --- a/common/interfaces.ts +++ b/common/interfaces.ts @@ -4,7 +4,7 @@ */ import { Node, Edge } from 'reactflow'; -import { IComponent as IComponentData } from '../public/component_types'; +import { IComponentData } from '../public/component_types'; export type Index = { name: string; diff --git a/package.json b/package.json index 979c1ad1..8db8d06b 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,9 @@ ] }, "dependencies": { - "reactflow": "^11.8.3" + "formik": "2.4.2", + "reactflow": "^11.8.3", + "yup": "^1.3.2" }, "devDependencies": { "pre-commit": "^1.2.2" diff --git a/public/component_types/indices/knn_index.ts b/public/component_types/indices/knn_index.ts index b66e17c0..e56e6dd2 100644 --- a/public/component_types/indices/knn_index.ts +++ b/public/component_types/indices/knn_index.ts @@ -55,6 +55,7 @@ export class KnnIndex extends BaseComponent implements IComponent { this.fields = [ { label: 'Index Name', + name: 'indexName', type: 'select', optional: false, advanced: false, @@ -63,6 +64,7 @@ export class KnnIndex extends BaseComponent implements IComponent { this.createFields = [ { label: 'Index Name', + name: 'indexName', type: 'string', optional: false, advanced: false, @@ -73,6 +75,7 @@ export class KnnIndex extends BaseComponent implements IComponent { // simple form inputs vs. complex JSON editor { label: 'Mappings', + name: 'indexMappings', type: 'json', placeholder: 'Enter an index mappings JSON blob...', optional: false, diff --git a/public/component_types/interfaces.ts b/public/component_types/interfaces.ts index 4fa63b80..6aea8cd9 100644 --- a/public/component_types/interfaces.ts +++ b/public/component_types/interfaces.ts @@ -3,17 +3,26 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { FormikValues } from 'formik'; +import { ObjectSchema } from 'yup'; import { COMPONENT_CATEGORY, COMPONENT_CLASS } from '../utils'; /** - * ************ Types ************************** + * ************ Types ************************* */ export type UIFlow = string; export type FieldType = 'string' | 'json' | 'select'; - -/** - * ************ Base interfaces **************** - */ +// TODO: this may expand to more types in the future. Formik supports 'any' so we can too. +// For now, limiting scope to expected types. +export type FieldValue = string | {}; +export type ComponentFormValues = FormikValues; +export type WorkspaceFormValues = { + [componentId: string]: ComponentFormValues; +}; +export type WorkspaceSchemaObj = { + [componentId: string]: ObjectSchema; +}; +export type WorkspaceSchema = ObjectSchema; /** * Represents a single base class as an input handle for a component. @@ -35,6 +44,8 @@ export interface IComponentInput { export interface IComponentField { label: string; type: FieldType; + name: string; + value?: FieldValue; placeholder?: string; optional?: boolean; advanced?: boolean; @@ -84,4 +95,5 @@ export interface IComponent { */ export interface IComponentData extends IComponent { id: string; + selected?: boolean; } diff --git a/public/component_types/processors/text_embedding_processor.ts b/public/component_types/processors/text_embedding_processor.ts index c6f3961a..4f5b16c5 100644 --- a/public/component_types/processors/text_embedding_processor.ts +++ b/public/component_types/processors/text_embedding_processor.ts @@ -46,18 +46,21 @@ export class TextEmbeddingProcessor this.fields = [ { label: 'Model ID', + name: 'modelId', type: 'string', optional: false, advanced: false, }, { label: 'Input Field', + name: 'inputField', type: 'string', optional: false, advanced: false, }, { label: 'Output Field', + name: 'outputField', type: 'string', optional: false, advanced: false, diff --git a/public/pages/workflow_detail/component_details/component_details.tsx b/public/pages/workflow_detail/component_details/component_details.tsx new file mode 100644 index 00000000..d106092f --- /dev/null +++ b/public/pages/workflow_detail/component_details/component_details.tsx @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import { ReactFlowComponent } from '../../../../common'; +import { ComponentInputs } from './component_inputs'; +import { EmptyComponentInputs } from './empty_component_inputs'; + +// styling +import '../workspace/workspace-styles.scss'; + +interface ComponentDetailsProps { + selectedComponent?: ReactFlowComponent; +} + +/** + * A panel that will be nested in a resizable container to dynamically show + * the details and user-required inputs based on the selected component + * in the flow workspace. + */ +export function ComponentDetails(props: ComponentDetailsProps) { + return ( + + + + {props.selectedComponent ? ( + + ) : ( + + )} + + + + ); +} diff --git a/public/pages/workflow_detail/component_details/component_inputs.tsx b/public/pages/workflow_detail/component_details/component_inputs.tsx new file mode 100644 index 00000000..bed3ad72 --- /dev/null +++ b/public/pages/workflow_detail/component_details/component_inputs.tsx @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; +import { InputFieldList } from './input_field_list'; +import { ReactFlowComponent } from '../../../../common'; + +interface ComponentInputsProps { + selectedComponent: ReactFlowComponent; +} + +export function ComponentInputs(props: ComponentInputsProps) { + return ( + <> + +

{props.selectedComponent.data.label || ''}

+
+ + + + ); +} diff --git a/public/pages/workflow_detail/component_details/empty_component_inputs.tsx b/public/pages/workflow_detail/component_details/empty_component_inputs.tsx new file mode 100644 index 00000000..18a19880 --- /dev/null +++ b/public/pages/workflow_detail/component_details/empty_component_inputs.tsx @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; + +export function EmptyComponentInputs() { + return ( + No component selected} + titleSize="s" + body={ + <> + + Add a component, or select a component to view or edit its + configuration. + + + } + /> + ); +} diff --git a/public/pages/workflow_detail/component_details/index.ts b/public/pages/workflow_detail/component_details/index.ts new file mode 100644 index 00000000..d53dcc60 --- /dev/null +++ b/public/pages/workflow_detail/component_details/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './component_details'; diff --git a/public/pages/workflow_detail/workspace_component/input_field_list.tsx b/public/pages/workflow_detail/component_details/input_field_list.tsx similarity index 75% rename from public/pages/workflow_detail/workspace_component/input_field_list.tsx rename to public/pages/workflow_detail/component_details/input_field_list.tsx index e37979e6..ced08c14 100644 --- a/public/pages/workflow_detail/workspace_component/input_field_list.tsx +++ b/public/pages/workflow_detail/component_details/input_field_list.tsx @@ -5,8 +5,8 @@ import React from 'react'; import { EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { IComponentField } from '../../../component_types'; import { TextField, JsonField, SelectField } from './input_fields'; +import { ReactFlowComponent } from '../../../../common'; /** * A helper component to format all of the input fields for a component. Dynamically @@ -14,42 +14,46 @@ import { TextField, JsonField, SelectField } from './input_fields'; */ interface InputFieldListProps { - inputFields?: IComponentField[]; + selectedComponent: ReactFlowComponent; } export function InputFieldList(props: InputFieldListProps) { + const inputFields = props.selectedComponent.data.fields || []; return ( - {props.inputFields?.map((field, idx) => { + {inputFields.map((field, idx) => { let el; switch (field.type) { case 'string': { el = ( ); break; } - case 'json': { + case 'select': { el = ( - ); break; } - case 'select': { + case 'json': { el = ( - + ); break; diff --git a/public/pages/workflow_detail/workspace_component/input_fields/index.ts b/public/pages/workflow_detail/component_details/input_fields/index.ts similarity index 100% rename from public/pages/workflow_detail/workspace_component/input_fields/index.ts rename to public/pages/workflow_detail/component_details/input_fields/index.ts diff --git a/public/pages/workflow_detail/workspace_component/input_fields/json_field.tsx b/public/pages/workflow_detail/component_details/input_fields/json_field.tsx similarity index 94% rename from public/pages/workflow_detail/workspace_component/input_fields/json_field.tsx rename to public/pages/workflow_detail/component_details/input_fields/json_field.tsx index eafa0cd3..73177bc0 100644 --- a/public/pages/workflow_detail/workspace_component/input_fields/json_field.tsx +++ b/public/pages/workflow_detail/component_details/input_fields/json_field.tsx @@ -15,6 +15,7 @@ interface JsonFieldProps { * An input field for a component where users manually enter * in some custom JSON */ +// TODO: integrate with formik export function JsonField(props: JsonFieldProps) { return ( <> diff --git a/public/pages/workflow_detail/component_details/input_fields/select_field.tsx b/public/pages/workflow_detail/component_details/input_fields/select_field.tsx new file mode 100644 index 00000000..95d83772 --- /dev/null +++ b/public/pages/workflow_detail/component_details/input_fields/select_field.tsx @@ -0,0 +1,74 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiFormRow, + EuiSuperSelect, + EuiSuperSelectOption, + EuiText, +} from '@elastic/eui'; +import { Field, FieldProps, useFormikContext } from 'formik'; +import { + IComponentField, + WorkspaceFormValues, + getInitialValue, + isFieldInvalid, +} from '../../../../../common'; + +// TODO: Should be fetched from global state. +// Need to have a way to determine where to fetch this dynamic data. +const existingIndices = [ + { + value: 'index-1', + inputDisplay: my-index-1, + disabled: false, + }, + { + value: 'index-2', + inputDisplay: my-index-2, + disabled: false, + }, +] as Array>; + +interface SelectFieldProps { + field: IComponentField; + componentId: string; +} + +/** + * An input field for a component where users select from a list of available + * options. + */ +export function SelectField(props: SelectFieldProps) { + const options = existingIndices; + const formField = `${props.componentId}.${props.field.name}`; + const { errors, touched } = useFormikContext(); + + return ( + + {({ field, form }: FieldProps) => { + return ( + + { + field.onChange(option); + form.setFieldValue(formField, option); + }} + isInvalid={isFieldInvalid( + props.componentId, + props.field.name, + errors, + touched + )} + /> + + ); + }} + + ); +} diff --git a/public/pages/workflow_detail/component_details/input_fields/text_field.tsx b/public/pages/workflow_detail/component_details/input_fields/text_field.tsx new file mode 100644 index 00000000..fec071ac --- /dev/null +++ b/public/pages/workflow_detail/component_details/input_fields/text_field.tsx @@ -0,0 +1,55 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Field, FieldProps, useFormikContext } from 'formik'; +import { EuiFieldText, EuiFormRow } from '@elastic/eui'; +import { + IComponentField, + WorkspaceFormValues, + getFieldError, + getInitialValue, + isFieldInvalid, +} from '../../../../../common'; + +interface TextFieldProps { + field: IComponentField; + componentId: string; +} + +/** + * An input field for a component where users input plaintext + */ +export function TextField(props: TextFieldProps) { + const formField = `${props.componentId}.${props.field.name}`; + const { errors, touched } = useFormikContext(); + + return ( + + {({ field, form }: FieldProps) => { + return ( + + + + ); + }} + + ); +} diff --git a/public/pages/workflow_detail/workflow_detail.tsx b/public/pages/workflow_detail/workflow_detail.tsx index f8ecc1f3..2d33881e 100644 --- a/public/pages/workflow_detail/workflow_detail.tsx +++ b/public/pages/workflow_detail/workflow_detail.tsx @@ -6,6 +6,7 @@ import React, { useEffect } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { useSelector } from 'react-redux'; +import { ReactFlowProvider } from 'reactflow'; import { EuiPage, EuiPageBody } from '@elastic/eui'; import { BREADCRUMBS } from '../../utils'; import { getCore } from '../../services'; @@ -42,11 +43,13 @@ export function WorkflowDetail(props: WorkflowDetailProps) { }); return ( - - - - - - + + + + + + + + ); } diff --git a/public/pages/workflow_detail/workspace/component_details.tsx b/public/pages/workflow_detail/workspace/component_details.tsx deleted file mode 100644 index 3499a3a9..00000000 --- a/public/pages/workflow_detail/workspace/component_details.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useState, useContext } from 'react'; -import { useOnSelectionChange } from 'reactflow'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiPanel, - EuiTitle, - EuiEmptyPrompt, - EuiText, -} from '@elastic/eui'; -import { ReactFlowComponent } from '../../../../common'; -import { rfContext } from '../../../store'; -import { InputFieldList } from '../workspace_component/input_field_list'; - -// styling -import './workspace-styles.scss'; - -interface ComponentDetailsProps { - onToggleChange: () => void; - isOpen: boolean; -} - -/** - * A panel that will be nested in a resizable container to dynamically show - * the details and user-required inputs based on the selected component - * in the flow workspace. - */ -export function ComponentDetails(props: ComponentDetailsProps) { - // TODO: use this instance to update the internal node state. ex: update field data in the selected node based - // on user input - const { reactFlowInstance } = useContext(rfContext); - - const [selectedComponent, setSelectedComponent] = useState< - ReactFlowComponent - >(); - - /** - * Hook provided by reactflow to listen on when nodes are selected / de-selected. - * - populate panel content appropriately - * - open the panel if a node is selected and the panel is closed - * - it is assumed that only one node can be selected at once - */ - useOnSelectionChange({ - onChange: ({ nodes, edges }) => { - if (nodes && nodes.length > 0) { - setSelectedComponent(nodes[0]); - if (!props.isOpen) { - props.onToggleChange(); - } - } else { - setSelectedComponent(undefined); - } - }, - }); - - return ( - - - - {selectedComponent ? ( - <> - -

{selectedComponent?.data.label || ''}

-
- - - - ) : ( - No component selected} - titleSize="s" - body={ - <> - - Add a component, or select a component to view or edit its - configuration. - - - } - /> - )} -
-
-
- ); -} diff --git a/public/pages/workflow_detail/workspace/reactflow-styles.scss b/public/pages/workflow_detail/workspace/reactflow-styles.scss index 5ce54672..1bd9b1aa 100644 --- a/public/pages/workflow_detail/workspace/reactflow-styles.scss +++ b/public/pages/workflow_detail/workspace/reactflow-styles.scss @@ -21,6 +21,17 @@ $handle-color-invalid: $euiColorDanger; width: 300px; } +// Overriding the styling for the reactflow node when it is selected. +// We need to use important tag to override ReactFlow's wrapNode that sets the box-shadow. +// Ref: https://github.com/wbkd/react-flow/blob/main/packages/core/src/components/Nodes/wrapNode.tsx#L187 +.reactflow-workspace .react-flow__node-customComponent.selected { + box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.5); + border-radius: 5px; + &:focus { + box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.5) !important; + } +} + .reactflow-workspace .react-flow__handle { height: 10px; width: 10px; diff --git a/public/pages/workflow_detail/workspace/resizable_workspace.tsx b/public/pages/workflow_detail/workspace/resizable_workspace.tsx index e092660d..36d2f225 100644 --- a/public/pages/workflow_detail/workspace/resizable_workspace.tsx +++ b/public/pages/workflow_detail/workspace/resizable_workspace.tsx @@ -3,12 +3,24 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useRef, useState } from 'react'; -import { ReactFlowProvider } from 'reactflow'; -import { EuiResizableContainer } from '@elastic/eui'; -import { Workflow } from '../../../../common'; +import React, { useRef, useState, useEffect, useContext } from 'react'; +import { useOnSelectionChange } from 'reactflow'; +import { Form, Formik } from 'formik'; +import * as yup from 'yup'; +import { cloneDeep } from 'lodash'; +import { EuiButton, EuiResizableContainer } from '@elastic/eui'; +import { + Workflow, + WorkspaceFormValues, + WorkspaceSchema, + ReactFlowComponent, + WorkspaceSchemaObj, + componentDataToFormik, + getComponentSchema, +} from '../../../../common'; +import { rfContext } from '../../../store'; import { Workspace } from './workspace'; -import { ComponentDetails } from './component_details'; +import { ComponentDetails } from '../component_details'; interface ResizableWorkspaceProps { workflow?: Workflow; @@ -21,48 +33,149 @@ const COMPONENT_DETAILS_PANEL_ID = 'component_details_panel_id'; * panels - the ReactFlow workspace panel and the selected component details panel. */ export function ResizableWorkspace(props: ResizableWorkspaceProps) { - const [isOpen, setIsOpen] = useState(true); + // Component details side panel state + const [isDetailsPanelOpen, setisDetailsPanelOpen] = useState(true); const collapseFn = useRef( (id: string, options: { direction: 'left' | 'right' }) => {} ); - const onToggleChange = () => { collapseFn.current(COMPONENT_DETAILS_PANEL_ID, { direction: 'left' }); - setIsOpen(!isOpen); + setisDetailsPanelOpen(!isDetailsPanelOpen); }; + // Selected component state + const { reactFlowInstance } = useContext(rfContext); + const [selectedComponent, setSelectedComponent] = useState< + ReactFlowComponent + >(); + + /** + * Hook provided by reactflow to listen on when nodes are selected / de-selected. + * - populate panel content appropriately + * - open the panel if a node is selected and the panel is closed + * - it is assumed that only one node can be selected at once + */ + useOnSelectionChange({ + onChange: ({ nodes, edges }) => { + if (nodes && nodes.length > 0) { + setSelectedComponent(nodes[0]); + if (!isDetailsPanelOpen) { + onToggleChange(); + } + } else { + setSelectedComponent(undefined); + } + }, + }); + + useEffect(() => { + reactFlowInstance?.setNodes((nodes: ReactFlowComponent[]) => + nodes.map((node) => { + node.data = { + ...node.data, + selected: node.id === selectedComponent?.id ? true : false, + }; + return node; + }) + ); + }, [selectedComponent]); + + // Formik form state + const [formValues, setFormValues] = useState({}); + const [formSchema, setFormSchema] = useState(yup.object({})); + + // Initialize the form state to an existing workflow, if applicable. + useEffect(() => { + if (props.workflow?.workspaceFlowState) { + const initFormValues = {} as WorkspaceFormValues; + const initSchemaObj = {} as WorkspaceSchemaObj; + props.workflow.workspaceFlowState.nodes.forEach((node) => { + initFormValues[node.id] = componentDataToFormik(node.data); + initSchemaObj[node.id] = getComponentSchema(node.data); + }); + const initFormSchema = yup.object(initSchemaObj) as WorkspaceSchema; + setFormValues(initFormValues); + setFormSchema(initFormSchema); + } + }, [props.workflow]); + + // Update the form values and validation schema when a node is added + // or removed from the workspace. + // For the schema, we do a deep clone of the underlying object, and later re-create the schema. + // For the form values, we update directly to prevent the form from being reinitialized. + function onNodesChange(nodes: ReactFlowComponent[]): void { + const updatedComponentIds = nodes.map((node) => node.id); + const existingComponentIds = Object.keys(formValues); + const updatedSchemaObj = cloneDeep(formSchema.fields) as WorkspaceSchemaObj; + + if (updatedComponentIds.length > existingComponentIds.length) { + // TODO: implement for when a node is added + } else if (updatedComponentIds.length < existingComponentIds.length) { + existingComponentIds.forEach((existingId) => { + if (!updatedComponentIds.includes(existingId)) { + // Remove the mapping for the removed component in the form values + // and schema. + delete formValues[`${existingId}`]; + delete updatedSchemaObj[`${existingId}`]; + } + }); + } else { + // if it is somehow triggered without node changes, be sure + // to prevent updating the form or schema + return; + } + + const updatedSchema = yup.object(updatedSchemaObj) as WorkspaceSchema; + setFormSchema(updatedSchema); + } + return ( - {}} + validate={(values) => {}} > - {(EuiResizablePanel, EuiResizableButton, { togglePanel }) => { - if (togglePanel) { - collapseFn.current = (panelId: string, { direction }) => - togglePanel(panelId, { direction }); - } + {(formikProps) => ( +
+ + {(EuiResizablePanel, EuiResizableButton, { togglePanel }) => { + if (togglePanel) { + collapseFn.current = (panelId: string, { direction }) => + togglePanel(panelId, { direction }); + } - return ( - - - - - - onToggleChange()} - > - - - - ); - }} - + return ( + <> + + + + + onToggleChange()} + > + + + + ); + }} + + formikProps.handleSubmit()}> + Submit + + + )} + ); } diff --git a/public/pages/workflow_detail/workspace/workspace-styles.scss b/public/pages/workflow_detail/workspace/workspace-styles.scss index 375c5107..57c09e10 100644 --- a/public/pages/workflow_detail/workspace/workspace-styles.scss +++ b/public/pages/workflow_detail/workspace/workspace-styles.scss @@ -4,9 +4,3 @@ height: 60vh; padding: 0; } - -.resizable-panel-border { - border-style: groove; - border-color: gray; - border-width: 1px; -} diff --git a/public/pages/workflow_detail/workspace/workspace.tsx b/public/pages/workflow_detail/workspace/workspace.tsx index a3d7b5b6..c53f23e2 100644 --- a/public/pages/workflow_detail/workspace/workspace.tsx +++ b/public/pages/workflow_detail/workspace/workspace.tsx @@ -12,10 +12,16 @@ import ReactFlow, { useEdgesState, addEdge, BackgroundVariant, + useStore, } from 'reactflow'; import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { rfContext, setDirty } from '../../../store'; -import { IComponent, Workflow } from '../../../../common'; +import { + IComponent, + IComponentData, + ReactFlowComponent, + Workflow, +} from '../../../../common'; import { generateId, initComponentData } from '../../../utils'; import { getCore } from '../../../services'; import { WorkspaceComponent } from '../workspace_component'; @@ -29,6 +35,7 @@ import '../workspace_edge/deletable-edge-styles.scss'; interface WorkspaceProps { workflow?: Workflow; + onNodesChange: (nodes: ReactFlowComponent[]) => void; } const nodeTypes = { customComponent: WorkspaceComponent }; @@ -39,9 +46,17 @@ export function Workspace(props: WorkspaceProps) { const reactFlowWrapper = useRef(null); const { reactFlowInstance, setReactFlowInstance } = useContext(rfContext); - const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); + // Listener for node additions or deletions to propagate to parent component + const nodesLength = useStore( + (state) => Array.from(state.nodeInternals.values()).length || 0 + ); + useEffect(() => { + props.onNodesChange(nodes); + }, [nodesLength]); + const onConnect = useCallback( (params) => { const edge = { @@ -124,7 +139,7 @@ export function Workspace(props: WorkspaceProps) { justifyContent="spaceBetween" className="workspace-panel" > - + {/** * We have these wrapper divs & reactFlowWrapper ref to control and calculate the * ReactFlow bounds when calculating node positioning. diff --git a/public/pages/workflow_detail/workspace_component/input_fields/select_field.tsx b/public/pages/workflow_detail/workspace_component/input_fields/select_field.tsx deleted file mode 100644 index eaec1c0f..00000000 --- a/public/pages/workflow_detail/workspace_component/input_fields/select_field.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useState } from 'react'; -import { EuiSuperSelect, EuiSuperSelectOption, EuiText } from '@elastic/eui'; - -// TODO: Should be fetched from global state. -// Need to have a way to determine where to fetch this dynamic data. -const existingIndices = [ - { - value: 'index-1', - inputDisplay: my-index-1, - disabled: false, - }, - { - value: 'index-2', - inputDisplay: my-index-2, - disabled: false, - }, -] as Array>; - -/** - * An input field for a component where users select from a list of available - * options. - */ -export function SelectField() { - const options = existingIndices; - - const [selectedOption, setSelectedOption] = useState( - options[0].value - ); - - const onChange = (option: string) => { - setSelectedOption(option); - }; - - return ( - onChange(option)} - /> - ); -} diff --git a/public/pages/workflow_detail/workspace_component/input_fields/text_field.tsx b/public/pages/workflow_detail/workspace_component/input_fields/text_field.tsx deleted file mode 100644 index b22635db..00000000 --- a/public/pages/workflow_detail/workspace_component/input_fields/text_field.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { EuiFieldText } from '@elastic/eui'; - -interface TextFieldProps { - label: string; - placeholder: string; -} - -/** - * An input field for a component where users input plaintext - */ -export function TextField(props: TextFieldProps) { - return ( - - ); -} diff --git a/public/pages/workflow_detail/workspace_edge/deletable_edge.tsx b/public/pages/workflow_detail/workspace_edge/deletable_edge.tsx index 0b46f47f..692afddd 100644 --- a/public/pages/workflow_detail/workspace_edge/deletable_edge.tsx +++ b/public/pages/workflow_detail/workspace_edge/deletable_edge.tsx @@ -35,6 +35,8 @@ export function DeletableEdge(props: DeletableEdgeProps) { const { deleteEdge } = useContext(rfContext); const onEdgeClick = (event: any, edgeId: string) => { + // Prevent this event from bubbling up and putting reactflow into an unexpected state. + // This implementation follows the doc example: https://reactflow.dev/docs/examples/edges/custom-edge/ event.stopPropagation(); deleteEdge(edgeId); }; @@ -54,6 +56,9 @@ export function DeletableEdge(props: DeletableEdgeProps) { className="nodrag nopan" >