Skip to content

Commit 8980cb2

Browse files
authored
Add ReactFlow to Workflow Details page (#42)
Signed-off-by: Tyler Ohlsen <ohltyler@amazon.com>
1 parent ea04bce commit 8980cb2

File tree

10 files changed

+315
-22
lines changed

10 files changed

+315
-22
lines changed

common/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Copyright OpenSearch Contributors
33
* SPDX-License-Identifier: Apache-2.0
44
*/
5+
56
export const PLUGIN_ID = 'aiFlowDashboards';
67

78
export const BASE_NODE_API_PATH = '/api/ai_flow';

common/helpers.ts

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { Node, Edge } from 'reactflow';
7+
import { IComponent } from '../public/component_types';
8+
9+
/**
10+
* TODO: remove hardcoded nodes/edges.
11+
*
12+
* Converts the stored IComponents into the low-level ReactFlow nodes and edges.
13+
* This may change entirely, depending on how/where the ReactFlow JSON will be
14+
* persisted. Using this stub helper fn in the meantime.
15+
*/
16+
export function convertToReactFlowData(components: IComponent[]) {
17+
const dummyNodes = [
18+
{
19+
id: 'semantic-search',
20+
position: { x: 40, y: 10 },
21+
data: { label: 'Semantic Search' },
22+
type: 'group',
23+
style: {
24+
height: 110,
25+
width: 700,
26+
},
27+
},
28+
{
29+
id: 'model',
30+
position: { x: 25, y: 25 },
31+
data: { label: 'Deployed Model ID' },
32+
type: 'default',
33+
parentNode: 'semantic-search',
34+
extent: 'parent',
35+
},
36+
{
37+
id: 'ingest-pipeline',
38+
position: { x: 262, y: 25 },
39+
data: { label: 'Ingest Pipeline Name' },
40+
type: 'default',
41+
parentNode: 'semantic-search',
42+
extent: 'parent',
43+
},
44+
] as Array<
45+
Node<
46+
{
47+
label: string;
48+
},
49+
string | undefined
50+
>
51+
>;
52+
53+
const dummyEdges = [
54+
{
55+
id: 'e1-2',
56+
source: 'model',
57+
target: 'ingest-pipeline',
58+
style: {
59+
strokeWidth: 2,
60+
stroke: 'black',
61+
},
62+
markerEnd: {
63+
type: 'arrow',
64+
strokeWidth: 1,
65+
color: 'black',
66+
},
67+
},
68+
] as Array<Edge<any>>;
69+
70+
return {
71+
rfNodes: dummyNodes,
72+
rfEdges: dummyEdges,
73+
};
74+
}

common/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55

66
export * from './constants';
77
export * from './interfaces';
8+
export * from './helpers';
89
export { IComponent } from '../public/component_types';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
.reactflow-parent-wrapper {
2+
display: flex;
3+
flex-grow: 1;
4+
height: 100%;
5+
}
6+
7+
.reactflow-parent-wrapper .reactflow-wrapper {
8+
flex-grow: 1;
9+
height: 100%;
10+
}
11+
12+
.workspace {
13+
width: 50vh;
14+
height: 50vh;
15+
padding: 0;
16+
}

public/pages/workflow_detail/workspace/workspace.tsx

+150-15
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,159 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import React from 'react';
6+
import React, { useRef, useContext, useCallback, useEffect } from 'react';
7+
import ReactFlow, {
8+
Controls,
9+
Background,
10+
useNodesState,
11+
useEdgesState,
12+
addEdge,
13+
} from 'reactflow';
714
import { useSelector } from 'react-redux';
8-
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
9-
import { AppState } from '../../../store';
10-
import { WorkspaceComponent } from '../workspace_component';
15+
import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
16+
import { AppState, rfContext } from '../../../store';
17+
import { convertToReactFlowData } from '../../../../common';
1118

12-
export function Workspace() {
13-
const { components } = useSelector((state: AppState) => state.workspace);
19+
// styling
20+
import 'reactflow/dist/style.css';
21+
import './reactflow-styles.scss';
22+
23+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
24+
interface WorkspaceProps {}
25+
26+
export function Workspace(props: WorkspaceProps) {
27+
const reactFlowWrapper = useRef(null);
28+
const { reactFlowInstance, setReactFlowInstance } = useContext(rfContext);
29+
30+
// Fetching workspace state to populate the initial nodes/edges.
31+
// Where/how the low-level ReactFlow JSON will be persisted is TBD.
32+
// TODO: update when that design is finalized
33+
const storedComponents = useSelector(
34+
(state: AppState) => state.workspace.components
35+
);
36+
const { rfNodes, rfEdges } = convertToReactFlowData(storedComponents);
37+
const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes);
38+
const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges);
39+
40+
const onConnect = useCallback(
41+
(params) => {
42+
setEdges((eds) => addEdge(params, eds));
43+
},
44+
// TODO: add customized logic to prevent connections based on the node's
45+
// allowed inputs. If allowed, update that node state as well with the added
46+
// connection details.
47+
[setEdges]
48+
);
49+
50+
const onDragOver = useCallback((event) => {
51+
event.preventDefault();
52+
event.dataTransfer.dropEffect = 'move';
53+
}, []);
54+
55+
const onDrop = useCallback(
56+
(event) => {
57+
event.preventDefault();
58+
// Get the node info from the event metadata
59+
const nodeData = event.dataTransfer.getData('application/reactflow');
60+
61+
// check if the dropped element is valid
62+
if (typeof nodeData === 'undefined' || !nodeData) {
63+
return;
64+
}
65+
66+
// Fetch bounds based on the ref'd div component, adjust as needed.
67+
// TODO: remove hardcoded bounds and fetch from a constant somewhere
68+
// @ts-ignore
69+
const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect();
70+
// @ts-ignore
71+
const position = reactFlowInstance.project({
72+
x: event.clientX - reactFlowBounds.left - 80,
73+
y: event.clientY - reactFlowBounds.top - 90,
74+
});
75+
76+
// TODO: remove hardcoded values when more component info is passed in the event.
77+
// Only keep the calculated 'positioning' field.
78+
const newNode = {
79+
// TODO: generate ID based on the node data maybe
80+
id: Date.now().toFixed(),
81+
type: nodeData.type,
82+
position,
83+
data: { label: nodeData.label },
84+
style: {
85+
background: 'white',
86+
},
87+
};
88+
89+
setNodes((nds) => nds.concat(newNode));
90+
},
91+
// eslint-disable-next-line react-hooks/exhaustive-deps
92+
[reactFlowInstance]
93+
);
94+
95+
// Initialization hook
96+
useEffect(() => {
97+
// TODO: fetch the nodes/edges dynamically (loading existing flow,
98+
// creating fresh from template, creating blank template, etc.)
99+
// Will involve populating and/or fetching from redux store
100+
}, []);
14101

15102
return (
16-
<EuiFlexGroup direction="row">
17-
{components.map((component, idx) => {
18-
return (
19-
<EuiFlexItem key={idx}>
20-
<WorkspaceComponent component={component} />
21-
</EuiFlexItem>
22-
);
23-
})}
24-
</EuiFlexGroup>
103+
<EuiFlexItem grow={true}>
104+
<EuiFlexGroup
105+
direction="column"
106+
gutterSize="m"
107+
justifyContent="spaceBetween"
108+
className="workspace"
109+
>
110+
<EuiFlexItem
111+
style={{
112+
borderStyle: 'groove',
113+
borderColor: 'gray',
114+
borderWidth: '1px',
115+
}}
116+
>
117+
{/**
118+
* We have these wrapper divs & reactFlowWrapper ref to control and calculate the
119+
* ReactFlow bounds when calculating node positioning.
120+
*/}
121+
<div className="reactflow-parent-wrapper">
122+
<div className="reactflow-wrapper" ref={reactFlowWrapper}>
123+
<ReactFlow
124+
nodes={nodes}
125+
edges={edges}
126+
onNodesChange={onNodesChange}
127+
onEdgesChange={onEdgesChange}
128+
onConnect={onConnect}
129+
onInit={setReactFlowInstance}
130+
onDrop={onDrop}
131+
onDragOver={onDragOver}
132+
fitView
133+
>
134+
<Controls />
135+
<Background />
136+
</ReactFlow>
137+
</div>
138+
</div>
139+
</EuiFlexItem>
140+
</EuiFlexGroup>
141+
</EuiFlexItem>
25142
);
26143
}
144+
145+
// TODO: remove later, leaving for reference
146+
147+
// export function Workspace() {
148+
// const { components } = useSelector((state: AppState) => state.workspace);
149+
150+
// return (
151+
// <EuiFlexGroup direction="row">
152+
// {components.map((component, idx) => {
153+
// return (
154+
// <EuiFlexItem key={idx}>
155+
// <WorkspaceComponent component={component} />
156+
// </EuiFlexItem>
157+
// );
158+
// })}
159+
// </EuiFlexGroup>
160+
// );
161+
// }

