Skip to content

Commit

Permalink
Add ReactFlow context
Browse files Browse the repository at this point in the history
Signed-off-by: Tyler Ohlsen <[email protected]>
  • Loading branch information
ohltyler committed Sep 25, 2023
1 parent ea04bce commit 5b1b446
Show file tree
Hide file tree
Showing 8 changed files with 297 additions and 8 deletions.
6 changes: 6 additions & 0 deletions public/pages/temp/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 './workspace';
10 changes: 10 additions & 0 deletions public/pages/temp/reactflow-styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.reactflow-parent-wrapper {
display: flex;
flex-grow: 1;
height: 100%;
}

.reactflow-parent-wrapper .reactflow-wrapper {
flex-grow: 1;
height: 100%;
}
141 changes: 141 additions & 0 deletions public/pages/temp/workspace.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<EuiFlexItem grow={true}>
<EuiFlexGroup
direction="column"
gutterSize="m"
justifyContent="spaceBetween"
>
<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>
);
}
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';
// 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 (
<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';
80 changes: 76 additions & 4 deletions public/store/reducers/workspace_reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Edge<any>>;

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({
Expand All @@ -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;

0 comments on commit 5b1b446

Please sign in to comment.