diff --git a/public/pages/temp/index.ts b/public/pages/temp/index.ts new file mode 100644 index 00000000..1d744f92 --- /dev/null +++ b/public/pages/temp/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './workspace'; diff --git a/public/pages/temp/reactflow-styles.scss b/public/pages/temp/reactflow-styles.scss new file mode 100644 index 00000000..840c718e --- /dev/null +++ b/public/pages/temp/reactflow-styles.scss @@ -0,0 +1,10 @@ +.reactflow-parent-wrapper { + display: flex; + flex-grow: 1; + height: 100%; +} + +.reactflow-parent-wrapper .reactflow-wrapper { + flex-grow: 1; + height: 100%; +} diff --git a/public/pages/temp/workspace.tsx b/public/pages/temp/workspace.tsx new file mode 100644 index 00000000..f6d5ece8 --- /dev/null +++ b/public/pages/temp/workspace.tsx @@ -0,0 +1,141 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useRef, useContext, useCallback, useEffect } from 'react'; +import ReactFlow, { + Controls, + Background, + useNodesState, + useEdgesState, + addEdge, +} from 'reactflow'; +import { useSelector } from 'react-redux'; +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { AppState, rfContext } from '../../store'; + +// 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 + const storedComponents = useSelector( + (state: AppState) => state.workspace.components + ); + const storedEdges = useSelector((state: AppState) => state.workspace.edges); + const [nodes, setNodes, onNodesChange] = useNodesState(storedComponents); + const [edges, setEdges, onEdgesChange] = useEdgesState(storedEdges); + + 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', + }, + }; + + // TODO: add logic to add node into the redux datastore + + 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 ( + + + + {/** + * We have these wrapper divs & reactFlowWrapper ref to control and calculate the + * ReactFlow bounds when calculating node positioning. + */} +
+
+ + + + +
+
+
+
+
+ ); +} 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..da9eb694 --- /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'; +// import { useDispatch } from 'react-redux'; + +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 dispatch = useDispatch(); + const [reactFlowInstance, setReactFlowInstance] = useState(null); + + const deleteNode = (nodeId: string) => { + // reactFlowInstance.setNodes(...) + }; + + const deleteEdge = (edgeId: string) => { + // 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..340d1c5b 100644 --- a/public/store/reducers/workspace_reducer.ts +++ b/public/store/reducers/workspace_reducer.ts @@ -4,14 +4,77 @@ */ import { createSlice } from '@reduxjs/toolkit'; +import { Node, Edge } from 'reactflow'; import { IComponent } from '../../../common'; import { KnnIndex, TextEmbeddingProcessor } from '../../component_types'; +// 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.) + +// TODO: we should store as IComponents. Have some helper fn for converting these to a +// actual ReactFlow Node. examples of reactflow nodes below. +const iComponents = [ + new TextEmbeddingProcessor(), + new KnnIndex(), +] as IComponent[]; + +const dummyComponents = [ + { + 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>; + 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, + edges: dummyEdges, }; const workspaceSlice = createSlice({ @@ -28,8 +91,17 @@ const workspaceSlice = createSlice({ state.components = action.payload; state.isDirty = true; }, + setEdges(state, action) { + state.edges = action.payload; + state.isDirty = true; + }, }, }); export const workspaceReducer = workspaceSlice.reducer; -export const { setDirty, removeDirty, setComponents } = workspaceSlice.actions; +export const { + setDirty, + removeDirty, + setComponents, + setEdges, +} = workspaceSlice.actions;