Skip to content

Commit

Permalink
Add ReactFlow to Workflow Details page (opensearch-project#42)
Browse files Browse the repository at this point in the history
Signed-off-by: Tyler Ohlsen <[email protected]>
  • Loading branch information
ohltyler committed Sep 29, 2023
1 parent ea04bce commit 8980cb2
Show file tree
Hide file tree
Showing 10 changed files with 315 additions and 22 deletions.
1 change: 1 addition & 0 deletions common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
74 changes: 74 additions & 0 deletions common/helpers.ts
Original file line number Diff line number Diff line change
@@ -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<Edge<any>>;

return {
rfNodes: dummyNodes,
rfEdges: dummyEdges,
};
}
1 change: 1 addition & 0 deletions common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@

export * from './constants';
export * from './interfaces';
export * from './helpers';
export { IComponent } from '../public/component_types';
16 changes: 16 additions & 0 deletions public/pages/workflow_detail/workspace/reactflow-styles.scss
Original file line number Diff line number Diff line change
@@ -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;
}
165 changes: 150 additions & 15 deletions public/pages/workflow_detail/workspace/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<EuiFlexGroup direction="row">
{components.map((component, idx) => {
return (
<EuiFlexItem key={idx}>
<WorkspaceComponent component={component} />
</EuiFlexItem>
);
})}
</EuiFlexGroup>
<EuiFlexItem grow={true}>
<EuiFlexGroup
direction="column"
gutterSize="m"
justifyContent="spaceBetween"
className="workspace"
>
<EuiFlexItem
style={{
borderStyle: 'groove',
borderColor: 'gray',
borderWidth: '1px',
}}
>
{/**
* We have these wrapper divs & reactFlowWrapper ref to control and calculate the
* ReactFlow bounds when calculating node positioning.
*/}
<div className="reactflow-parent-wrapper">
<div className="reactflow-wrapper" ref={reactFlowWrapper}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onInit={setReactFlowInstance}
onDrop={onDrop}
onDragOver={onDragOver}
fitView
>
<Controls />
<Background />
</ReactFlow>
</div>
</div>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
}

// TODO: remove later, leaving for reference

// export function Workspace() {
// const { components } = useSelector((state: AppState) => state.workspace);

// return (
// <EuiFlexGroup direction="row">
// {components.map((component, idx) => {
// return (
// <EuiFlexItem key={idx}>
// <WorkspaceComponent component={component} />
// </EuiFlexItem>
// );
// })}
// </EuiFlexGroup>
// );
// }
10 changes: 6 additions & 4 deletions public/render_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@ 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,
{ appBasePath, element }: AppMountParameters
) => {
ReactDOM.render(
<Provider store={store}>
<Router basename={appBasePath + '#/'}>
<Route render={(props) => <AiFlowDashboardsApp {...props} />} />
</Router>
<ReactFlowContextProvider>
<Router basename={appBasePath + '#/'}>
<Route render={(props) => <AiFlowDashboardsApp {...props} />} />
</Router>
</ReactFlowContextProvider>
</Provider>,
element
);
Expand Down
6 changes: 6 additions & 0 deletions public/store/context/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

export * from './react_flow_context_provider';
51 changes: 51 additions & 0 deletions public/store/context/react_flow_context_provider.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<rfContext.Provider
value={{
reactFlowInstance,
// @ts-ignore
setReactFlowInstance,
deleteNode,
deleteEdge,
}}
>
{children}
</rfContext.Provider>
);
}
1 change: 1 addition & 0 deletions public/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@

export * from './store';
export * from './reducers';
export * from './context';
12 changes: 9 additions & 3 deletions public/store/reducers/workspace_reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down

0 comments on commit 8980cb2

Please sign in to comment.