From 5660f24a4f36bbf12b837cf5624c3246d8a1479a Mon Sep 17 00:00:00 2001 From: Tyler Ohlsen Date: Mon, 2 Oct 2023 17:09:12 -0700 Subject: [PATCH] Add input & output handlers and some validation Signed-off-by: Tyler Ohlsen --- public/component_types/indices/knn_index.ts | 10 +++- .../workspace/reactflow-styles.scss | 6 +- .../workspace_component/input_handle.tsx | 48 +++++++++++++++ .../workspace_component/output_handle.tsx | 49 ++++++++++++++++ .../workspace_component/utils.ts | 58 +++++++++++++++++++ .../workspace_component.tsx | 51 +++++++--------- public/store/reducers/workflows_reducer.ts | 27 +++------ 7 files changed, 197 insertions(+), 52 deletions(-) create mode 100644 public/pages/workflow_detail/workspace_component/input_handle.tsx create mode 100644 public/pages/workflow_detail/workspace_component/output_handle.tsx create mode 100644 public/pages/workflow_detail/workspace_component/utils.ts diff --git a/public/component_types/indices/knn_index.ts b/public/component_types/indices/knn_index.ts index acf81a66..3b390e5a 100644 --- a/public/component_types/indices/knn_index.ts +++ b/public/component_types/indices/knn_index.ts @@ -44,7 +44,15 @@ export class KnnIndex implements IComponent { // that will be referenced/used as input across multiple flows this.allowedFlows = ['Ingest', 'Query', 'Other']; this.baseClasses = [this.type]; - this.inputs = []; + this.inputs = [ + { + id: 'text-embedding-processor', + label: 'Text embedding processor', + baseClass: 'text_embedding_processor', + optional: false, + acceptMultiple: false, + }, + ]; this.fields = [ { label: 'Index Name', diff --git a/public/pages/workflow_detail/workspace/reactflow-styles.scss b/public/pages/workflow_detail/workspace/reactflow-styles.scss index 1f5479b4..9c027fd5 100644 --- a/public/pages/workflow_detail/workspace/reactflow-styles.scss +++ b/public/pages/workflow_detail/workspace/reactflow-styles.scss @@ -10,7 +10,11 @@ } .workspace { - width: 50vh; + width: 80vh; height: 50vh; padding: 0; } + +.workspace-component { + width: 300px; +} diff --git a/public/pages/workflow_detail/workspace_component/input_handle.tsx b/public/pages/workflow_detail/workspace_component/input_handle.tsx new file mode 100644 index 00000000..6a8c4634 --- /dev/null +++ b/public/pages/workflow_detail/workspace_component/input_handle.tsx @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useRef, useEffect, useContext } from 'react'; +import { Connection, Handle, Position } from 'reactflow'; +import { EuiText } from '@elastic/eui'; +import { IComponent, IComponentInput } from '../../../component_types'; +import { calculateHandlePosition, isValidConnection } from './utils'; +import { rfContext } from '../../../store'; + +interface InputHandleProps { + data: IComponent; + input: IComponentInput; +} + +export function InputHandle(props: InputHandleProps) { + const ref = useRef(null); + const { reactFlowInstance } = useContext(rfContext); + const [position, setPosition] = useState(0); + + useEffect(() => { + setPosition(calculateHandlePosition(ref)); + }, [ref]); + + return ( +
+ <> + {props.input.label} + + isValidConnection(connection, reactFlowInstance) + } + style={{ + height: 10, + width: 10, + backgroundColor: 'black', + top: position, + }} + /> + +
+ ); +} diff --git a/public/pages/workflow_detail/workspace_component/output_handle.tsx b/public/pages/workflow_detail/workspace_component/output_handle.tsx new file mode 100644 index 00000000..38f89346 --- /dev/null +++ b/public/pages/workflow_detail/workspace_component/output_handle.tsx @@ -0,0 +1,49 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useRef, useEffect, useContext } from 'react'; +import { Connection, Handle, Position } from 'reactflow'; +import { EuiText } from '@elastic/eui'; +import { IComponent, IComponentOutput } from '../../../component_types'; +import { calculateHandlePosition, isValidConnection } from './utils'; +import { rfContext } from '../../../store'; + +interface OutputHandleProps { + data: IComponent; + output: IComponentOutput; +} + +export function OutputHandle(props: OutputHandleProps) { + const ref = useRef(null); + const { reactFlowInstance } = useContext(rfContext); + const [position, setPosition] = useState(0); + const outputClasses = props.output.baseClasses.join('|'); + + useEffect(() => { + setPosition(calculateHandlePosition(ref)); + }, [ref]); + + return ( +
+ <> + {props.output.label} + + isValidConnection(connection, reactFlowInstance) + } + style={{ + height: 10, + width: 10, + backgroundColor: 'black', + top: position, + }} + /> + +
+ ); +} diff --git a/public/pages/workflow_detail/workspace_component/utils.ts b/public/pages/workflow_detail/workspace_component/utils.ts new file mode 100644 index 00000000..4d9f471c --- /dev/null +++ b/public/pages/workflow_detail/workspace_component/utils.ts @@ -0,0 +1,58 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Connection, ReactFlowInstance } from 'reactflow'; +import { IComponentInput } from '../../../../common'; + +/** + * Collection of utility functions for the workspace component + */ + +// Uses DOM elements to calculate where the handle should be placed +// vertically on the ReactFlow component. offsetTop is the offset relative to the +// parent element, and clientHeight is the element height including padding. +// We can combine them to get the exact amount, in pixels. +export function calculateHandlePosition(ref: any): number { + if (ref.current && ref.current.offsetTop && ref.current.clientHeight) { + return ref.current.offsetTop + ref.current.clientHeight / 2; + } else { + return 0; + } +} + +export function isValidConnection( + connection: Connection, + rfInstance: ReactFlowInstance +): boolean { + const sourceHandle = connection.sourceHandle; + const targetHandle = connection.targetHandle; + const targetNodeId = connection.target; + + const inputClass = sourceHandle || ''; + // We store the output classes in a pipe-delimited string. Converting back to a list. + const outputClasses = targetHandle?.split('|') || []; + + if (outputClasses?.includes(inputClass)) { + const targetNode = rfInstance.getNode(targetNodeId || ''); + if (targetNode) { + // We pull out the relevant IComponentInput config, and check if it allows multiple connections. + // We also check the existing edges in the ReactFlow state. + // If there is an existing edge, and we don't allow multiple, we don't allow this connection. + // For all other scenarios, we allow the connection. + const inputConfig = targetNode.data.inputs.find( + (input: IComponentInput) => input.baseClass === inputClass + ); + const existingEdge = rfInstance + .getEdges() + .find((edge) => edge.targetHandle === targetHandle); + if (existingEdge && inputConfig.acceptMultiple === false) { + return false; + } + } + return true; + } else { + return false; + } +} diff --git a/public/pages/workflow_detail/workspace_component/workspace_component.tsx b/public/pages/workflow_detail/workspace_component/workspace_component.tsx index 7c8b8dcf..7e634b30 100644 --- a/public/pages/workflow_detail/workspace_component/workspace_component.tsx +++ b/public/pages/workflow_detail/workspace_component/workspace_component.tsx @@ -4,16 +4,12 @@ */ import React, { useState } from 'react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiSpacer, - EuiCard, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiCard } from '@elastic/eui'; import { IComponent } from '../../../component_types'; import { InputFieldList } from './input_field_list'; import { NewOrExistingTabs } from './new_or_existing_tabs'; +import { InputHandle } from './input_handle'; +import { OutputHandle } from './output_handle'; interface WorkspaceComponentProps { data: IComponent; @@ -35,38 +31,31 @@ export function WorkspaceComponent(props: WorkspaceComponentProps) { : component.fields; return ( - + - + {/* {component.allowsCreation ? ( ) : undefined} - + */} + {component.inputs?.map((input, index) => { + return ( + + + + ); + })} - {/** - * Hardcoding the interfaced inputs/outputs for readability - * TODO: remove when moving this into the context of a ReactFlow node with Handles. - */} - - <> - - Inputs: - - {component.inputs?.map((input, idx) => { - return {input.label}; - })} - - - Outputs: - - {component.outputs?.map((output, idx) => { - return {output.label}; - })} - - + {component.outputs?.map((output, index) => { + return ( + + + + ); + })} ); diff --git a/public/store/reducers/workflows_reducer.ts b/public/store/reducers/workflows_reducer.ts index 4da54a13..314928b9 100644 --- a/public/store/reducers/workflows_reducer.ts +++ b/public/store/reducers/workflows_reducer.ts @@ -20,6 +20,12 @@ const dummyNodes = [ data: new TextEmbeddingProcessor(), type: 'customComponent', }, + { + id: 'text-embedding-processor-2', + position: { x: 0, y: 200 }, + data: new TextEmbeddingProcessor(), + type: 'customComponent', + }, { id: 'knn-index', position: { x: 500, y: 500 }, @@ -28,23 +34,6 @@ const dummyNodes = [ }, ] as ReactFlowComponent[]; -const dummyEdges = [ - { - id: 'e1-2', - source: 'model', - target: 'ingest-pipeline', - style: { - strokeWidth: 2, - stroke: 'black', - }, - markerEnd: { - type: 'arrow', - strokeWidth: 1, - color: 'black', - }, - }, -] as ReactFlowEdge[]; - const initialState = { // TODO: fetch from server-side later workflows: [ @@ -54,7 +43,7 @@ const initialState = { description: 'description for workflow 1', reactFlowState: { nodes: dummyNodes, - edges: dummyEdges, + edges: [] as ReactFlowEdge[], }, template: {}, }, @@ -64,7 +53,7 @@ const initialState = { description: 'description for workflow 2', reactFlowState: { nodes: dummyNodes, - edges: dummyEdges, + edges: [] as ReactFlowEdge[], }, template: {}, },