From ae37968a09fcf760aed0047036db427a411b66e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20Jolt=20AI=20=E2=9A=A1=EF=B8=8F?= Date: Sun, 15 Dec 2024 16:39:07 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20Commit=20from=20Jolt?= =?UTF-8?q?=20AI=20=E2=9A=A1=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/redux/middleware/flow.middleware.ts | 52 +++++ .../src/app/redux/modules/api/flow.api.ts | 44 ++++ .../src/app/redux/modules/flow/flow.logic.ts | 4 + .../src/app/redux/modules/flow/red.logic.ts | 193 ++++++++++++++++++ packages/flow-client/src/app/redux/store.ts | 9 +- 5 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 packages/flow-client/src/app/redux/middleware/flow.middleware.ts create mode 100644 packages/flow-client/src/app/redux/modules/api/flow.api.ts create mode 100644 packages/flow-client/src/app/redux/modules/flow/red.logic.ts diff --git a/packages/flow-client/src/app/redux/middleware/flow.middleware.ts b/packages/flow-client/src/app/redux/middleware/flow.middleware.ts new file mode 100644 index 0000000..1875cf9 --- /dev/null +++ b/packages/flow-client/src/app/redux/middleware/flow.middleware.ts @@ -0,0 +1,52 @@ +import { createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit'; +import { flowActions } from '../modules/flow/flow.slice'; +import { flowApi } from '../modules/api/flow.api'; +import { AppLogic } from '../logic'; +import { RootState } from '../store'; + +// Create the Node-RED sync middleware instance +export const nodeRedListener = createListenerMiddleware(); + +// Add a listener that responds to any flow state changes to sync with Node-RED +nodeRedListener.startListening({ + matcher: isAnyOf( + // Flow entity actions + flowActions.addFlowEntity, + flowActions.updateFlowEntity, + flowActions.removeFlowEntity, + flowActions.addFlowEntities, + flowActions.updateFlowEntities, + flowActions.removeFlowEntities, + // Flow node actions + flowActions.addFlowNode, + flowActions.updateFlowNode, + flowActions.removeFlowNode, + flowActions.addFlowNodes, + flowActions.updateFlowNodes, + flowActions.removeFlowNodes + ), + // Debounce API calls to avoid too many requests + debounce: 1000, + listener: async (action, listenerApi) => { + try { + const state = listenerApi.getState() as RootState; + const logic = listenerApi.extra as AppLogic; + + // Convert our state to Node-RED format + const nodeRedFlows = logic.flow.red.toNodeRed(state); + + // Update flows in Node-RED + await listenerApi.dispatch( + flowApi.endpoints.updateFlows.initiate(nodeRedFlows) + ); + } catch (error) { + // Log any errors but don't crash the app + console.error('Error updating Node-RED flows:', error); + + // Could also dispatch an error action if needed: + // listenerApi.dispatch(flowActions.setError('Failed to update Node-RED flows')); + } + }, +}); + +export const nodeRedMiddleware = nodeRedListener.middleware; diff --git a/packages/flow-client/src/app/redux/modules/api/flow.api.ts b/packages/flow-client/src/app/redux/modules/api/flow.api.ts new file mode 100644 index 0000000..4cc4bcd --- /dev/null +++ b/packages/flow-client/src/app/redux/modules/api/flow.api.ts @@ -0,0 +1,44 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import environment from '../../../../environment'; + +// Type for Node-RED flows response/request +export interface NodeRedFlows { + flows: unknown[]; +} + +// Define a service using a base URL and expected endpoints for flows +export const flowApi = createApi({ + reducerPath: 'flowApi', + baseQuery: fetchBaseQuery({ + baseUrl: environment.NODE_RED_API_ROOT, + responseHandler: 'content-type', + }), + tagTypes: ['Flow'], // For automatic cache invalidation and refetching + endpoints: builder => ({ + // Endpoint to fetch all flows + getFlows: builder.query({ + query: () => ({ + url: 'flows', + headers: { + Accept: 'application/json', + }, + }), + providesTags: ['Flow'], + }), + // Endpoint to update all flows + updateFlows: builder.mutation({ + query: (flows) => ({ + url: 'flows', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: flows, + }), + invalidatesTags: ['Flow'], + }), + }), +}); + +// Export hooks for usage in components +export const { useGetFlowsQuery, useUpdateFlowsMutation } = flowApi; diff --git a/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts b/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts index c7337fe..ea8e9dc 100644 --- a/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts +++ b/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts @@ -9,10 +9,12 @@ import { selectFlowEntityById, selectSubflowInOutByFlowId, selectSubflowInstancesByFlowId, + FlowEntity, } from './flow.slice'; import { GraphLogic } from './graph.logic'; import { NodeLogic } from './node.logic'; import { TreeLogic } from './tree.logic'; +import { RedLogic } from './red.logic'; // checks if a given property has changed const objectHasChange = ( @@ -37,11 +39,13 @@ export class FlowLogic { public readonly graph: GraphLogic; public readonly node: NodeLogic; public readonly tree: TreeLogic; + public readonly red: RedLogic; constructor() { this.node = new NodeLogic(); this.graph = new GraphLogic(this.node); this.tree = new TreeLogic(); + this.red = new RedLogic(); } public createNewFlow( diff --git a/packages/flow-client/src/app/redux/modules/flow/red.logic.ts b/packages/flow-client/src/app/redux/modules/flow/red.logic.ts new file mode 100644 index 0000000..54331c3 --- /dev/null +++ b/packages/flow-client/src/app/redux/modules/flow/red.logic.ts @@ -0,0 +1,193 @@ +import { RootState } from '../../store'; +import { NodeRedFlows } from '../api/flow.api'; +import { + FlowEntity, + FlowNodeEntity, + SubflowEntity, + selectAllFlowEntities, + selectFlowNodesByFlowId, +} from './flow.slice'; + +export class RedLogic { + private convertNodeToNodeRed(node: FlowNodeEntity): unknown { + // Convert our node format to Node-RED format + const nodeRedNode = { + id: node.id, + type: node.type, + name: node.name, + x: node.x, + y: node.y, + z: node.z, + wires: node.wires ?? [], + inputs: node.inputs, + outputs: node.outputs, + inputLabels: node.inputLabels, + outputLabels: node.outputLabels, + icon: node.icon, + info: node.info, + }; + + // Remove undefined properties + return Object.fromEntries( + Object.entries(nodeRedNode).filter(([, v]) => v !== undefined) + ); + } + + private convertFlowToNodeRed( + flow: FlowEntity | SubflowEntity, + state: RootState + ): unknown { + const baseFlow = { + id: flow.id, + type: flow.type, + label: flow.name, + disabled: flow.disabled, + info: flow.info, + }; + + if (flow.type === 'subflow') { + return { + ...baseFlow, + name: flow.name, + category: flow.category, + color: flow.color, + icon: flow.icon, + in: flow.in, + out: flow.out, + env: flow.env, + meta: {}, + }; + } + + return { + ...baseFlow, + env: flow.env, + }; + } + + private createMetadataFlow(state: RootState): unknown { + const flows = selectAllFlowEntities(state); + const metadata = flows.map(flow => ({ + id: flow.id, + extraData: flow, + nodes: selectFlowNodesByFlowId(state, flow.id).map(node => ({ + id: node.id, + extraData: node + })) + })); + return { + id: "aed83478cb340859", + type: "tab", + label: "FLOW-CLIENT:::METADATA::ROOT", + disabled: true, + info: JSON.stringify(metadata), + env: [] + }; + } + + public toNodeRed(state: RootState): NodeRedFlows { + // Get all flows and their nodes + const flows = selectAllFlowEntities(state); + + // Convert each flow and its nodes to Node-RED format + const nodeRedFlows = flows.map(flow => { + const flowNodes = selectFlowNodesByFlowId(state, flow.id); + + return { + ...this.convertFlowToNodeRed(flow, state), + // Include nodes if this is not a subflow + ...(flow.type !== 'subflow' && { + nodes: flowNodes.map(node => + this.convertNodeToNodeRed(node) + ), + }), + }; + }).concat(this.createMetadataFlow(state)); + + return { + flows: nodeRedFlows, + }; + } + + private extractMetadata(nodeRedFlows: NodeRedFlows) { + const metadataFlow = nodeRedFlows.flows.find( + flow => flow.type === 'tab' && flow.label === 'FLOW-CLIENT:::METADATA::ROOT' + ); + if (!metadataFlow?.info) { + return {}; + } + const metadata = JSON.parse(metadataFlow.info as string); + return metadata.reduce((acc: Record, flowMeta: any) => { + acc[flowMeta.id] = { + flow: flowMeta.extraData, + nodes: flowMeta.nodes.reduce((nodeAcc: Record, nodeMeta: any) => { + nodeAcc[nodeMeta.id] = nodeMeta.extraData; + return nodeAcc; + }, {}) + }; + return acc; + }, {}); + } + + public fromNodeRed(nodeRedFlows: NodeRedFlows): { + flows: Array; + nodes: FlowNodeEntity[]; + } { + const metadata = this.extractMetadata(nodeRedFlows); + const flows: Array = []; + const nodes: FlowNodeEntity[] = []; + + nodeRedFlows.flows + .filter(flow => flow.label !== 'FLOW-CLIENT:::METADATA::ROOT') + .forEach(nodeRedFlow => { + const flowMetadata = metadata[nodeRedFlow.id as string]?.flow || {}; + + if (nodeRedFlow.type === 'subflow') { + flows.push({ + ...flowMetadata, + id: nodeRedFlow.id as string, + type: 'subflow', + name: nodeRedFlow.name as string, + info: nodeRedFlow.info as string || '', + category: nodeRedFlow.category as string, + color: nodeRedFlow.color as string, + icon: nodeRedFlow.icon as string, + in: nodeRedFlow.in as string[], + out: nodeRedFlow.out as string[], + env: nodeRedFlow.env as [] + } as SubflowEntity); + } else { + flows.push({ + ...flowMetadata, + id: nodeRedFlow.id as string, + type: 'flow', + name: nodeRedFlow.label as string, + disabled: nodeRedFlow.disabled as boolean, + info: nodeRedFlow.info as string || '', + env: nodeRedFlow.env as [] + } as FlowEntity); + } + + // If this is a regular flow with nodes, process them + if (nodeRedFlow.type !== 'subflow' && Array.isArray(nodeRedFlow.nodes)) { + nodeRedFlow.nodes.forEach(nodeRedNode => { + const nodeMetadata = metadata[nodeRedFlow.id as string]?.nodes?.[nodeRedNode.id as string] || {}; + nodes.push({ + ...nodeMetadata, + id: nodeRedNode.id as string, + type: nodeRedNode.type as string, + x: nodeRedNode.x as number, + y: nodeRedNode.y as number, + z: nodeRedFlow.id as string, + wires: nodeRedNode.wires as string[][], + } as FlowNodeEntity); + }); + } + }); + + return { + flows, + nodes, + }; + } +} diff --git a/packages/flow-client/src/app/redux/store.ts b/packages/flow-client/src/app/redux/store.ts index cc644bc..55c051e 100644 --- a/packages/flow-client/src/app/redux/store.ts +++ b/packages/flow-client/src/app/redux/store.ts @@ -1,6 +1,7 @@ import { Action, configureStore, ThunkAction } from '@reduxjs/toolkit'; import { setupListeners } from '@reduxjs/toolkit/query'; import { + flowApi } from './modules/api/flow.api'; FLUSH, PAUSE, PERSIST, @@ -12,6 +13,7 @@ import { import storage from 'redux-persist/lib/storage'; import type { AppLogic } from './logic'; +import { flowMiddleware } from './middleware/flow.middleware'; import { iconApi } from './modules/api/icon.api'; import { nodeApi } from './modules/api/node.api'; // Import the nodeApi import { @@ -32,6 +34,7 @@ import { export const createStore = (logic: AppLogic) => { const store = configureStore({ reducer: { + [flowApi.reducerPath]: flowApi.reducer, [nodeApi.reducerPath]: nodeApi.reducer, [iconApi.reducerPath]: iconApi.reducer, [PALETTE_NODE_FEATURE_KEY]: paletteNodeReducer, @@ -66,7 +69,11 @@ export const createStore = (logic: AppLogic) => { thunk: { extraArgument: logic, }, - }).concat(nodeApi.middleware, iconApi.middleware), + }).concat( + nodeApi.middleware, + iconApi.middleware, + flowApi.middleware, + flowMiddleware), devTools: process.env.NODE_ENV !== 'production', }); From 16b3346ba8d1efbb1266c7c3ef3893533ee20cc4 Mon Sep 17 00:00:00 2001 From: Joshua Carter Date: Sun, 15 Dec 2024 22:15:50 -0800 Subject: [PATCH 2/4] Update @reduxjs/toolkit to 2.5.0 --- package-lock.json | 12 ++++++------ package.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4683eb4..9d9d29b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@fortawesome/fontawesome-free": "^6.5.2", "@projectstorm/react-canvas-core": "^7.0.3", "@projectstorm/react-diagrams": "^7.0.4", - "@reduxjs/toolkit": "^2.2.3", + "@reduxjs/toolkit": "^2.5.0", "dompurify": "^3.1.0", "easymde": "^2.18.0", "i18next": "^23.11.2", @@ -3527,17 +3527,17 @@ "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==" }, "node_modules/@reduxjs/toolkit": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.3.tgz", - "integrity": "sha512-76dll9EnJXg4EVcI5YNxZA/9hSAmZsFqzMmNRHvIlzw2WS/twfcVX3ysYrWGJMClwEmChQFC4yRq74tn6fdzRA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.5.0.tgz", + "integrity": "sha512-awNe2oTodsZ6LmRqmkFhtb/KH03hUhxOamEQy411m3Njj3BbFvoBovxo4Q1cBWnV1ErprVj9MlF0UPXkng0eyg==", "dependencies": { "immer": "^10.0.3", "redux": "^5.0.1", "redux-thunk": "^3.1.0", - "reselect": "^5.0.1" + "reselect": "^5.1.0" }, "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18", + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "peerDependenciesMeta": { diff --git a/package.json b/package.json index b6688a0..1b24423 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@fortawesome/fontawesome-free": "^6.5.2", "@projectstorm/react-canvas-core": "^7.0.3", "@projectstorm/react-diagrams": "^7.0.4", - "@reduxjs/toolkit": "^2.2.3", + "@reduxjs/toolkit": "^2.5.0", "dompurify": "^3.1.0", "easymde": "^2.18.0", "i18next": "^23.11.2", From 33f8259952fb977da4bba27eae274c00714d657c Mon Sep 17 00:00:00 2001 From: Joshua Carter Date: Sun, 15 Dec 2024 22:17:48 -0800 Subject: [PATCH 3/4] Implement new listener middleware pattern --- .../app/redux/middleware/flow.middleware.ts | 52 ---------------- .../app/redux/modules/flow/red.listener.ts | 62 +++++++++++++++++++ packages/flow-client/src/app/redux/store.ts | 14 +++-- 3 files changed, 72 insertions(+), 56 deletions(-) delete mode 100644 packages/flow-client/src/app/redux/middleware/flow.middleware.ts create mode 100644 packages/flow-client/src/app/redux/modules/flow/red.listener.ts diff --git a/packages/flow-client/src/app/redux/middleware/flow.middleware.ts b/packages/flow-client/src/app/redux/middleware/flow.middleware.ts deleted file mode 100644 index 1875cf9..0000000 --- a/packages/flow-client/src/app/redux/middleware/flow.middleware.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit'; -import { flowActions } from '../modules/flow/flow.slice'; -import { flowApi } from '../modules/api/flow.api'; -import { AppLogic } from '../logic'; -import { RootState } from '../store'; - -// Create the Node-RED sync middleware instance -export const nodeRedListener = createListenerMiddleware(); - -// Add a listener that responds to any flow state changes to sync with Node-RED -nodeRedListener.startListening({ - matcher: isAnyOf( - // Flow entity actions - flowActions.addFlowEntity, - flowActions.updateFlowEntity, - flowActions.removeFlowEntity, - flowActions.addFlowEntities, - flowActions.updateFlowEntities, - flowActions.removeFlowEntities, - // Flow node actions - flowActions.addFlowNode, - flowActions.updateFlowNode, - flowActions.removeFlowNode, - flowActions.addFlowNodes, - flowActions.updateFlowNodes, - flowActions.removeFlowNodes - ), - // Debounce API calls to avoid too many requests - debounce: 1000, - listener: async (action, listenerApi) => { - try { - const state = listenerApi.getState() as RootState; - const logic = listenerApi.extra as AppLogic; - - // Convert our state to Node-RED format - const nodeRedFlows = logic.flow.red.toNodeRed(state); - - // Update flows in Node-RED - await listenerApi.dispatch( - flowApi.endpoints.updateFlows.initiate(nodeRedFlows) - ); - } catch (error) { - // Log any errors but don't crash the app - console.error('Error updating Node-RED flows:', error); - - // Could also dispatch an error action if needed: - // listenerApi.dispatch(flowActions.setError('Failed to update Node-RED flows')); - } - }, -}); - -export const nodeRedMiddleware = nodeRedListener.middleware; diff --git a/packages/flow-client/src/app/redux/modules/flow/red.listener.ts b/packages/flow-client/src/app/redux/modules/flow/red.listener.ts new file mode 100644 index 0000000..8f624ee --- /dev/null +++ b/packages/flow-client/src/app/redux/modules/flow/red.listener.ts @@ -0,0 +1,62 @@ +import { createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit'; +import type { AppLogic } from '../../logic'; +import type { AppDispatch, RootState } from '../../store'; +import { flowApi } from '../api/flow.api'; +import { flowActions } from './flow.slice'; + +// Create the Node-RED sync middleware instance +export const createRedListener = (logic: AppLogic) => { + const nodeRedListener = createListenerMiddleware({ + extra: logic, + }); + + return nodeRedListener; +}; + +export const startRedListener = ( + listener: ReturnType +) => { + // Add a listener that responds to any flow state changes to sync with Node-RED + listener.startListening.withTypes()({ + matcher: isAnyOf( + // Flow entity actions + flowActions.addFlowEntity, + flowActions.updateFlowEntity, + flowActions.removeFlowEntity, + flowActions.addFlowEntities, + flowActions.updateFlowEntities, + flowActions.removeFlowEntities, + // Flow node actions + flowActions.addFlowNode, + flowActions.updateFlowNode, + flowActions.removeFlowNode, + flowActions.addFlowNodes, + flowActions.updateFlowNodes, + flowActions.removeFlowNodes + ), + effect: async (action, listenerApi) => { + // debounce pattern + listenerApi.cancelActiveListeners(); + await listenerApi.delay(1000); + + try { + const state = listenerApi.getState(); + const logic = listenerApi.extra; + + // Convert our state to Node-RED format + const nodeRedFlows = logic.flow.red.toNodeRed(state); + + // Update flows in Node-RED + await listenerApi.dispatch( + flowApi.endpoints.updateFlows.initiate(nodeRedFlows) + ); + } catch (error) { + // Log any errors but don't crash the app + console.error('Error updating Node-RED flows:', error); + + // Could also dispatch an error action if needed: + // listenerApi.dispatch(flowActions.setError('Failed to update Node-RED flows')); + } + }, + }); +}; diff --git a/packages/flow-client/src/app/redux/store.ts b/packages/flow-client/src/app/redux/store.ts index 55c051e..f024769 100644 --- a/packages/flow-client/src/app/redux/store.ts +++ b/packages/flow-client/src/app/redux/store.ts @@ -1,7 +1,6 @@ import { Action, configureStore, ThunkAction } from '@reduxjs/toolkit'; import { setupListeners } from '@reduxjs/toolkit/query'; import { - flowApi } from './modules/api/flow.api'; FLUSH, PAUSE, PERSIST, @@ -11,9 +10,8 @@ import { REHYDRATE, } from 'redux-persist'; import storage from 'redux-persist/lib/storage'; - import type { AppLogic } from './logic'; -import { flowMiddleware } from './middleware/flow.middleware'; +import { flowApi } from './modules/api/flow.api'; import { iconApi } from './modules/api/icon.api'; import { nodeApi } from './modules/api/node.api'; // Import the nodeApi import { @@ -26,12 +24,18 @@ import { flowReducer, FlowState, } from './modules/flow/flow.slice'; +import { + createRedListener, + startRedListener, +} from './modules/flow/red.listener'; import { PALETTE_NODE_FEATURE_KEY, paletteNodeReducer, } from './modules/palette/node.slice'; export const createStore = (logic: AppLogic) => { + const redListener = createRedListener(logic); + const store = configureStore({ reducer: { [flowApi.reducerPath]: flowApi.reducer, @@ -73,11 +77,13 @@ export const createStore = (logic: AppLogic) => { nodeApi.middleware, iconApi.middleware, flowApi.middleware, - flowMiddleware), + redListener.middleware + ), devTools: process.env.NODE_ENV !== 'production', }); setupListeners(store.dispatch); + startRedListener(redListener); return store; }; From 25d069f8ae765d6aad6b56bd910ce62043bfd8d6 Mon Sep 17 00:00:00 2001 From: Joshua Carter Date: Sun, 15 Dec 2024 22:19:30 -0800 Subject: [PATCH 4/4] Correct flow.api and red.logic implementations to correctly save translated data to Node-RED API --- .../src/app/redux/modules/api/flow.api.ts | 59 ++++- .../src/app/redux/modules/flow/flow.logic.ts | 3 +- .../src/app/redux/modules/flow/red.logic.ts | 228 ++++++++++-------- 3 files changed, 190 insertions(+), 100 deletions(-) diff --git a/packages/flow-client/src/app/redux/modules/api/flow.api.ts b/packages/flow-client/src/app/redux/modules/api/flow.api.ts index 4cc4bcd..0156a25 100644 --- a/packages/flow-client/src/app/redux/modules/api/flow.api.ts +++ b/packages/flow-client/src/app/redux/modules/api/flow.api.ts @@ -1,9 +1,57 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import environment from '../../../../environment'; -// Type for Node-RED flows response/request +// Base type for common properties +export interface NodeRedBase { + id: string; + type: string; + info: string; + env: { name: string; type: string; value: string }[]; +} + +// Type for regular Node-RED flows +export interface NodeRedFlow extends NodeRedBase { + type: 'tab'; + label: string; + disabled: boolean; +} + +// Type for Node-RED subflows +export interface NodeRedSubflow extends NodeRedBase { + type: 'subflow'; + name: string; + category: string; + color: string; + icon: string; + in: NodeRedEndpoint[]; + out: NodeRedEndpoint[]; +} + +// Type for nodes within flows or subflows +export interface NodeRedNode extends NodeRedBase { + name: string; + x: number; + y: number; + z: string; + wires: string[][]; + inputs?: number; + outputs?: number; + inputLabels?: string[]; + outputLabels?: string[]; + icon?: string; +} + +// Type for endpoints used in subflows (inputs and outputs) +export interface NodeRedEndpoint { + x: number; + y: number; + wires: { id: string; port?: number }[]; +} + +// Composite type for all Node-RED objects export interface NodeRedFlows { - flows: unknown[]; + rev?: string; + flows: NodeRedBase[]; } // Define a service using a base URL and expected endpoints for flows @@ -12,6 +60,11 @@ export const flowApi = createApi({ baseQuery: fetchBaseQuery({ baseUrl: environment.NODE_RED_API_ROOT, responseHandler: 'content-type', + prepareHeaders: headers => { + headers.set('Node-RED-API-Version', 'v2'); + headers.set('Node-RED-Deployment-Type', 'nodes'); + return headers; + }, }), tagTypes: ['Flow'], // For automatic cache invalidation and refetching endpoints: builder => ({ @@ -27,7 +80,7 @@ export const flowApi = createApi({ }), // Endpoint to update all flows updateFlows: builder.mutation({ - query: (flows) => ({ + query: flows => ({ url: 'flows', method: 'POST', headers: { diff --git a/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts b/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts index ea8e9dc..e2bdf61 100644 --- a/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts +++ b/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts @@ -9,12 +9,11 @@ import { selectFlowEntityById, selectSubflowInOutByFlowId, selectSubflowInstancesByFlowId, - FlowEntity, } from './flow.slice'; import { GraphLogic } from './graph.logic'; import { NodeLogic } from './node.logic'; -import { TreeLogic } from './tree.logic'; import { RedLogic } from './red.logic'; +import { TreeLogic } from './tree.logic'; // checks if a given property has changed const objectHasChange = ( diff --git a/packages/flow-client/src/app/redux/modules/flow/red.logic.ts b/packages/flow-client/src/app/redux/modules/flow/red.logic.ts index 54331c3..56ba88a 100644 --- a/packages/flow-client/src/app/redux/modules/flow/red.logic.ts +++ b/packages/flow-client/src/app/redux/modules/flow/red.logic.ts @@ -1,5 +1,11 @@ import { RootState } from '../../store'; -import { NodeRedFlows } from '../api/flow.api'; +import { + NodeRedBase, + NodeRedFlow, + NodeRedFlows, + NodeRedNode, + NodeRedSubflow, +} from '../api/flow.api'; import { FlowEntity, FlowNodeEntity, @@ -8,10 +14,22 @@ import { selectFlowNodesByFlowId, } from './flow.slice'; +// Define a more specific type for flowMeta +interface FlowMeta { + id: string; + extraData: Record; + nodes: Array<{ id: string; extraData: Record }>; +} + +interface NodeMeta { + id: string; + extraData: Record; +} + export class RedLogic { - private convertNodeToNodeRed(node: FlowNodeEntity): unknown { + private convertNodeToNodeRed(node: FlowNodeEntity): NodeRedNode { // Convert our node format to Node-RED format - const nodeRedNode = { + const nodeRedNode: NodeRedNode = { id: node.id, type: node.type, name: node.name, @@ -24,24 +42,21 @@ export class RedLogic { inputLabels: node.inputLabels, outputLabels: node.outputLabels, icon: node.icon, - info: node.info, + info: node.info ?? '', + env: [], }; - // Remove undefined properties - return Object.fromEntries( - Object.entries(nodeRedNode).filter(([, v]) => v !== undefined) - ); + return nodeRedNode; } private convertFlowToNodeRed( - flow: FlowEntity | SubflowEntity, - state: RootState - ): unknown { + flow: FlowEntity | SubflowEntity + ): NodeRedBase { const baseFlow = { id: flow.id, - type: flow.type, + type: 'tab', label: flow.name, - disabled: flow.disabled, + disabled: false, info: flow.info, }; @@ -52,11 +67,23 @@ export class RedLogic { category: flow.category, color: flow.color, icon: flow.icon, - in: flow.in, - out: flow.out, + in: + flow.in?.map(endpoint => ({ + id: endpoint, + x: 0, + y: 0, + wires: [], + })) ?? [], + out: + flow.out?.map(endpoint => ({ + id: endpoint, + x: 0, + y: 0, + wires: [], + })) ?? [], env: flow.env, meta: {}, - }; + } as NodeRedSubflow; } return { @@ -65,23 +92,23 @@ export class RedLogic { }; } - private createMetadataFlow(state: RootState): unknown { + private createMetadataFlow(state: RootState): NodeRedFlow { const flows = selectAllFlowEntities(state); const metadata = flows.map(flow => ({ id: flow.id, extraData: flow, nodes: selectFlowNodesByFlowId(state, flow.id).map(node => ({ id: node.id, - extraData: node - })) + extraData: node, + })), })); return { - id: "aed83478cb340859", - type: "tab", - label: "FLOW-CLIENT:::METADATA::ROOT", + id: 'aed83478cb340859', + type: 'tab', + label: 'FLOW-CLIENT:::METADATA::ROOT', disabled: true, info: JSON.stringify(metadata), - env: [] + env: [], }; } @@ -90,43 +117,54 @@ export class RedLogic { const flows = selectAllFlowEntities(state); // Convert each flow and its nodes to Node-RED format - const nodeRedFlows = flows.map(flow => { - const flowNodes = selectFlowNodesByFlowId(state, flow.id); - - return { - ...this.convertFlowToNodeRed(flow, state), - // Include nodes if this is not a subflow - ...(flow.type !== 'subflow' && { - nodes: flowNodes.map(node => - this.convertNodeToNodeRed(node) - ), - }), - }; - }).concat(this.createMetadataFlow(state)); + const nodeRedFlows = flows + .map(flow => { + const flowNodes = selectFlowNodesByFlowId(state, flow.id); + + return [ + this.convertFlowToNodeRed(flow), + ...flowNodes.map(node => this.convertNodeToNodeRed(node)), + ]; + }) + .flat() + .concat(this.createMetadataFlow(state)); return { flows: nodeRedFlows, + // TODO: Implement versioning + // rev: 'b03654ee1803134c42f82d4530f0ebf8', }; } private extractMetadata(nodeRedFlows: NodeRedFlows) { const metadataFlow = nodeRedFlows.flows.find( - flow => flow.type === 'tab' && flow.label === 'FLOW-CLIENT:::METADATA::ROOT' - ); + flow => + flow.type === 'tab' && + (flow as NodeRedFlow).label === 'FLOW-CLIENT:::METADATA::ROOT' + ) as NodeRedFlow; if (!metadataFlow?.info) { return {}; } const metadata = JSON.parse(metadataFlow.info as string); - return metadata.reduce((acc: Record, flowMeta: any) => { - acc[flowMeta.id] = { - flow: flowMeta.extraData, - nodes: flowMeta.nodes.reduce((nodeAcc: Record, nodeMeta: any) => { - nodeAcc[nodeMeta.id] = nodeMeta.extraData; - return nodeAcc; - }, {}) - }; - return acc; - }, {}); + return metadata.reduce( + (acc: Record, flowMeta: FlowMeta) => { + acc[flowMeta.id] = { + flow: flowMeta.extraData, + nodes: flowMeta.nodes.reduce( + ( + nodeAcc: Record, + nodeMeta: NodeMeta + ) => { + nodeAcc[nodeMeta.id] = nodeMeta.extraData; + return nodeAcc; + }, + {} + ), + }; + return acc; + }, + {} + ); } public fromNodeRed(nodeRedFlows: NodeRedFlows): { @@ -137,53 +175,53 @@ export class RedLogic { const flows: Array = []; const nodes: FlowNodeEntity[] = []; - nodeRedFlows.flows - .filter(flow => flow.label !== 'FLOW-CLIENT:::METADATA::ROOT') - .forEach(nodeRedFlow => { - const flowMetadata = metadata[nodeRedFlow.id as string]?.flow || {}; - - if (nodeRedFlow.type === 'subflow') { - flows.push({ - ...flowMetadata, - id: nodeRedFlow.id as string, - type: 'subflow', - name: nodeRedFlow.name as string, - info: nodeRedFlow.info as string || '', - category: nodeRedFlow.category as string, - color: nodeRedFlow.color as string, - icon: nodeRedFlow.icon as string, - in: nodeRedFlow.in as string[], - out: nodeRedFlow.out as string[], - env: nodeRedFlow.env as [] - } as SubflowEntity); - } else { - flows.push({ - ...flowMetadata, - id: nodeRedFlow.id as string, - type: 'flow', - name: nodeRedFlow.label as string, - disabled: nodeRedFlow.disabled as boolean, - info: nodeRedFlow.info as string || '', - env: nodeRedFlow.env as [] - } as FlowEntity); - } - - // If this is a regular flow with nodes, process them - if (nodeRedFlow.type !== 'subflow' && Array.isArray(nodeRedFlow.nodes)) { - nodeRedFlow.nodes.forEach(nodeRedNode => { - const nodeMetadata = metadata[nodeRedFlow.id as string]?.nodes?.[nodeRedNode.id as string] || {}; - nodes.push({ - ...nodeMetadata, - id: nodeRedNode.id as string, - type: nodeRedNode.type as string, - x: nodeRedNode.x as number, - y: nodeRedNode.y as number, - z: nodeRedFlow.id as string, - wires: nodeRedNode.wires as string[][], - } as FlowNodeEntity); - }); - } - }); + nodeRedFlows.flows.forEach(nodeRedObj => { + const flowMetadata = metadata[nodeRedObj.id as string]?.flow || {}; + + // Handle subflows + if (nodeRedObj.type === 'subflow') { + const nodeRedFlow = nodeRedObj as NodeRedSubflow; + flows.push({ + ...flowMetadata, + id: nodeRedFlow.id, + type: 'subflow', + name: nodeRedFlow.name || '', + info: nodeRedFlow.info || '', + category: nodeRedFlow.category || '', + color: nodeRedFlow.color || '', + icon: nodeRedFlow.icon || '', + in: nodeRedFlow.in || [], + out: nodeRedFlow.out || [], + env: nodeRedFlow.env || [], + } as SubflowEntity); + } + // Handle regular flows + else if (nodeRedObj.type === 'tab') { + const nodeRedFlow = nodeRedObj as NodeRedFlow; + flows.push({ + ...flowMetadata, + id: nodeRedFlow.id, + type: 'flow', + name: nodeRedFlow.label || '', + disabled: nodeRedFlow.disabled || false, + info: nodeRedFlow.info || '', + env: nodeRedFlow.env || [], + } as FlowEntity); + } else { + const nodeRedNode = nodeRedObj as NodeRedNode; + const nodeMetadata = + metadata[nodeRedNode.id]?.nodes?.[nodeRedNode.id] || {}; + nodes.push({ + ...nodeMetadata, + id: nodeRedNode.id, + type: nodeRedNode.type, + x: nodeRedNode.x, + y: nodeRedNode.y, + z: nodeRedNode.z, + wires: nodeRedNode.wires || [], + } as FlowNodeEntity); + } + }); return { flows,