From 6a1749c1825a367ad04b7dde495f411a6da427fb Mon Sep 17 00:00:00 2001 From: Tyler Ohlsen Date: Fri, 29 Sep 2023 10:02:33 -0700 Subject: [PATCH] Add ReactFlow to Workflow Details page (#42) Signed-off-by: Tyler Ohlsen (cherry picked from commit 8980cb2107688a0d20237b815d86b1715af3cda8) --- common/constants.ts | 1 + common/helpers.ts | 74 ++++++++ common/index.ts | 1 + .../workspace/reactflow-styles.scss | 16 ++ .../workflow_detail/workspace/workspace.tsx | 165 ++++++++++++++++-- public/render_app.tsx | 10 +- public/store/context/index.ts | 6 + .../context/react_flow_context_provider.tsx | 51 ++++++ public/store/index.ts | 1 + public/store/reducers/workspace_reducer.ts | 12 +- 10 files changed, 315 insertions(+), 22 deletions(-) create mode 100644 common/helpers.ts create mode 100644 public/pages/workflow_detail/workspace/reactflow-styles.scss create mode 100644 public/store/context/index.ts create mode 100644 public/store/context/react_flow_context_provider.tsx diff --git a/common/constants.ts b/common/constants.ts index 2c6fcfed..5cdd656c 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -2,6 +2,7 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ + export const PLUGIN_ID = 'aiFlowDashboards'; export const BASE_NODE_API_PATH = '/api/ai_flow'; diff --git a/common/helpers.ts b/common/helpers.ts new file mode 100644 index 00000000..cb422fff --- /dev/null +++ b/common/helpers.ts @@ -0,0 +1,74 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Node, Edge } from 'reactflow'; +import { IComponent } from '../public/component_types'; + +/** + * TODO: remove hardcoded nodes/edges. + * + * Converts the stored IComponents into the low-level ReactFlow nodes and edges. + * This may change entirely, depending on how/where the ReactFlow JSON will be + * persisted. Using this stub helper fn in the meantime. + */ +export function convertToReactFlowData(components: IComponent[]) { + const dummyNodes = [ + { + id: 'semantic-search', + position: { x: 40, y: 10 }, + data: { label: 'Semantic Search' }, + type: 'group', + style: { + height: 110, + width: 700, + }, + }, + { + id: 'model', + position: { x: 25, y: 25 }, + data: { label: 'Deployed Model ID' }, + type: 'default', + parentNode: 'semantic-search', + extent: 'parent', + }, + { + id: 'ingest-pipeline', + position: { x: 262, y: 25 }, + data: { label: 'Ingest Pipeline Name' }, + type: 'default', + parentNode: 'semantic-search', + extent: 'parent', + }, + ] as Array< + Node< + { + label: string; + }, + string | undefined + > + >; + + const dummyEdges = [ + { + id: 'e1-2', + source: 'model', + target: 'ingest-pipeline', + style: { + strokeWidth: 2, + stroke: 'black', + }, + markerEnd: { + type: 'arrow', + strokeWidth: 1, + color: 'black', + }, + }, + ] as Array>; + + return { + rfNodes: dummyNodes, + rfEdges: dummyEdges, + }; +} diff --git a/common/index.ts b/common/index.ts index 47f29dc4..d3ac23ca 100644 --- a/common/index.ts +++ b/common/index.ts @@ -5,4 +5,5 @@ export * from './constants'; export * from './interfaces'; +export * from './helpers'; export { IComponent } from '../public/component_types'; diff --git a/public/pages/workflow_detail/workspace/reactflow-styles.scss b/public/pages/workflow_detail/workspace/reactflow-styles.scss new file mode 100644 index 00000000..1f5479b4 --- /dev/null +++ b/public/pages/workflow_detail/workspace/reactflow-styles.scss @@ -0,0 +1,16 @@ +.reactflow-parent-wrapper { + display: flex; + flex-grow: 1; + height: 100%; +} + +.reactflow-parent-wrapper .reactflow-wrapper { + flex-grow: 1; + height: 100%; +} + +.workspace { + width: 50vh; + height: 50vh; + padding: 0; +} diff --git a/public/pages/workflow_detail/workspace/workspace.tsx b/public/pages/workflow_detail/workspace/workspace.tsx index 96cb990d..9b883fda 100644 --- a/public/pages/workflow_detail/workspace/workspace.tsx +++ b/public/pages/workflow_detail/workspace/workspace.tsx @@ -3,24 +3,159 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useRef, useContext, useCallback, useEffect } from 'react'; +import ReactFlow, { + Controls, + Background, + useNodesState, + useEdgesState, + addEdge, +} from 'reactflow'; import { useSelector } from 'react-redux'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { AppState } from '../../../store'; -import { WorkspaceComponent } from '../workspace_component'; +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { AppState, rfContext } from '../../../store'; +import { convertToReactFlowData } from '../../../../common'; -export function Workspace() { - const { components } = useSelector((state: AppState) => state.workspace); +// styling +import 'reactflow/dist/style.css'; +import './reactflow-styles.scss'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface WorkspaceProps {} + +export function Workspace(props: WorkspaceProps) { + const reactFlowWrapper = useRef(null); + const { reactFlowInstance, setReactFlowInstance } = useContext(rfContext); + + // Fetching workspace state to populate the initial nodes/edges. + // Where/how the low-level ReactFlow JSON will be persisted is TBD. + // TODO: update when that design is finalized + const storedComponents = useSelector( + (state: AppState) => state.workspace.components + ); + const { rfNodes, rfEdges } = convertToReactFlowData(storedComponents); + const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges); + + const onConnect = useCallback( + (params) => { + setEdges((eds) => addEdge(params, eds)); + }, + // TODO: add customized logic to prevent connections based on the node's + // allowed inputs. If allowed, update that node state as well with the added + // connection details. + [setEdges] + ); + + const onDragOver = useCallback((event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }, []); + + const onDrop = useCallback( + (event) => { + event.preventDefault(); + // Get the node info from the event metadata + const nodeData = event.dataTransfer.getData('application/reactflow'); + + // check if the dropped element is valid + if (typeof nodeData === 'undefined' || !nodeData) { + return; + } + + // Fetch bounds based on the ref'd div component, adjust as needed. + // TODO: remove hardcoded bounds and fetch from a constant somewhere + // @ts-ignore + const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect(); + // @ts-ignore + const position = reactFlowInstance.project({ + x: event.clientX - reactFlowBounds.left - 80, + y: event.clientY - reactFlowBounds.top - 90, + }); + + // TODO: remove hardcoded values when more component info is passed in the event. + // Only keep the calculated 'positioning' field. + const newNode = { + // TODO: generate ID based on the node data maybe + id: Date.now().toFixed(), + type: nodeData.type, + position, + data: { label: nodeData.label }, + style: { + background: 'white', + }, + }; + + setNodes((nds) => nds.concat(newNode)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [reactFlowInstance] + ); + + // Initialization hook + useEffect(() => { + // TODO: fetch the nodes/edges dynamically (loading existing flow, + // creating fresh from template, creating blank template, etc.) + // Will involve populating and/or fetching from redux store + }, []); return ( - - {components.map((component, idx) => { - return ( - - - - ); - })} - + + + + {/** + * We have these wrapper divs & reactFlowWrapper ref to control and calculate the + * ReactFlow bounds when calculating node positioning. + */} +
+
+ + + + +
+
+
+
+
); } + +// TODO: remove later, leaving for reference + +// export function Workspace() { +// const { components } = useSelector((state: AppState) => state.workspace); + +// return ( +// +// {components.map((component, idx) => { +// return ( +// +// +// +// ); +// })} +// +// ); +// } diff --git a/public/render_app.tsx b/public/render_app.tsx index 4393262a..33ddca1f 100644 --- a/public/render_app.tsx +++ b/public/render_app.tsx @@ -9,7 +9,7 @@ import { BrowserRouter as Router, Route } from 'react-router-dom'; import { Provider } from 'react-redux'; import { AppMountParameters, CoreStart } from '../../../src/core/public'; import { AiFlowDashboardsApp } from './app'; -import { store } from './store'; +import { store, ReactFlowContextProvider } from './store'; export const renderApp = ( coreStart: CoreStart, @@ -17,9 +17,11 @@ export const renderApp = ( ) => { ReactDOM.render( - - } /> - + + + } /> + + , element ); diff --git a/public/store/context/index.ts b/public/store/context/index.ts new file mode 100644 index 00000000..1584425e --- /dev/null +++ b/public/store/context/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './react_flow_context_provider'; diff --git a/public/store/context/react_flow_context_provider.tsx b/public/store/context/react_flow_context_provider.tsx new file mode 100644 index 00000000..8434e358 --- /dev/null +++ b/public/store/context/react_flow_context_provider.tsx @@ -0,0 +1,51 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { createContext, useState } from 'react'; + +const initialValues = { + reactFlowInstance: null, + setReactFlowInstance: () => {}, + deleteNode: (nodeId: string) => {}, + deleteEdge: (edgeId: string) => {}, +}; + +export const rfContext = createContext(initialValues); + +/** + * This returns a provider from the rfContext context created above. The initial + * values are set so any nested components can use useContext to access these + * values. + * + * This is how we can manage ReactFlow context consistently across the various + * nested child components. + */ +export function ReactFlowContextProvider({ children }: any) { + const [reactFlowInstance, setReactFlowInstance] = useState(null); + + const deleteNode = (nodeId: string) => { + // TODO: implement node deletion + // reactFlowInstance.setNodes(...) + }; + + const deleteEdge = (edgeId: string) => { + // TODO: implement edge deletion + // reactFlowInstance.setEdges(...) + }; + + return ( + + {children} + + ); +} diff --git a/public/store/index.ts b/public/store/index.ts index ccc2465d..45aab826 100644 --- a/public/store/index.ts +++ b/public/store/index.ts @@ -5,3 +5,4 @@ export * from './store'; export * from './reducers'; +export * from './context'; diff --git a/public/store/reducers/workspace_reducer.ts b/public/store/reducers/workspace_reducer.ts index 4ef99dc7..2eb6748e 100644 --- a/public/store/reducers/workspace_reducer.ts +++ b/public/store/reducers/workspace_reducer.ts @@ -7,11 +7,17 @@ import { createSlice } from '@reduxjs/toolkit'; import { IComponent } from '../../../common'; import { KnnIndex, TextEmbeddingProcessor } from '../../component_types'; +// TODO: should be fetched from server-side. This will be the list of all +// available components that the framework offers. This will be used in the component +// library to populate the available components to drag-and-drop into the workspace. +const dummyComponents = [ + new TextEmbeddingProcessor(), + new KnnIndex(), +] as IComponent[]; + const initialState = { isDirty: false, - // TODO: fetch from server-size if it is a created workflow, else have some default - // mapping somewhere (e.g., 'semantic search': text_embedding_processor, knn_index, etc.) - components: [new TextEmbeddingProcessor(), new KnnIndex()] as IComponent[], + components: dummyComponents, }; const workspaceSlice = createSlice({