diff --git a/packages/flow-client/src/app/redux/modules/api/flow.api.spec.ts b/packages/flow-client/src/app/redux/modules/api/flow.api.spec.ts new file mode 100644 index 0000000..70faa32 --- /dev/null +++ b/packages/flow-client/src/app/redux/modules/api/flow.api.spec.ts @@ -0,0 +1,390 @@ +import * as apiModule from '@reduxjs/toolkit/query/react'; +import { MockedFunction } from 'vitest'; +import { flowActions } from '../flow/flow.slice'; +import { extractEndpointQuery } from './test-util'; + +// Mock the createApi and fetchBaseQuery functions from RTK Query +vi.mock('@reduxjs/toolkit/query/react', () => { + const originalModule = vi.importActual('@reduxjs/toolkit/query/react'); + return { + ...originalModule, + createApi: vi.fn(() => ({ + useGetFlowsQuery: vi.fn(), + useGetFlowQuery: vi.fn(), + useCreateFlowMutation: vi.fn(), + useUpdateFlowMutation: vi.fn(), + useDeleteFlowMutation: vi.fn(), + })), + fetchBaseQuery: vi.fn(), + }; +}); + +const mockedCreateApi = apiModule.createApi as unknown as MockedFunction< + typeof apiModule.createApi +>; +const mockedBaseQuery = apiModule.fetchBaseQuery as unknown as MockedFunction< + typeof apiModule.fetchBaseQuery +>; + +describe('flowApi', () => { + const BASE_URL = 'https://www.example.com/api'; + + beforeEach(async () => { + vi.stubEnv('VITE_NODE_RED_API_ROOT', BASE_URL); + mockedCreateApi.mockClear(); + mockedBaseQuery.mockClear(); + vi.resetModules(); + await import('./flow.api'); + }); + + it('fetchBaseQuery is called with correct baseUrl', () => { + expect(mockedBaseQuery).toHaveBeenCalledWith({ + baseUrl: BASE_URL, + responseHandler: 'content-type', + }); + }); + + describe('getFlows()', () => { + it('query() configuration is correct', () => { + const { query } = extractEndpointQuery('getFlows'); + const queryConfig = query(); + expect(queryConfig).toEqual({ + url: 'flows', + headers: { + Accept: 'application/json', + }, + }); + }); + + it('transformResponse correctly transforms flow response', () => { + const { transformResponse } = extractEndpointQuery('getFlows'); + const mockResponse = [{ + id: 'flow1', + type: 'flow', + label: 'Test Flow', + disabled: false, + nodes: [], + }]; + + const result = transformResponse(mockResponse); + expect(result).toEqual([{ + id: 'flow1', + type: 'flow', + name: 'Test Flow', + disabled: false, + info: '', + env: [], + }]); + }); + + it('transformResponse correctly transforms subflow response', () => { + const { transformResponse } = extractEndpointQuery('getFlows'); + const mockResponse = [{ + id: 'subflow1', + type: 'subflow', + label: 'Test Subflow', + nodes: [], + }]; + + const result = transformResponse(mockResponse); + expect(result).toEqual([{ + id: 'subflow1', + type: 'subflow', + name: 'Test Subflow', + category: 'subflows', + color: '#ddaa99', + icon: 'node-red/subflow.svg', + env: [], + inputLabels: [], + outputLabels: [], + }]); + }); + + it('onQueryStarted updates Redux store with flows', async () => { + const dispatch = vi.fn(); + const flows = [{ + id: 'flow1', + type: 'flow', + name: 'Test Flow', + disabled: false, + info: '', + env: [], + }]; + const queryFulfilled = Promise.resolve({ data: flows }); + + const { onQueryStarted } = extractEndpointQuery('getFlows'); + await onQueryStarted(undefined, { dispatch, queryFulfilled }); + + expect(dispatch).toHaveBeenCalledWith( + flowActions.addFlowEntities(flows) + ); + }); + + it('onQueryStarted handles errors correctly', async () => { + const dispatch = vi.fn(); + const queryFulfilled = Promise.reject(new Error('API Error')); + + const { onQueryStarted } = extractEndpointQuery('getFlows'); + await onQueryStarted(undefined, { dispatch, queryFulfilled }); + + expect(dispatch).toHaveBeenCalledWith( + flowActions.setError('Error: API Error') + ); + }); + }); + + describe('getFlow()', () => { + it('query() configuration is correct', () => { + const { query } = extractEndpointQuery('getFlow'); + const queryConfig = query('flow1'); + expect(queryConfig).toEqual({ + url: 'flow/flow1', + headers: { + Accept: 'application/json', + }, + }); + }); + }); + + describe('createFlow()', () => { + it('mutation configuration is correct', () => { + const { query } = extractEndpointQuery('createFlow'); + const newFlow = { + type: 'flow', + name: 'New Flow', + }; + const queryConfig = query(newFlow); + expect(queryConfig).toEqual({ + url: 'flow', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: newFlow, + }); + }); + }); + + describe('updateFlow()', () => { + it('mutation configuration is correct', () => { + const { query } = extractEndpointQuery('updateFlow'); + const update = { + id: 'flow1', + changes: { name: 'Updated Flow' }, + }; + const queryConfig = query(update); + expect(queryConfig).toEqual({ + url: 'flow/flow1', + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: update.changes, + }); + }); + }); +}); + +Human: Review and analyze the following coding task 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 + + +Study and understand this file-by-file implementation plan for the task: + + +1. Create new file for flow API endpoints using RTK Query pattern +2. Add types for Flow API responses and requests +3. Implement getFlows query endpoint to fetch all flows +4. Implement getFlow query endpoint to fetch single flow by id +5. Implement createFlow mutation endpoint +6. Implement updateFlow mutation endpoint +7. Implement deleteFlow mutation endpoint +8. Add transformResponse handlers to convert API data to internal format +9. Add onQueryStarted handlers to update Redux store + + +1. Import flowApi from new flow.api.ts +2. Add flowApi.reducer to store configuration +3. Add flowApi.middleware to middleware configuration + + +1. Import flow API hooks from flow.api.ts +2. Update createNewFlow to use createFlow mutation +3. Update updateSubflow to use updateFlow mutation +4. Add error handling for API operations + + +1. Create test file following pattern from node.api.spec.ts +2. Add tests for all flow API endpoints +3. Add tests for transform functions +4. Add tests for Redux store integration + + +1. Add loading states for API operations +2. Add error handling states for API operations +3. Update types to match API response formats + + +1. Add any new API endpoint configurations needed for flow management + + + +Review these diffs that you have already completed for the implementation plan: + + +@@ -0,0 +1,147 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import environment from '../../../../environment'; +import { + FlowEntity, + FlowNodeEntity, + SubflowEntity, + flowActions, +} from '../flow/flow.slice'; + +// API Types +type FlowApiResponse = { + id: string; + type: 'flow' | 'subflow'; + label?: string; + info?: string; + disabled?: boolean; + nodes: Array<{ + id: string; + type: string; + name?: string; + [key: string]: unknown; + }>; + [key: string]: unknown; +}; + +type CreateFlowRequest = Partial & { + nodes?: FlowNodeEntity[]; +}; + +type UpdateFlowRequest = Partial & { + nodes?: FlowNodeEntity[]; +}; + +// Transform API response to internal format +const transformFlowResponse = (response: FlowApiResponse): FlowEntity | SubflowEntity => { + const { nodes, label, ...rest } = response; + + if (response.type === 'subflow') { + return { + ...rest, + name: label || '', + category: 'subflows', + color: '#ddaa99', + icon: 'node-red/subflow.svg', + env: [], + inputLabels: [], + outputLabels: [], + } as SubflowEntity; + } + + return { + ...rest, + name: label || '', + disabled: rest.disabled || false, + info: rest.info || '', + env: [], + } as FlowEntity; +}; + +// Create the API service +export const flowApi = createApi({ + reducerPath: 'flowApi', + baseQuery: fetchBaseQuery({ + baseUrl: environment.NODE_RED_API_ROOT, + responseHandler: 'content-type', + }), + tagTypes: ['Flow'], + endpoints: builder => ({ + // Get all flows + getFlows: builder.query, void>({ + query: () => ({ + url: 'flows', + headers: { + Accept: 'application/json', + }, + }), + transformResponse: (response: FlowApiResponse[]) => + response.map(transformFlowResponse), + providesTags: ['Flow'], + async onQueryStarted(_arg, { dispatch, queryFulfilled }) { + try { + const { data: flows } = await queryFulfilled; + dispatch(flowActions.addFlowEntities(flows)); + } catch (error) { + dispatch(flowActions.setError(error?.toString() || 'Failed to fetch flows')); + } + }, + }), + + // Get single flow by ID + getFlow: builder.query({ + query: (id) => ({ + url: `flow/${id}`, + headers: { + Accept: 'application/json', + }, + }), + transformResponse: transformFlowResponse, + providesTags: (_result, _error, id) => [{ type: 'Flow', id }], + }), + + // Create new flow + createFlow: builder.mutation({ + query: (flow) => ({ + url: 'flow', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: flow, + }), + transformResponse: transformFlowResponse, + invalidatesTags: ['Flow'], + }), + + // Update existing flow + updateFlow: builder.mutation({ + query: ({ id, changes }) => ({ + url: `flow/${id}`, + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: changes, + }), + transformResponse: transformFlowResponse, + invalidatesTags: (_result, _error, { id }) => [{ type: 'Flow', id }], + }), + + // Delete flow + deleteFlow: builder.mutation({ + query: (id) => ({ + url: `flow/${id}`, + method: 'DELETE', + }), + invalidatesTags: (_result, _error, id) => [{ type: 'Flow', id }], + }), + }), +}); + +export const { + useGetFlowsQuery, + useGetFlowQuery, + useCreateFlowMutation, + useUpdateFlowMutation, + useDeleteFlowMutation, +} = flowApi; 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..11db415 --- /dev/null +++ b/packages/flow-client/src/app/redux/modules/api/flow.api.ts @@ -0,0 +1,147 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import environment from '../../../../environment'; +import { + FlowEntity, + FlowNodeEntity, + SubflowEntity, + flowActions, +} from '../flow/flow.slice'; + +// API Types +type FlowApiResponse = { + id: string; + type: 'flow' | 'subflow'; + label?: string; + info?: string; + disabled?: boolean; + nodes: Array<{ + id: string; + type: string; + name?: string; + [key: string]: unknown; + }>; + [key: string]: unknown; +}; + +type CreateFlowRequest = Partial & { + nodes?: FlowNodeEntity[]; +}; + +type UpdateFlowRequest = Partial & { + nodes?: FlowNodeEntity[]; +}; + +// Transform API response to internal format +const transformFlowResponse = (response: FlowApiResponse): FlowEntity | SubflowEntity => { + const { nodes, label, ...rest } = response; + + if (response.type === 'subflow') { + return { + ...rest, + name: label || '', + category: 'subflows', + color: '#ddaa99', + icon: 'node-red/subflow.svg', + env: [], + inputLabels: [], + outputLabels: [], + } as SubflowEntity; + } + + return { + ...rest, + name: label || '', + disabled: rest.disabled || false, + info: rest.info || '', + env: [], + } as FlowEntity; +}; + +// Create the API service +export const flowApi = createApi({ + reducerPath: 'flowApi', + baseQuery: fetchBaseQuery({ + baseUrl: environment.NODE_RED_API_ROOT, + responseHandler: 'content-type', + }), + tagTypes: ['Flow'], + endpoints: builder => ({ + // Get all flows + getFlows: builder.query, void>({ + query: () => ({ + url: 'flows', + headers: { + Accept: 'application/json', + }, + }), + transformResponse: (response: FlowApiResponse[]) => + response.map(transformFlowResponse), + providesTags: ['Flow'], + async onQueryStarted(_arg, { dispatch, queryFulfilled }) { + try { + const { data: flows } = await queryFulfilled; + dispatch(flowActions.addFlowEntities(flows)); + } catch (error) { + dispatch(flowActions.setError(error?.toString() || 'Failed to fetch flows')); + } + }, + }), + + // Get single flow by ID + getFlow: builder.query({ + query: (id) => ({ + url: `flow/${id}`, + headers: { + Accept: 'application/json', + }, + }), + transformResponse: transformFlowResponse, + providesTags: (_result, _error, id) => [{ type: 'Flow', id }], + }), + + // Create new flow + createFlow: builder.mutation({ + query: (flow) => ({ + url: 'flow', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: flow, + }), + transformResponse: transformFlowResponse, + invalidatesTags: ['Flow'], + }), + + // Update existing flow + updateFlow: builder.mutation({ + query: ({ id, changes }) => ({ + url: `flow/${id}`, + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: changes, + }), + transformResponse: transformFlowResponse, + invalidatesTags: (_result, _error, { id }) => [{ type: 'Flow', id }], + }), + + // Delete flow + deleteFlow: builder.mutation({ + query: (id) => ({ + url: `flow/${id}`, + method: 'DELETE', + }), + invalidatesTags: (_result, _error, id) => [{ type: 'Flow', id }], + }), + }), +}); + +export const { + useGetFlowsQuery, + useGetFlowQuery, + useCreateFlowMutation, + useUpdateFlowMutation, + useDeleteFlowMutation, +} = 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..685de5f 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 @@ -1,5 +1,11 @@ import { v4 as uuidv4 } from 'uuid'; +import { + useCreateFlowMutation, + useUpdateFlowMutation, + useDeleteFlowMutation, +} from '../api/flow.api'; + import { AppDispatch, RootState } from '../../store'; import { builderActions, selectNewFlowCounter } from '../builder/builder.slice'; import { @@ -49,10 +55,10 @@ export class FlowLogic { open = true ) { return (dispatch: AppDispatch, getState: () => RootState) => { - const flowCounter = selectNewFlowCounter(getState()); - const flowId = id ?? uuidv4(); - dispatch( - flowActions.addFlowEntity({ + try { + const flowCounter = selectNewFlowCounter(getState()); + const flowId = id ?? uuidv4(); + const newFlow = { id: flowId, type: 'flow', name: `New Flow${flowCounter ? ` ${flowCounter}` : ''}`, @@ -60,11 +66,25 @@ export class FlowLogic { info: '', env: [], directory, - }) - ); - dispatch(builderActions.addNewFlow(flowId)); - if (open) { - dispatch(builderActions.setActiveFlow(flowId)); + }; + + // Create flow via API + const [createFlow] = useCreateFlowMutation(); + const result = await createFlow(newFlow).unwrap(); + + // Update local state + dispatch(flowActions.addFlowEntity(result)); + dispatch(builderActions.addNewFlow(flowId)); + + if (open) { + dispatch(builderActions.setActiveFlow(flowId)); + } + } catch (error) { + dispatch( + flowActions.setError( + error?.toString() || 'Failed to create new flow' + ) + ); } }; } @@ -247,39 +267,52 @@ export class FlowLogic { } public updateSubflow(subflowId: string, changes: Partial) { - return (dispatch: AppDispatch, getState: () => RootState) => { - const subflow = selectFlowEntityById( - getState(), - subflowId - ) as SubflowEntity; - const subflowInstances = selectSubflowInstancesByFlowId( - getState(), - subflow.id - ); - const subflowInOut = selectSubflowInOutByFlowId( - getState(), - subflow.id - ); + return async (dispatch: AppDispatch, getState: () => RootState) => { + try { + const subflow = selectFlowEntityById( + getState(), + subflowId + ) as SubflowEntity; + const subflowInstances = selectSubflowInstancesByFlowId( + getState(), + subflow.id + ); + const subflowInOut = selectSubflowInOutByFlowId( + getState(), + subflow.id + ); + + // Update flow via API + const [updateFlow] = useUpdateFlowMutation(); + await updateFlow({ id: subflowId, changes }).unwrap(); - [ - ...this.updateSubflowInstances( - subflowInstances, - subflow, - changes - ), - ...this.updateSubflowInOutNodes(subflowInOut, subflow, changes), - ].forEach(nodeChange => { + // Update local state for subflow instances and in/out nodes + [ + ...this.updateSubflowInstances( + subflowInstances, + subflow, + changes + ), + ...this.updateSubflowInOutNodes(subflowInOut, subflow, changes), + ].forEach(nodeChange => { + dispatch( + this.node.updateFlowNode(nodeChange.id, nodeChange.changes) + ); + }); + + dispatch( + flowActions.updateFlowEntity({ + id: subflow.id, + changes, + }) + ); + } catch (error) { dispatch( - this.node.updateFlowNode(nodeChange.id, nodeChange.changes) + flowActions.setError( + error?.toString() || 'Failed to update subflow' + ) ); - }); - - dispatch( - flowActions.updateFlowEntity({ - id: subflow.id, - changes, - }) - ); + } }; } diff --git a/packages/flow-client/src/app/redux/modules/flow/flow.slice.ts b/packages/flow-client/src/app/redux/modules/flow/flow.slice.ts index c3bbf60..17a804e 100644 --- a/packages/flow-client/src/app/redux/modules/flow/flow.slice.ts +++ b/packages/flow-client/src/app/redux/modules/flow/flow.slice.ts @@ -136,9 +136,27 @@ export interface DirectoryEntity { directory: string; } +export type ApiOperationStatus = { + getFlows: 'idle' | 'pending' | 'fulfilled' | 'rejected'; + getFlow: Record; + createFlow: 'idle' | 'pending' | 'fulfilled' | 'rejected'; + updateFlow: Record; + deleteFlow: Record; +}; + +export type ApiError = { message: string; code?: string; details?: unknown }; + export interface FlowState { loadingStatus: 'not loaded' | 'loading' | 'loaded' | 'error'; error?: string | null; + apiStatus: ApiOperationStatus; + apiErrors: { + getFlows?: ApiError; + getFlow?: Record; + createFlow?: ApiError; + updateFlow?: Record; + deleteFlow?: Record; + }; flowEntities: EntityState; flowNodes: EntityState; directories: EntityState; @@ -151,6 +169,18 @@ export const directoryAdapter = createEntityAdapter(); export const initialFlowState: FlowState = { loadingStatus: 'not loaded', error: null, + apiStatus: { + getFlows: 'idle', + getFlow: {}, + createFlow: 'idle', + updateFlow: {}, + deleteFlow: {}, + }, + apiErrors: { + getFlow: {}, + updateFlow: {}, + deleteFlow: {}, + }, flowEntities: flowAdapter.getInitialState(), flowNodes: nodeAdapter.getInitialState(), directories: directoryAdapter.getInitialState(), @@ -313,6 +343,36 @@ export const flowSlice = createSlice({ setError: (state, action: PayloadAction) => { state.error = action.payload; }, + setApiStatus: ( + state, + action: PayloadAction<{ + operation: keyof ApiOperationStatus; + id?: string; + status: 'idle' | 'pending' | 'fulfilled' | 'rejected'; + }> + ) => { + const { operation, id, status } = action.payload; + if (id && operation !== 'createFlow') { + (state.apiStatus[operation] as Record)[id] = status; + } else { + (state.apiStatus[operation] as string) = status; + } + }, + setApiError: ( + state, + action: PayloadAction<{ + operation: keyof ApiOperationStatus; + id?: string; + error: ApiError | undefined; + }> + ) => { + const { operation, id, error } = action.payload; + if (id && operation !== 'createFlow') { + (state.apiErrors[operation] as Record)[id] = error; + } else { + state.apiErrors[operation] = error; + } + }, }, }); @@ -388,3 +448,14 @@ export const selectSubflowInOutByFlowId = createSelector( [selectFlowNodesByFlowId], nodes => nodes.filter(node => ['in', 'out'].includes(node.type)) ); + +// API status selectors +export const selectApiStatus = (state: RootState) => state[FLOW_FEATURE_KEY].apiStatus; +export const selectApiErrors = (state: RootState) => state[FLOW_FEATURE_KEY].apiErrors; + +export const selectOperationStatus = (operation: keyof ApiOperationStatus, id?: string) => + createSelector(selectApiStatus, status => + id && operation !== 'createFlow' + ? (status[operation] as Record)[id] || 'idle' + : (status[operation] as string) + ); diff --git a/packages/flow-client/src/app/redux/store.ts b/packages/flow-client/src/app/redux/store.ts index cc644bc..40c51d9 100644 --- a/packages/flow-client/src/app/redux/store.ts +++ b/packages/flow-client/src/app/redux/store.ts @@ -6,6 +6,7 @@ import { PERSIST, persistReducer, PURGE, +import { flowApi } from './modules/api/flow.api'; REGISTER, REHYDRATE, } from 'redux-persist'; @@ -33,6 +34,7 @@ export const createStore = (logic: AppLogic) => { const store = configureStore({ reducer: { [nodeApi.reducerPath]: nodeApi.reducer, + [flowApi.reducerPath]: flowApi.reducer, [iconApi.reducerPath]: iconApi.reducer, [PALETTE_NODE_FEATURE_KEY]: paletteNodeReducer, [FLOW_FEATURE_KEY]: persistReducer( @@ -66,7 +68,7 @@ export const createStore = (logic: AppLogic) => { thunk: { extraArgument: logic, }, - }).concat(nodeApi.middleware, iconApi.middleware), + }).concat(nodeApi.middleware, iconApi.middleware, flowApi.middleware), devTools: process.env.NODE_ENV !== 'production', });