Skip to content

Commit ae37968

Browse files
committed
⚡️ Commit from Jolt AI ⚡️
Connect Node-RED API integration (https://app.usejolt.ai/tasks/cb7585af-e17d-46cc-897f-e9f811b38561) Description: Implement the following task in the README.md: ``` IR-01: Establish API communication for flow management. Objective: Enable communication between the frontend client and the Node-RED backend for flow management. Technical Requirements: Design and implement a service layer in the frontend that communicates with Node-RED's backend APIs. ``` The API we're trying to connect to is Node\_RED's API, it has a `/flows` endpoint that accepts GET and POST requests. It has a single property called `flows` which contains all the flows for our project. See `packages/node-red-data/flows.json` for an example of what the value of this flows property is. Whenever the flow in our client is updated, send a request to the `/flows` endpoint to update the flows there as well. Logic for translating between Node-RED and our data format should go into a new logic file called packages/flow-client/src/app/redux/modules/flow/red.logic.ts The actual requests should get triggered in a redux middleware using RTK's `createListenerMiddleware()`, whenever our flows get updated, dispatch a POST to the API.
1 parent 02ac79c commit ae37968

File tree

5 files changed

+301
-1
lines changed

5 files changed

+301
-1
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit';
2+
import { flowActions } from '../modules/flow/flow.slice';
3+
import { flowApi } from '../modules/api/flow.api';
4+
import { AppLogic } from '../logic';
5+
import { RootState } from '../store';
6+
7+
// Create the Node-RED sync middleware instance
8+
export const nodeRedListener = createListenerMiddleware();
9+
10+
// Add a listener that responds to any flow state changes to sync with Node-RED
11+
nodeRedListener.startListening({
12+
matcher: isAnyOf(
13+
// Flow entity actions
14+
flowActions.addFlowEntity,
15+
flowActions.updateFlowEntity,
16+
flowActions.removeFlowEntity,
17+
flowActions.addFlowEntities,
18+
flowActions.updateFlowEntities,
19+
flowActions.removeFlowEntities,
20+
// Flow node actions
21+
flowActions.addFlowNode,
22+
flowActions.updateFlowNode,
23+
flowActions.removeFlowNode,
24+
flowActions.addFlowNodes,
25+
flowActions.updateFlowNodes,
26+
flowActions.removeFlowNodes
27+
),
28+
// Debounce API calls to avoid too many requests
29+
debounce: 1000,
30+
listener: async (action, listenerApi) => {
31+
try {
32+
const state = listenerApi.getState() as RootState;
33+
const logic = listenerApi.extra as AppLogic;
34+
35+
// Convert our state to Node-RED format
36+
const nodeRedFlows = logic.flow.red.toNodeRed(state);
37+
38+
// Update flows in Node-RED
39+
await listenerApi.dispatch(
40+
flowApi.endpoints.updateFlows.initiate(nodeRedFlows)
41+
);
42+
} catch (error) {
43+
// Log any errors but don't crash the app
44+
console.error('Error updating Node-RED flows:', error);
45+
46+
// Could also dispatch an error action if needed:
47+
// listenerApi.dispatch(flowActions.setError('Failed to update Node-RED flows'));
48+
}
49+
},
50+
});
51+
52+
export const nodeRedMiddleware = nodeRedListener.middleware;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
2+
import environment from '../../../../environment';
3+
4+
// Type for Node-RED flows response/request
5+
export interface NodeRedFlows {
6+
flows: unknown[];
7+
}
8+
9+
// Define a service using a base URL and expected endpoints for flows
10+
export const flowApi = createApi({
11+
reducerPath: 'flowApi',
12+
baseQuery: fetchBaseQuery({
13+
baseUrl: environment.NODE_RED_API_ROOT,
14+
responseHandler: 'content-type',
15+
}),
16+
tagTypes: ['Flow'], // For automatic cache invalidation and refetching
17+
endpoints: builder => ({
18+
// Endpoint to fetch all flows
19+
getFlows: builder.query<NodeRedFlows, void>({
20+
query: () => ({
21+
url: 'flows',
22+
headers: {
23+
Accept: 'application/json',
24+
},
25+
}),
26+
providesTags: ['Flow'],
27+
}),
28+
// Endpoint to update all flows
29+
updateFlows: builder.mutation<NodeRedFlows, NodeRedFlows>({
30+
query: (flows) => ({
31+
url: 'flows',
32+
method: 'POST',
33+
headers: {
34+
'Content-Type': 'application/json',
35+
},
36+
body: flows,
37+
}),
38+
invalidatesTags: ['Flow'],
39+
}),
40+
}),
41+
});
42+
43+
// Export hooks for usage in components
44+
export const { useGetFlowsQuery, useUpdateFlowsMutation } = flowApi;

packages/flow-client/src/app/redux/modules/flow/flow.logic.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import {
99
selectFlowEntityById,
1010
selectSubflowInOutByFlowId,
1111
selectSubflowInstancesByFlowId,
12+
FlowEntity,
1213
} from './flow.slice';
1314
import { GraphLogic } from './graph.logic';
1415
import { NodeLogic } from './node.logic';
1516
import { TreeLogic } from './tree.logic';
17+
import { RedLogic } from './red.logic';
1618

1719
// checks if a given property has changed
1820
const objectHasChange = <T>(
@@ -37,11 +39,13 @@ export class FlowLogic {
3739
public readonly graph: GraphLogic;
3840
public readonly node: NodeLogic;
3941
public readonly tree: TreeLogic;
42+
public readonly red: RedLogic;
4043

4144
constructor() {
4245
this.node = new NodeLogic();
4346
this.graph = new GraphLogic(this.node);
4447
this.tree = new TreeLogic();
48+
this.red = new RedLogic();
4549
}
4650

4751
public createNewFlow(
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { RootState } from '../../store';
2+
import { NodeRedFlows } from '../api/flow.api';
3+
import {
4+
FlowEntity,
5+
FlowNodeEntity,
6+
SubflowEntity,
7+
selectAllFlowEntities,
8+
selectFlowNodesByFlowId,
9+
} from './flow.slice';
10+
11+
export class RedLogic {
12+
private convertNodeToNodeRed(node: FlowNodeEntity): unknown {
13+
// Convert our node format to Node-RED format
14+
const nodeRedNode = {
15+
id: node.id,
16+
type: node.type,
17+
name: node.name,
18+
x: node.x,
19+
y: node.y,
20+
z: node.z,
21+
wires: node.wires ?? [],
22+
inputs: node.inputs,
23+
outputs: node.outputs,
24+
inputLabels: node.inputLabels,
25+
outputLabels: node.outputLabels,
26+
icon: node.icon,
27+
info: node.info,
28+
};
29+
30+
// Remove undefined properties
31+
return Object.fromEntries(
32+
Object.entries(nodeRedNode).filter(([, v]) => v !== undefined)
33+
);
34+
}
35+
36+
private convertFlowToNodeRed(
37+
flow: FlowEntity | SubflowEntity,
38+
state: RootState
39+
): unknown {
40+
const baseFlow = {
41+
id: flow.id,
42+
type: flow.type,
43+
label: flow.name,
44+
disabled: flow.disabled,
45+
info: flow.info,
46+
};
47+
48+
if (flow.type === 'subflow') {
49+
return {
50+
...baseFlow,
51+
name: flow.name,
52+
category: flow.category,
53+
color: flow.color,
54+
icon: flow.icon,
55+
in: flow.in,
56+
out: flow.out,
57+
env: flow.env,
58+
meta: {},
59+
};
60+
}
61+
62+
return {
63+
...baseFlow,
64+
env: flow.env,
65+
};
66+
}
67+
68+
private createMetadataFlow(state: RootState): unknown {
69+
const flows = selectAllFlowEntities(state);
70+
const metadata = flows.map(flow => ({
71+
id: flow.id,
72+
extraData: flow,
73+
nodes: selectFlowNodesByFlowId(state, flow.id).map(node => ({
74+
id: node.id,
75+
extraData: node
76+
}))
77+
}));
78+
return {
79+
id: "aed83478cb340859",
80+
type: "tab",
81+
label: "FLOW-CLIENT:::METADATA::ROOT",
82+
disabled: true,
83+
info: JSON.stringify(metadata),
84+
env: []
85+
};
86+
}
87+
88+
public toNodeRed(state: RootState): NodeRedFlows {
89+
// Get all flows and their nodes
90+
const flows = selectAllFlowEntities(state);
91+
92+
// Convert each flow and its nodes to Node-RED format
93+
const nodeRedFlows = flows.map(flow => {
94+
const flowNodes = selectFlowNodesByFlowId(state, flow.id);
95+
96+
return {
97+
...this.convertFlowToNodeRed(flow, state),
98+
// Include nodes if this is not a subflow
99+
...(flow.type !== 'subflow' && {
100+
nodes: flowNodes.map(node =>
101+
this.convertNodeToNodeRed(node)
102+
),
103+
}),
104+
};
105+
}).concat(this.createMetadataFlow(state));
106+
107+
return {
108+
flows: nodeRedFlows,
109+
};
110+
}
111+
112+
private extractMetadata(nodeRedFlows: NodeRedFlows) {
113+
const metadataFlow = nodeRedFlows.flows.find(
114+
flow => flow.type === 'tab' && flow.label === 'FLOW-CLIENT:::METADATA::ROOT'
115+
);
116+
if (!metadataFlow?.info) {
117+
return {};
118+
}
119+
const metadata = JSON.parse(metadataFlow.info as string);
120+
return metadata.reduce((acc: Record<string, unknown>, flowMeta: any) => {
121+
acc[flowMeta.id] = {
122+
flow: flowMeta.extraData,
123+
nodes: flowMeta.nodes.reduce((nodeAcc: Record<string, unknown>, nodeMeta: any) => {
124+
nodeAcc[nodeMeta.id] = nodeMeta.extraData;
125+
return nodeAcc;
126+
}, {})
127+
};
128+
return acc;
129+
}, {});
130+
}
131+
132+
public fromNodeRed(nodeRedFlows: NodeRedFlows): {
133+
flows: Array<FlowEntity | SubflowEntity>;
134+
nodes: FlowNodeEntity[];
135+
} {
136+
const metadata = this.extractMetadata(nodeRedFlows);
137+
const flows: Array<FlowEntity | SubflowEntity> = [];
138+
const nodes: FlowNodeEntity[] = [];
139+
140+
nodeRedFlows.flows
141+
.filter(flow => flow.label !== 'FLOW-CLIENT:::METADATA::ROOT')
142+
.forEach(nodeRedFlow => {
143+
const flowMetadata = metadata[nodeRedFlow.id as string]?.flow || {};
144+
145+
if (nodeRedFlow.type === 'subflow') {
146+
flows.push({
147+
...flowMetadata,
148+
id: nodeRedFlow.id as string,
149+
type: 'subflow',
150+
name: nodeRedFlow.name as string,
151+
info: nodeRedFlow.info as string || '',
152+
category: nodeRedFlow.category as string,
153+
color: nodeRedFlow.color as string,
154+
icon: nodeRedFlow.icon as string,
155+
in: nodeRedFlow.in as string[],
156+
out: nodeRedFlow.out as string[],
157+
env: nodeRedFlow.env as []
158+
} as SubflowEntity);
159+
} else {
160+
flows.push({
161+
...flowMetadata,
162+
id: nodeRedFlow.id as string,
163+
type: 'flow',
164+
name: nodeRedFlow.label as string,
165+
disabled: nodeRedFlow.disabled as boolean,
166+
info: nodeRedFlow.info as string || '',
167+
env: nodeRedFlow.env as []
168+
} as FlowEntity);
169+
}
170+
171+
// If this is a regular flow with nodes, process them
172+
if (nodeRedFlow.type !== 'subflow' && Array.isArray(nodeRedFlow.nodes)) {
173+
nodeRedFlow.nodes.forEach(nodeRedNode => {
174+
const nodeMetadata = metadata[nodeRedFlow.id as string]?.nodes?.[nodeRedNode.id as string] || {};
175+
nodes.push({
176+
...nodeMetadata,
177+
id: nodeRedNode.id as string,
178+
type: nodeRedNode.type as string,
179+
x: nodeRedNode.x as number,
180+
y: nodeRedNode.y as number,
181+
z: nodeRedFlow.id as string,
182+
wires: nodeRedNode.wires as string[][],
183+
} as FlowNodeEntity);
184+
});
185+
}
186+
});
187+
188+
return {
189+
flows,
190+
nodes,
191+
};
192+
}
193+
}

packages/flow-client/src/app/redux/store.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Action, configureStore, ThunkAction } from '@reduxjs/toolkit';
22
import { setupListeners } from '@reduxjs/toolkit/query';
33
import {
4+
flowApi } from './modules/api/flow.api';
45
FLUSH,
56
PAUSE,
67
PERSIST,
@@ -12,6 +13,7 @@ import {
1213
import storage from 'redux-persist/lib/storage';
1314

1415
import type { AppLogic } from './logic';
16+
import { flowMiddleware } from './middleware/flow.middleware';
1517
import { iconApi } from './modules/api/icon.api';
1618
import { nodeApi } from './modules/api/node.api'; // Import the nodeApi
1719
import {
@@ -32,6 +34,7 @@ import {
3234
export const createStore = (logic: AppLogic) => {
3335
const store = configureStore({
3436
reducer: {
37+
[flowApi.reducerPath]: flowApi.reducer,
3538
[nodeApi.reducerPath]: nodeApi.reducer,
3639
[iconApi.reducerPath]: iconApi.reducer,
3740
[PALETTE_NODE_FEATURE_KEY]: paletteNodeReducer,
@@ -66,7 +69,11 @@ export const createStore = (logic: AppLogic) => {
6669
thunk: {
6770
extraArgument: logic,
6871
},
69-
}).concat(nodeApi.middleware, iconApi.middleware),
72+
}).concat(
73+
nodeApi.middleware,
74+
iconApi.middleware,
75+
flowApi.middleware,
76+
flowMiddleware),
7077
devTools: process.env.NODE_ENV !== 'production',
7178
});
7279

0 commit comments

Comments
 (0)