From 42a6fa6fa79d493e27203a6a4f80a88bc6ffbc04 Mon Sep 17 00:00:00 2001 From: Tyler Ohlsen Date: Mon, 25 Sep 2023 12:57:13 -0700 Subject: [PATCH] Refactor into existing Workspace component Signed-off-by: Tyler Ohlsen --- common/constants.ts | 1 + common/helpers.ts | 74 ++++++++ common/index.ts | 1 + public/pages/temp/index.ts | 6 - public/pages/temp/workspace.tsx | 141 --------------- .../workspace}/reactflow-styles.scss | 6 + .../workflow_detail/workspace/workspace.tsx | 167 ++++++++++++++++-- public/store/reducers/workspace_reducer.ts | 74 +------- 8 files changed, 237 insertions(+), 233 deletions(-) create mode 100644 common/helpers.ts delete mode 100644 public/pages/temp/index.ts delete mode 100644 public/pages/temp/workspace.tsx rename public/pages/{temp => workflow_detail/workspace}/reactflow-styles.scss (72%) 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/temp/index.ts b/public/pages/temp/index.ts deleted file mode 100644 index 1d744f92..00000000 --- a/public/pages/temp/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export * from './workspace'; diff --git a/public/pages/temp/workspace.tsx b/public/pages/temp/workspace.tsx deleted file mode 100644 index f6d5ece8..00000000 --- a/public/pages/temp/workspace.tsx +++ /dev/null @@ -1,141 +0,0 @@ -/* - * 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/pages/temp/reactflow-styles.scss b/public/pages/workflow_detail/workspace/reactflow-styles.scss similarity index 72% rename from public/pages/temp/reactflow-styles.scss rename to public/pages/workflow_detail/workspace/reactflow-styles.scss index 840c718e..1f5479b4 100644 --- a/public/pages/temp/reactflow-styles.scss +++ b/public/pages/workflow_detail/workspace/reactflow-styles.scss @@ -8,3 +8,9 @@ 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..ee5f38af 100644 --- a/public/pages/workflow_detail/workspace/workspace.tsx +++ b/public/pages/workflow_detail/workspace/workspace.tsx @@ -3,24 +3,161 @@ * 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'; -export function Workspace() { - const { components } = useSelector((state: AppState) => state.workspace); +// styling +import 'reactflow/dist/style.css'; +import './reactflow-styles.scss'; +import { convertToReactFlowData } from '../../../../common'; + +// 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', + }, + }; + + // 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 ( - - {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/store/reducers/workspace_reducer.ts b/public/store/reducers/workspace_reducer.ts index 340d1c5b..f293ba63 100644 --- a/public/store/reducers/workspace_reducer.ts +++ b/public/store/reducers/workspace_reducer.ts @@ -4,77 +4,18 @@ */ 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 = [ +// TODO: should be fetched from server-side +const dummyComponents = [ 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, components: dummyComponents, - edges: dummyEdges, }; const workspaceSlice = createSlice({ @@ -91,17 +32,8 @@ 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, - setEdges, -} = workspaceSlice.actions; +export const { setDirty, removeDirty, setComponents } = workspaceSlice.actions;