public/render_app.tsx

+6-4
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,19 @@ import { BrowserRouter as Router, Route } from 'react-router-dom';
99
import { Provider } from 'react-redux';
1010
import { AppMountParameters, CoreStart } from '../../../src/core/public';
1111
import { AiFlowDashboardsApp } from './app';
12-
import { store } from './store';
12+
import { store, ReactFlowContextProvider } from './store';
1313

1414
export const renderApp = (
1515
coreStart: CoreStart,
1616
{ appBasePath, element }: AppMountParameters
1717
) => {
1818
ReactDOM.render(
1919
<Provider store={store}>
20-
<Router basename={appBasePath + '#/'}>
21-
<Route render={(props) => <AiFlowDashboardsApp {...props} />} />
22-
</Router>
20+
<ReactFlowContextProvider>
21+
<Router basename={appBasePath + '#/'}>
22+
<Route render={(props) => <AiFlowDashboardsApp {...props} />} />
23+
</Router>
24+
</ReactFlowContextProvider>
2325
</Provider>,
2426
element
2527
);

public/store/context/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
export * from './react_flow_context_provider';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import React, { createContext, useState } from 'react';
7+
8+
const initialValues = {
9+
reactFlowInstance: null,
10+
setReactFlowInstance: () => {},
11+
deleteNode: (nodeId: string) => {},
12+
deleteEdge: (edgeId: string) => {},
13+
};
14+
15+
export const rfContext = createContext(initialValues);
16+
17+
/**
18+
* This returns a provider from the rfContext context created above. The initial
19+
* values are set so any nested components can use useContext to access these
20+
* values.
21+
*
22+
* This is how we can manage ReactFlow context consistently across the various
23+
* nested child components.
24+
*/
25+
export function ReactFlowContextProvider({ children }: any) {
26+
const [reactFlowInstance, setReactFlowInstance] = useState(null);
27+
28+
const deleteNode = (nodeId: string) => {
29+
// TODO: implement node deletion
30+
// reactFlowInstance.setNodes(...)
31+
};
32+
33+
const deleteEdge = (edgeId: string) => {
34+
// TODO: implement edge deletion
35+
// reactFlowInstance.setEdges(...)
36+
};
37+
38+
return (
39+
<rfContext.Provider
40+
value={{
41+
reactFlowInstance,
42+
// @ts-ignore
43+
setReactFlowInstance,
44+
deleteNode,
45+
deleteEdge,
46+
}}
47+
>
48+
{children}
49+
</rfContext.Provider>
50+
);
51+
}

public/store/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55

66
export * from './store';
77
export * from './reducers';
8+
export * from './context';

public/store/reducers/workspace_reducer.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@ import { createSlice } from '@reduxjs/toolkit';
77
import { IComponent } from '../../../common';
88
import { KnnIndex, TextEmbeddingProcessor } from '../../component_types';
99

10+
// TODO: should be fetched from server-side. This will be the list of all
11+
// available components that the framework offers. This will be used in the component
12+
// library to populate the available components to drag-and-drop into the workspace.
13+
const dummyComponents = [
14+
new TextEmbeddingProcessor(),
15+
new KnnIndex(),
16+
] as IComponent[];
17+
1018
const initialState = {
1119
isDirty: false,
12-
// TODO: fetch from server-size if it is a created workflow, else have some default
13-
// mapping somewhere (e.g., 'semantic search': text_embedding_processor, knn_index, etc.)
14-
components: [new TextEmbeddingProcessor(), new KnnIndex()] as IComponent[],
20+
components: dummyComponents,
1521
};
1622

1723
const workspaceSlice = createSlice({

0 commit comments

Comments
 (0)