diff --git a/packages/flow-client/src/app/redux/modules/flow/flow.logic.spec.ts b/packages/flow-client/src/app/redux/modules/flow/flow.logic.spec.ts index a6e8022..e8acb87 100644 --- a/packages/flow-client/src/app/redux/modules/flow/flow.logic.spec.ts +++ b/packages/flow-client/src/app/redux/modules/flow/flow.logic.spec.ts @@ -1,1034 +1,29 @@ -import { MockedFunction } from 'vitest'; import '../../../../../vitest-esbuild-compat'; -import { RootState } from '../../store'; -import { - PaletteNodeEntity, - selectAllPaletteNodes, - selectPaletteNodeById, -} from '../palette/node.slice'; -import { - FlowLogic, - NodeModel, - SerializedGraph, - TreeDirectory, - TreeFile, -} from './flow.logic'; -import { - DirectoryEntity, - FlowEntity, - FlowNodeEntity, - SubflowEntity, - flowActions, - selectAllDirectories, - selectAllFlowEntities, - selectFlowEntityById, - selectFlowNodeById, - selectFlowNodesByFlowId, -} from './flow.slice'; -vi.mock('../palette/node.slice', async importOriginal => { - const originalModule = await importOriginal< - typeof import('../palette/node.slice') - >(); - return { - ...originalModule, - selectAllPaletteNodes: vi.fn(() => []), - selectPaletteNodeById: vi.fn(() => null), - }; -}); - -// Mock the selectFlowNodesByFlowId selector if used within the method -vi.mock('./flow.slice', async importOriginal => { - const originalModule = await importOriginal< - typeof import('./flow.slice') - >(); - return { - ...originalModule, - selectAllFlowEntities: vi.fn(() => []), - selectFlowNodesByFlowId: vi.fn(() => []), - selectFlowEntityById: vi.fn(() => null), - selectFlowNodeById: vi.fn(() => null), - selectAllDirectories: vi.fn(() => []), - }; -}); - -const mockDispatch = vi.fn(); -const mockGetState = vi.fn(() => ({})) as unknown as () => RootState; - -const mockedSelectAllPaletteNodes = selectAllPaletteNodes as MockedFunction< - typeof selectAllPaletteNodes ->; -const mockedSelectPaletteNodeById = selectPaletteNodeById as MockedFunction< - typeof selectPaletteNodeById ->; -const mockedSelectFlowEntityById = selectFlowEntityById as MockedFunction< - typeof selectFlowEntityById ->; -const mockedSelectFlowNodeById = selectFlowNodeById as MockedFunction< - typeof selectFlowNodeById ->; -const mockedSelectFlowNodesByFlowId = selectFlowNodesByFlowId as MockedFunction< - typeof selectFlowNodesByFlowId ->; -const mockedSelectAllFlowEntities = selectAllFlowEntities as MockedFunction< - typeof selectAllFlowEntities ->; -const mockedSelectAllDirectories = selectAllDirectories as MockedFunction< - typeof selectAllDirectories ->; +import { FlowLogic } from './flow.logic'; +import { GraphLogic } from './graph.logic'; +import { NodeLogic } from './node.logic'; +import { TreeLogic } from './tree.logic'; describe('flow.logic', () => { let flowLogic: FlowLogic; - const testNode = { - id: 'node1', - type: 'default', - x: 100, - y: 200, - ports: [], - name: 'Node 1', - color: 'rgb(0,192,255)', - extras: { - entity: { - type: 'nodeType', - id: 'node1', - nodeRedId: 'node1', - name: 'Node 1', - module: 'node-module', - version: '1.0.0', - }, - }, - locked: false, - selected: false, - } as NodeModel; beforeEach(() => { - // Reset mocks before each test - vi.clearAllMocks(); flowLogic = new FlowLogic(); }); - describe('getNodeInputsOutputs', () => { - const baseNodeProps = { - id: 'test-node', - nodeRedId: 'test-node', - module: 'module', - version: 'version', - name: 'name', - type: 'type', - }; - - it('should extract inputs and outputs with default labels when no custom labels are provided', () => { - const entity = { - ...baseNodeProps, - id: 'test-node', - }; - - const instance = { - inputs: 2, - outputs: 1, - } as FlowNodeEntity; - - const { inputs, outputs } = flowLogic.getNodeInputsOutputs( - instance, - entity - ); - - expect(inputs).toEqual(['Input 1', 'Input 2']); - expect(outputs).toEqual(['Output 1']); - }); - - it('should correctly deserialize and use custom input and output label functions', () => { - const entity = { - ...baseNodeProps, - id: 'test-node', - type: 'test-node', - definitionScript: ` - RED.nodes.registerType("test-node", { - inputLabels: function(index) { - return \`Custom Input \${index + 1}\`; - }, - outputLabels: function(index) { - return \`Custom Output \${index + 1}\`; - } - }); - `, - }; - - const instance = { - inputs: 2, - outputs: 2, - } as FlowNodeEntity; - - const { inputs, outputs } = flowLogic.getNodeInputsOutputs( - instance, - entity - ); - - expect(inputs).toEqual(['Custom Input 1', 'Custom Input 2']); - expect(outputs).toEqual(['Custom Output 1', 'Custom Output 2']); - }); - - it('should handle nodes without inputs or outputs', () => { - const node = { - ...baseNodeProps, - id: 'test-node', - }; - - const { inputs, outputs } = flowLogic.getNodeInputsOutputs( - {} as FlowNodeEntity, - node - ); - - expect(inputs).toEqual([]); - expect(outputs).toEqual([]); - }); + it('graph', () => { + const graph = flowLogic.graph; + expect(graph).toBeInstanceOf(GraphLogic); }); - describe('updateFlowFromSerializedGraph', () => { - it('correctly creates a flow from a serialized graph', async () => { - const serializedGraph = { - id: 'flow1', - offsetX: 0, - offsetY: 0, - zoom: 100, - gridSize: 20, - layers: [], - locked: false, - selected: false, - extras: {}, - // Other necessary properties for the test - }; - - await flowLogic.updateFlowFromSerializedGraph(serializedGraph)( - mockDispatch, - mockGetState - ); - - expect(mockDispatch).toHaveBeenCalledWith( - flowActions.upsertFlowEntity( - expect.objectContaining({ - id: 'flow1', - type: 'flow', - // Other properties as they should be in the action payload - }) - ) - ); - }); - - it('should not override existing flow properties with new ones', async () => { - const existingFlow = { - id: 'flow1', - name: 'Existing Flow Label', - type: 'flow', - extras: { detail: 'Existing details' }, - disabled: false, - info: '', - env: [], - } as FlowEntity; - - const serializedGraph = { - id: 'flow1', - extras: { detail: 'New details' }, - layers: [], - offsetX: 0, - offsetY: 0, - zoom: 100, - gridSize: 20, - locked: false, - selected: false, - } as SerializedGraph; - - mockedSelectFlowEntityById.mockReturnValue(existingFlow); - - await flowLogic.updateFlowFromSerializedGraph(serializedGraph)( - mockDispatch, - mockGetState - ); - - expect(mockDispatch).toHaveBeenCalledWith( - flowActions.upsertFlowEntity( - expect.objectContaining({ - id: 'flow1', - name: 'Existing Flow Label', // Ensure the label is not overridden - extras: { detail: 'Existing details' }, // Ensure extras are not overridden - }) - ) - ); - }); - - it('correctly creates a node from a serialized graph', async () => { - const serializedGraph = { - id: 'flow1', - offsetX: 0, - offsetY: 0, - zoom: 100, - gridSize: 20, - locked: false, - selected: false, - layers: [ - { - type: 'diagram-nodes' as const, - models: { - node1: testNode, - }, - id: 'layer1', - isSvg: false, - transformed: true, - locked: false, - selected: false, - extras: {}, - }, - ], - }; - - await flowLogic.updateFlowFromSerializedGraph(serializedGraph)( - mockDispatch, - mockGetState - ); - - expect(mockDispatch).toHaveBeenCalledWith( - flowActions.upsertFlowNodes( - expect.arrayContaining([ - expect.objectContaining({ - id: 'node1', - // Verify other properties as needed - }), - ]) - ) - ); - }); - - it('correctly adds wires based on the serialized graph', async () => { - const serializedGraph = { - id: 'flow1', - offsetX: 0, - offsetY: 0, - zoom: 100, - gridSize: 20, - layers: [], - locked: false, - selected: false, - } as SerializedGraph; - serializedGraph.layers.push( - { - id: 'layer1', - isSvg: false, - transformed: true, - type: 'diagram-nodes' as const, - locked: false, - selected: false, - extras: {}, - models: { - node1: { - ...testNode, - id: 'node1', - x: 100, - y: 200, - locked: false, - selected: false, - extras: { - entity: {} as PaletteNodeEntity, - }, - ports: [ - { - id: 'port1', - type: 'out', - x: 0, - y: 0, - name: 'Out', - alignment: 'right', - parentNode: 'node1', - links: ['link1'], - in: false, - extras: { - label: 'Output', - }, - }, - ], - }, - node2: { - ...testNode, - id: 'node2', - x: 400, - y: 200, - locked: false, - selected: false, - extras: { - entity: {} as PaletteNodeEntity, - }, - ports: [ - { - id: 'port2', - type: 'in', - x: 0, - y: 0, - name: 'In', - alignment: 'left', - parentNode: 'node2', - links: ['link1'], - in: true, - extras: { - label: 'Input', - }, - }, - ], - }, - }, - }, - { - id: 'layer2', - isSvg: false, - transformed: true, - type: 'diagram-links' as const, - locked: false, - selected: false, - extras: {}, - models: { - link1: { - id: 'link1', - type: 'default', - source: 'node1', - sourcePort: 'port1', - target: 'node2', - targetPort: 'port2', - points: [], - labels: [], - width: 3, - color: 'black', - curvyness: 50, - selectedColor: 'blue', - locked: false, - selected: false, - extras: {}, - }, - }, - } - ); - - await flowLogic.updateFlowFromSerializedGraph(serializedGraph)( - mockDispatch, - mockGetState - ); - - // Verify that the dispatch was called with actions that reflect the correct wiring - expect(mockDispatch).toHaveBeenCalledWith( - flowActions.upsertFlowNodes( - expect.arrayContaining([ - expect.objectContaining({ - id: 'node1', - wires: [['node2']], // node1 is connected to node2 - }), - expect.objectContaining({ - id: 'node2', - wires: [], // node2 has no outgoing connections - }), - ]) - ) - ); - }); + it('node', () => { + const node = flowLogic.node; + expect(node).toBeInstanceOf(NodeLogic); }); - describe('updateFlowNode', () => { - const testNodeEntity: PaletteNodeEntity = { - id: 'node1', - type: 'custom-node', - nodeRedId: 'node1', - name: 'Test Node', - module: 'test-module', - version: '1.0.0', - }; - - const numInputs = 1; - const numOutputs = 2; - - const testFlowNodeEntity: FlowNodeEntity = { - id: 'node1', - type: 'custom-node', - x: 100, - y: 200, - z: 'flow1', - name: 'Test Node', - wires: Array.from({ length: numOutputs }, () => []), // Assuming 1 output, no connections yet - inPorts: Array.from({ length: numInputs }, (_, i) => ({ - id: `in${i}`, - type: 'default', - x: 0, - y: 0, - name: `Input Port ${i}`, - alignment: 'left', - maximumLinks: 1, - connected: false, - parentNode: 'node1', - links: [], - in: true, - extras: { - label: `Input Port ${i}`, - }, - })), // 1 input port - outPorts: Array.from({ length: numOutputs }, (_, i) => ({ - id: `out${i}`, - type: 'default', - x: 0, - y: 0, - name: `Output Port ${i}`, - alignment: 'right', - maximumLinks: 1, - connected: false, - parentNode: 'node1', - links: [], - in: false, - extras: { - label: `Output Port ${i}`, - }, - })), // 1 output port - links: {}, - inputs: numInputs, - outputs: numOutputs, - }; - beforeEach(() => { - mockedSelectFlowNodeById.mockImplementation((state, id) => { - if (id === 'node1') { - return testFlowNodeEntity; - } - return null as unknown as FlowNodeEntity; - }); - - mockedSelectPaletteNodeById.mockImplementation((state, id) => { - if (id === 'custom-node') { - return testNodeEntity; - } - return null as unknown as PaletteNodeEntity; - }); - }); - - it('updates node inputs and outputs correctly', async () => { - const changes = { - inputs: 0, - outputs: 3, - }; - - await flowLogic.updateFlowNode('node1', changes)( - mockDispatch, - mockGetState - ); - - expect(mockDispatch).toHaveBeenCalledWith( - flowActions.updateFlowNode({ - id: 'node1', - changes: expect.objectContaining({ - inputs: 0, - outputs: 3, - // Additional checks for ports and wires if necessary - }), - }) - ); - }); - - it('handles changes in node outputs correctly', async () => { - const changes = { - outputs: '{"0": "-1", "1": "0"}', // Move output 1 to 0, remove output 0 - }; - - await flowLogic.updateFlowNode('node1', changes)( - mockDispatch, - mockGetState - ); - - expect(mockDispatch).toHaveBeenCalledWith( - flowActions.updateFlowNode({ - id: 'node1', - changes: expect.objectContaining({ - outputs: 1, - wires: [[]], - outPorts: expect.arrayContaining([ - expect.objectContaining({ - id: 'out1', - links: [], - }), - ]), - // Verify that the output ports and wires are correctly updated - }), - }) - ); - }); - - it('updates node labels based on inputs and outputs', async () => { - const changes = { - inputs: 1, - outputs: 1, - }; - - await flowLogic.updateFlowNode('node1', changes)( - mockDispatch, - mockGetState - ); - - // Assuming the getNodeInputsOutputs method generates labels "Input 1" and "Output 1" - expect(mockDispatch).toHaveBeenCalledWith( - flowActions.updateFlowNode({ - id: 'node1', - changes: expect.objectContaining({ - inPorts: expect.arrayContaining([ - expect.objectContaining({ - extras: expect.objectContaining({ - label: 'Input 1', - }), - }), - ]), - outPorts: expect.arrayContaining([ - expect.objectContaining({ - extras: expect.objectContaining({ - label: 'Output 1', - }), - }), - ]), - }), - }) - ); - }); - - it('removes all input ports when inputs set to 0', async () => { - const changes = { - inputs: 0, // Set inputs to 0, expecting all input ports to be removed - }; - - await flowLogic.updateFlowNode('node1', changes)( - mockDispatch, - mockGetState - ); - - expect(mockDispatch).toHaveBeenCalledWith( - flowActions.updateFlowNode({ - id: 'node1', - changes: expect.objectContaining({ - inPorts: [], // Expecting no input ports - }), - }) - ); - }); - }); - - describe('selectSerializedGraphByFlowId', () => { - it('returns null for non-existent flow', () => { - const result = flowLogic.selectSerializedGraphByFlowId.resultFunc( - {}, // Mock state - null as unknown as FlowEntity, // Mock flow (non-existent) - [] // Mock flowNodes - ); - - expect(result).toBeNull(); - }); - - it('correctly serializes a flow with nodes and links', () => { - const mockNodeEntity: PaletteNodeEntity = { - id: 'node2', - nodeRedId: 'node2', - name: 'Node 2', - type: 'custom-node', - module: 'node-module', - version: '1.0.0', - defaults: { - property1: { value: 'default1' }, - property2: { value: 42 }, - }, - inputs: 1, - outputs: 2, - color: 'rgb(255,0,0)', - icon: 'icon.png', - label: 'Node 2 Label', - labelStyle: 'node-label', - }; - - const mockFlow = { - id: 'flow1', - type: 'flow', - name: 'My Flow', - disabled: false, - info: '', - env: [], - } as FlowEntity; - - const mockNodes = [ - { - id: 'node1', - type: 'custom-node', - x: 100, - y: 200, - ports: [], - name: 'Node 1', - color: 'rgb(0,192,255)', - extras: { - entity: { - type: 'nodeType', - id: 'node1', - nodeRedId: 'node1', - name: 'Node 1', - module: 'node-module', - version: '1.0.0', - }, - config: {}, - }, - locked: false, - selected: false, - z: '123', - inputs: 1, - outputs: 1, - wires: [], - inPorts: [], - outPorts: [], - links: {}, - }, - ]; - - // Mock the selector responses - mockedSelectAllPaletteNodes.mockImplementation(() => [ - mockNodeEntity, - ]); - mockedSelectFlowEntityById.mockImplementation(() => mockFlow); - mockedSelectFlowNodesByFlowId.mockImplementation(() => mockNodes); - - const result = flowLogic.selectSerializedGraphByFlowId.resultFunc( - {}, // Mock state - mockFlow, // Mock flow - mockNodes // Mock flowNodes - ); - - expect(result).toEqual( - expect.objectContaining({ - id: 'flow1', - layers: expect.arrayContaining([ - expect.objectContaining({ - type: 'diagram-nodes', - models: expect.objectContaining({ - node1: expect.objectContaining({ - id: 'node1', - name: 'Node 1', - }), - }), - }), - expect.objectContaining({ - type: 'diagram-links', - // Test for links if necessary - }), - ]), - }) - ); - }); - }); - - describe('directoryIsDefault', () => { - it('should return true for default directories', () => { - const defaultFlowDirectory: TreeDirectory = { - id: 'flows', - name: 'Flows', - type: 'directory', - directory: '', - directoryPath: '', - children: [], - }; - const defaultSubflowDirectory: TreeDirectory = { - id: 'subflows', - name: 'Subflows', - type: 'directory', - directory: '', - directoryPath: '', - children: [], - }; - expect(flowLogic.directoryIsDefault(defaultFlowDirectory)).toBe( - true - ); - expect(flowLogic.directoryIsDefault(defaultSubflowDirectory)).toBe( - true - ); - }); - - it('should return false for non-default directories', () => { - const customDirectory: TreeDirectory = { - id: 'custom', - name: 'Custom', - type: 'directory', - directory: '', - directoryPath: '', - children: [], - }; - expect(flowLogic.directoryIsDefault(customDirectory)).toBe(false); - }); - }); - - describe('getFilePath', () => { - it('should return the correct file path for a given node ID', () => { - const treeFile: TreeFile = { - id: 'node123', - name: 'node123.json', - type: 'file', - directory: 'flows', - directoryPath: '/flows/nodes', - }; - const expectedPath = `/flows/nodes/node123.json`; - expect(flowLogic.getFilePath(treeFile)).toBe(expectedPath); - }); - - it('should handle undefined or null node IDs gracefully', () => { - const nullTreeItem: TreeFile = { - id: '', - name: '', - type: 'file', - directory: '', - directoryPath: '', - }; - expect(flowLogic.getFilePath(nullTreeItem)).toBe(''); - }); - }); - - describe('selectFlowTree', () => { - it('should construct a tree with custom directories', () => { - const customDirectories: DirectoryEntity[] = [ - { - id: 'custom1', - name: 'Custom Directory 1', - directory: '', - type: 'directory', - }, - { - id: 'custom2', - name: 'Custom Directory 2', - directory: '', - type: 'directory', - }, - ]; - const customFlows: FlowEntity[] = [ - { - id: 'flow3', - name: 'Custom Flow 1', - directory: 'custom1', - type: 'flow', - disabled: false, - info: '', - env: [], - }, - { - id: 'flow4', - name: 'Custom Flow 2', - directory: 'custom2', - type: 'flow', - disabled: false, - info: '', - env: [], - }, - ]; - const customSubflows: SubflowEntity[] = [ - { - id: 'subflow3', - name: 'Custom Subflow 1', - directory: 'custom1', - type: 'subflow', - info: '', - category: '', - env: [], - color: '', - }, - { - id: 'subflow4', - name: 'Custom Subflow 2', - directory: 'custom2', - type: 'subflow', - info: '', - category: '', - env: [], - color: '', - }, - ]; - - // Mock the selectors - mockedSelectAllDirectories.mockReturnValue(customDirectories); - mockedSelectAllFlowEntities.mockReturnValue( - (customFlows as (FlowEntity | SubflowEntity)[]).concat( - customSubflows - ) - ); - - const result = flowLogic.selectFlowTree(mockGetState()); - - expect(result).toEqual({ - tree: expect.arrayContaining([ - { - id: 'custom1', - name: 'Custom Directory 1', - type: 'directory', - directory: '', - directoryPath: '', - children: [ - { - id: 'flow3', - name: 'Custom Flow 1', - type: 'file', - directory: 'custom1', - directoryPath: '/Custom Directory 1', - }, - { - id: 'subflow3', - name: 'Custom Subflow 1', - type: 'file', - directory: 'custom1', - directoryPath: '/Custom Directory 1', - }, - ], - }, - { - id: 'custom2', - name: 'Custom Directory 2', - type: 'directory', - directory: '', - directoryPath: '', - children: [ - { - id: 'flow4', - name: 'Custom Flow 2', - type: 'file', - directory: 'custom2', - directoryPath: '/Custom Directory 2', - }, - { - id: 'subflow4', - name: 'Custom Subflow 2', - type: 'file', - directory: 'custom2', - directoryPath: '/Custom Directory 2', - }, - ], - }, - ]), - items: expect.any(Object), - }); - }); - - it('should ensure default directories are correctly created and populated with flows and subflows', () => { - const flows: FlowEntity[] = [ - { - id: 'flow1', - name: 'Main Flow', - directory: 'flows', - type: 'flow', - disabled: false, - info: '', - env: [], - }, - { - id: 'flow2', - name: 'Secondary Flow', - directory: 'flows', - type: 'flow', - disabled: false, - info: '', - env: [], - }, - ]; - const subflows: SubflowEntity[] = [ - { - id: 'subflow1', - name: 'Subflow A', - directory: 'subflows', - type: 'subflow', - info: '', - category: '', - env: [], - color: '', - }, - { - id: 'subflow2', - name: 'Subflow B', - directory: 'subflows', - type: 'subflow', - info: '', - category: '', - env: [], - color: '', - }, - ]; - - // Mock the selectors - mockedSelectAllDirectories.mockReturnValue([]); // No custom directories are provided - mockedSelectAllFlowEntities.mockReturnValue( - (flows as (FlowEntity | SubflowEntity)[]).concat(subflows) - ); - - const result = flowLogic.selectFlowTree(mockGetState()); - - expect(result.tree).toEqual([ - { - id: 'flows', - name: 'Flows', - type: 'directory', - directory: '', - directoryPath: '', - children: [ - { - id: 'flow1', - name: 'Main Flow', - type: 'file', - directory: 'flows', - directoryPath: '/Flows', - }, - { - id: 'flow2', - name: 'Secondary Flow', - type: 'file', - directory: 'flows', - directoryPath: '/Flows', - }, - ], - }, - { - id: 'subflows', - name: 'Subflows', - type: 'directory', - directory: '', - directoryPath: '', - children: [ - { - id: 'subflow1', - name: 'Subflow A', - type: 'file', - directory: 'subflows', - directoryPath: '/Subflows', - }, - { - id: 'subflow2', - name: 'Subflow B', - type: 'file', - directory: 'subflows', - directoryPath: '/Subflows', - }, - ], - }, - ]); - }); - - it('should handle empty directories correctly', () => { - // Mock empty responses - mockedSelectAllDirectories.mockReturnValue([]); - mockedSelectAllFlowEntities.mockReturnValue([]); - - const result = flowLogic.selectFlowTree(mockGetState()); - - expect(result.tree).toEqual([ - { - id: 'flows', - name: 'Flows', - type: 'directory', - directory: '', - directoryPath: '', - children: [], - }, - { - id: 'subflows', - name: 'Subflows', - type: 'directory', - directory: '', - directoryPath: '', - children: [], - }, - ]); - }); + it('tree', () => { + const tree = flowLogic.tree; + expect(tree).toBeInstanceOf(TreeLogic); }); }); 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 9c71ab2..55fa6cd 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,766 +1,15 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { v4 as uuidv4 } from 'uuid'; -import { PortModelAlignment } from '@projectstorm/react-diagrams'; - -import { executeNodeFn } from '../../../red/execute-script'; -import { AppDispatch, RootState } from '../../store'; -import { - PaletteNodeEntity, - selectAllPaletteNodes, - selectPaletteNodeById, -} from '../palette/node.slice'; -import { - DirectoryEntity, - FlowNodeEntity, - LinkModel, - PortModel, - flowActions, - selectAllDirectories, - selectAllFlowEntities, - selectFlowEntityById, - selectFlowNodeById, - selectFlowNodesByFlowId, -} from './flow.slice'; - -export type SerializedGraph = { - id: string; - offsetX: number; - offsetY: number; - zoom: number; - gridSize: number; - layers: Layer[]; - locked: boolean; -}; - -type BaseLayer = { - id: string; - isSvg: boolean; - transformed: boolean; - selected: boolean; - extras: Record; - locked: boolean; -}; - -export type NodeLayer = BaseLayer & { - type: 'diagram-nodes'; - models: { [key: string]: NodeModel }; -}; - -export type LinkLayer = BaseLayer & { - type: 'diagram-links'; - models: { [key: string]: LinkModel }; -}; - -export type Layer = NodeLayer | LinkLayer; - -export type NodeModel = { - id: string; - type: string; - x: number; - y: number; - ports: PortModel[]; - name: string; - color: string; - portsInOrder?: string[]; - portsOutOrder?: string[]; - locked: boolean; - selected: boolean; - extras: { - entity: PaletteNodeEntity; - [key: string]: unknown; - }; -}; - -type DirtyNodeChanges = Partial< - Omit & { - inputs: number | string; - outputs: number | string | null; - __outputs: number | null; - } ->; - -type TreeItem = { - id: string; - name: string; - directory: string; - directoryPath: string; -}; - -export type TreeDirectory = TreeItem & { - type: 'directory'; - children: TreeItemData[]; -}; - -export type TreeFile = TreeItem & { - type: 'file'; -}; - -export type TreeItemData = TreeDirectory | TreeFile; +import { GraphLogic } from './graph.logic'; +import { NodeLogic } from './node.logic'; +import { TreeLogic } from './tree.logic'; export class FlowLogic { - // Method to extract inputs and outputs from a NodeEntity, including deserializing inputLabels and outputLabels - getNodeInputsOutputs( - nodeInstance: FlowNodeEntity, - nodeEntity: PaletteNodeEntity - ): { - inputs: string[]; - outputs: string[]; - } { - const inputs: string[] = []; - const outputs: string[] = []; - - // Handle optional properties with defaults - const inputsCount = nodeInstance.inputs ?? 0; - const outputsCount = nodeInstance.outputs ?? 0; - - // Generate input and output labels using the deserialized functions - for (let i = 0; i < inputsCount; i++) { - inputs.push( - executeNodeFn<(index: number) => string>( - ['inputLabels', i], - nodeEntity, - nodeInstance - ) ?? `Input ${i + 1}` - ); - } - - for (let i = 0; i < outputsCount; i++) { - outputs.push( - executeNodeFn<(index: number) => string>( - ['outputLabels', i], - nodeEntity, - nodeInstance - ) ?? `Output ${i + 1}` - ); - } - - return { inputs, outputs }; - } - - // Method to convert and update the flow based on the serialized graph from react-diagrams - updateFlowFromSerializedGraph(graph: SerializedGraph) { - return async (dispatch: AppDispatch, getState: () => RootState) => { - // const graph = JSON.parse(serializedGraph) as SerializedGraph; - - // Assuming layers[1] contains nodes and layers[0] contains links - const nodeModels = - ( - graph.layers.find( - layer => layer.type === 'diagram-nodes' - ) as NodeLayer - )?.models || {}; - const linkModels = - ( - graph.layers.find( - layer => layer.type === 'diagram-links' - ) as LinkLayer - )?.models || {}; - const portModels = Object.fromEntries( - Object.values(nodeModels).flatMap(node => - node.ports.map(it => [it.id, it]) - ) - ); + public readonly graph: GraphLogic; + public readonly node: NodeLogic; + public readonly tree: TreeLogic; - // get existing flow entity or create new one - const flowEntity = selectFlowEntityById(getState(), graph.id) ?? { - id: graph.id, - type: 'flow', - name: 'My Flow', // Example label, could be dynamic - disabled: false, - info: '', // Additional info about the flow - env: [], // Environment variables or other settings - }; - - // Dispatch an action to add the flow entity to the Redux state - dispatch(flowActions.upsertFlowEntity(flowEntity)); - - // Step 1: Fetch current nodes of the flow - const currentNodes = selectFlowNodesByFlowId(getState(), graph.id); - - // Step 2: Identify nodes to remove - const updatedNodeIds = Object.values(nodeModels).map(it => it.id); - const nodesToRemove = currentNodes.filter( - node => !updatedNodeIds.includes(node.id) - ); - - // Step 3: Remove the identified nodes - dispatch( - flowActions.removeFlowNodes(nodesToRemove.map(it => it.id)) - ); - - // Map all links by their out port to organize connections from out -> in - const linksByOutPort = Object.values(linkModels) - .filter( - link => - link.sourcePort in portModels && - link.targetPort in portModels - ) - .reduce>((acc, link) => { - const outPortId = portModels[link.sourcePort].in - ? link.targetPort - : link.sourcePort; - const inPortId = portModels[link.sourcePort].in - ? link.sourcePort - : link.targetPort; - const inPortNode = portModels[inPortId].parentNode; - (acc[outPortId] = acc[outPortId] || []).push(inPortNode); - return acc; - }, {}); - - // Transform nodes, incorporating links data into the wires property based on out ports - const nodes = Object.values(nodeModels).map( - (node): FlowNodeEntity => { - // For each out port of the node - const wires: string[][] = []; - const outLinks: Record = {}; - node.ports - .filter(port => !port.in) // only look at out ports - .forEach(port => { - wires.push(linksByOutPort[port.id] || []); - port.links.forEach(linkId => { - outLinks[linkId] = linkModels[linkId]; - }); - }); - - return { - ...(node.extras.config as FlowNodeEntity), - id: node.id, - type: node.extras.entity.type, - x: node.x, - y: node.y, - z: graph.id, // Assuming all nodes belong to the same flow - name: node.name, - wires, - inPorts: node.ports.filter(it => it.in), - outPorts: node.ports.filter(it => !it.in), - links: outLinks, - selected: node.selected, - locked: node.locked, - }; - } - ); - - // Dispatch an action to add the transformed nodes to the Redux state - dispatch(flowActions.upsertFlowNodes(nodes)); - }; - } - - private parseNodeOutputs( - changes: DirtyNodeChanges, - nodeInstance: FlowNodeEntity - ): { - outputs?: number; - outputMap?: Record; - } { - const parseNumber = (value: string | number) => { - try { - const number = parseInt(value.toString()); - return isNaN(number) ? null : number; - } catch (e) { - return null; - } - }; - - // Create a new index for our output map, using the algorithm that - // Node-RED's switch node uses - const createNewIndex = () => { - return `${Math.floor((0x99999 - 0x10000) * Math.random())}`; - }; - - // if no outputs, return nothing - if ( - typeof changes.outputs == 'undefined' || - changes.outputs === null || - changes.outputs?.toString().trim() === '' - ) { - return {}; - } - - // if we were just given a number - const outputs = parseNumber(changes.outputs); - if (outputs !== null) { - // get our existing number of outputs - const oldOutputs = nodeInstance.outputs ?? 0; - // if our number of outputs hasn't changed - if (outputs === oldOutputs) { - // just return our outputs - return { - outputs, - }; - } - // else, we either have more or fewer outputs - // we'll handle the addition/removal of ports by creating our own outputMap - const outputMap: Record = {}; - // if we have fewer outputs - if (outputs < oldOutputs) { - // truncate output ports and wires by marking excess as removed - for (let i = outputs; i < oldOutputs; i++) { - outputMap[`${i}`] = '-1'; // Marking index for removal - } - } - // else, if we have more outputs - else if (outputs > oldOutputs) { - // create new output ports and wires - for (let i = oldOutputs; i < outputs; i++) { - // a non-existent index indicates a new port - outputMap[createNewIndex()] = `${i}`; - } - } - // return our new outputs - return { - outputs, - outputMap, - }; - } - - // else, it's a map, parse it - const outputMap = JSON.parse(changes.outputs as string) as Record< - string, - string - >; - // count our outputs - let outputCount = 0; - // filter our output map - for (const [oldPort, newPort] of Object.entries(outputMap)) { - // ensure our value is a string - outputMap[oldPort] = `${newPort}`; - - // if our old port is not a number, that indicates a new output - if (parseNumber(oldPort) === null) { - // replace our non number port with a number port that still indicates a new output - outputMap[createNewIndex()] = newPort; - delete outputMap[oldPort]; - // our port is now definitely a number, so we can keep going - } - - // a value of -1 indicates the port will be removed - if (newPort === '-1') { - continue; - } - - // this definitely counts as an output - outputCount++; - - // if our port has not changed, then no updates are needed - if (oldPort === newPort) { - delete outputMap[oldPort]; - continue; - } - } - - return { - outputs: outputCount, - outputMap, - }; + constructor() { + this.graph = new GraphLogic(); + this.node = new NodeLogic(); + this.tree = new TreeLogic(); } - - private updateNodeInputsOutputs( - nodeInstance: FlowNodeEntity, - nodeEntity: PaletteNodeEntity, - changes: DirtyNodeChanges - ): Partial { - // build new changes - const newChanges = { - inputs: nodeInstance.inputs, - outputs: nodeInstance.outputs, - inPorts: nodeInstance.inPorts.map(it => ({ - ...it, - extras: { ...it.extras }, - })), - outPorts: nodeInstance.outPorts.map(it => ({ - ...it, - extras: { ...it.extras }, - })), - wires: [...(nodeInstance.wires ?? [])], - }; - - // parse node outputs property - const { outputs, outputMap } = this.parseNodeOutputs( - changes, - nodeInstance - ); - - // if we have new outputs - if (typeof outputs !== 'undefined' && outputs !== newChanges.outputs) { - // record them - newChanges.outputs = outputs; - } - - // handle the output map, if returned - if (outputMap) { - // build new ports and wires collection - const outPorts: PortModel[] = []; - const wires: string[][] = []; - // first, iterate over current wires (no-changes, removals, and movers) - newChanges.wires?.forEach((portWires, index) => { - const oldPort = index; - - // if we don't have this port in our map - if (!Object.prototype.hasOwnProperty.call(outputMap, oldPort)) { - // then it has not changed - wires[oldPort] = portWires; - outPorts[oldPort] = newChanges.outPorts[oldPort]; - return; - } - - // else, this is in our output map - const newPort = parseInt(outputMap[oldPort]); - - // if this port is being removed - if (newPort === -1) { - // simply don't add it - return; - } - - // else, it must be being moved - wires[newPort] = portWires; - outPorts[newPort] = newChanges.outPorts[oldPort]; - }); - // now, iterate over our output map and add new wires - for (const [oldPort, newPort] of Object.entries(outputMap)) { - // if this port already exists, skip it - if (newChanges.wires?.[parseInt(oldPort)]) { - continue; - } - // else, it is new - wires[parseInt(newPort)] = []; - outPorts[parseInt(newPort)] = { - id: uuidv4(), - type: 'default', - x: 0, - y: 0, - name: uuidv4(), - alignment: PortModelAlignment.RIGHT, - parentNode: nodeInstance.id, - links: [], - in: false, - extras: { - label: `Output ${parseInt(newPort) + 1}`, - }, - }; - } - // update changes, replace out ports and wires - newChanges.outPorts = outPorts.filter(it => it); - newChanges.wires = wires.filter(it => it); - // TODO: Remove old links from nodeInstance.links (if necessary) - } - - let inputs = newChanges.inputs; - // parse new inputs - if (Object.prototype.hasOwnProperty.call(changes, 'inputs')) { - inputs = - typeof changes.inputs === 'string' - ? parseInt(changes.inputs) - : (changes.inputs as number); - } - // normalize inputs - inputs = Math.min(1, Math.max(0, inputs ?? 0)); - if (isNaN(inputs)) { - inputs = 0; - } - // if we have new inputs - if (inputs !== newChanges.inputs) { - // record them - newChanges.inputs = inputs; - } - if (inputs === 0) { - // remove all input nodes - newChanges.inPorts = []; - // TODO: Remove links from the source port and node (if necessary) - } - - // update port labels - const portLabels = this.getNodeInputsOutputs( - { ...nodeInstance, ...changes, ...newChanges }, - nodeEntity - ); - newChanges.inPorts.forEach((port, index) => { - const label = portLabels.inputs[index]; - port.extras.label = label; - }); - newChanges.outPorts.forEach((port, index) => { - const label = portLabels.outputs[index]; - port.extras.label = label; - }); - - return newChanges; - } - - updateFlowNode = (nodeId: string, changes: DirtyNodeChanges) => { - return async (dispatch: AppDispatch, getState: () => RootState) => { - // update node inputs and outputs - const nodeInstance = selectFlowNodeById( - getState(), - nodeId - ) as FlowNodeEntity; - const nodeEntity = selectPaletteNodeById( - getState(), - nodeInstance.type - ) as PaletteNodeEntity; - - const newChanges = { - ...changes, - ...this.updateNodeInputsOutputs( - nodeInstance, - nodeEntity, - changes - ), - } as Partial; - - dispatch( - flowActions.updateFlowNode({ id: nodeId, changes: newChanges }) - ); - }; - }; - - selectSerializedGraphByFlowId = createSelector( - [state => state, selectFlowEntityById, selectFlowNodesByFlowId], - (state, flow, flowNodes) => { - if (!flow) { - return null; - } - - const nodeEntities = Object.fromEntries( - selectAllPaletteNodes(state).map(it => [it.id, it]) - ); - - // Construct NodeModels from flow nodes - const nodeModels: { [key: string]: NodeModel } = {}; - // Infer LinkModels from node wires - const linkModels: { [key: string]: LinkModel } = {}; - - flowNodes.forEach(node => { - node.wires?.forEach((portWires, index) => { - const port = node.outPorts[index]; - - // let portLinks = outPort.links; - - // if (outPort.id === null) { - // outPort = { - // id: `${node.id}-out-${index}`, - // type: 'default', - // x: 0, - // y: 0, - // name: '', - // alignment: 'right', - // parentNode: node.id, - // links: [], - // in: false, - // label: `Output ${index + 1}`, - // }; - // node.ports?.splice(index, 1, outPort); - // } - - // const port = outPort as PortModel; - - portWires.forEach((targetNodeId, targetIndex) => { - const linkId = port.links[targetIndex]; - const nodeLink = node.links?.[linkId]; - if (nodeLink) { - linkModels[linkId] = nodeLink; - } - - // linkModels[linkId] = { - // id: linkId, - // type: 'default', // Placeholder value - // source: node.id, - // sourcePort: port.id, // Assuming a port naming convention - // target: targetNodeId, - // targetPort: `${targetNodeId}-in-${targetIndex}`, // This assumes you can determine the target port index - // points: [], // Points would need to be constructed if they are used - // labels: [], // Labels would need to be constructed if they are used - // width: 1, // Placeholder value - // color: 'defaultColor', // Placeholder value - // curvyness: 0, // Placeholder value - // selectedColor: 'defaultSelectedColor', // Placeholder value - // locked: false, - // selected: false, - // extras: {}, - // }; - }); - - // port.links = portLinks; - }); - - nodeModels[node.id] = { - // default values - locked: false, - selected: false, - color: 'defaultColor', - // flow node values - ...node, - // node model values - ports: [...node.inPorts, ...node.outPorts], - type: 'custom-node', - extras: { - entity: nodeEntities[node.type], - config: node, - }, - }; - }); - - // Assemble the SerializedGraph - const serializedGraph: SerializedGraph = { - id: flow.id, - offsetX: 0, // Placeholder value - offsetY: 0, // Placeholder value - zoom: 1, // Placeholder value - gridSize: 20, // Placeholder value - locked: false, - layers: [ - { - id: 'nodeLayer', - isSvg: false, - transformed: true, - type: 'diagram-nodes', - models: nodeModels, - locked: false, - selected: false, - extras: {}, - }, - { - id: 'linkLayer', - isSvg: true, - transformed: true, - type: 'diagram-links', - models: linkModels, - locked: false, - selected: false, - extras: {}, - }, - ], - }; - - return serializedGraph; - } - ); - - public directoryIsDefault(item: TreeDirectory) { - return ['flows', 'subflows'].includes(item.id); - } - - public getFilePath(item: TreeItemData) { - const parent = item.directoryPath ? `${item.directoryPath}` : ''; - return item.name ? `${parent}/${item.name}` : parent; - } - - private createTreeDirectory( - directory: DirectoryEntity, - defaultDirectory: string - ) { - return { - id: directory.id, - name: directory.name, - type: 'directory', - directory: directory.directory ?? defaultDirectory, - directoryPath: '', - children: [], - } as TreeDirectory; - } - - private addTreeDirectory( - treeItems: Record, - directories: DirectoryEntity[], - defaultDirectory: string, - directory: DirectoryEntity - ) { - // create item - const item = this.createTreeDirectory(directory, defaultDirectory); - // get the parent directory - let parent = treeItems[item.directory] as TreeDirectory; - if (!parent) { - const parentEntity = directories.find( - it => it.id === item.directory - ); - if (!parentEntity) { - throw new Error(`Directory ${item.directory} not found`); - } - parent = this.addTreeDirectory( - treeItems, - directories, - defaultDirectory, - parentEntity - ); - } - // update item - item.directoryPath = this.getFilePath(parent); - parent.children?.push(item); - treeItems[item.id] = item; - // return item - return item; - } - - selectFlowTree = createSelector( - [state => state, selectAllDirectories, selectAllFlowEntities], - (state, directories, flowEntities) => { - // collect tree hierarchy - const rootDirectory = { - id: '', - name: '', - type: 'directory', - directory: '', - directoryPath: '', - children: [], - } as TreeDirectory; - const flowsDirectory = { - id: 'flows', - name: 'Flows', - type: 'directory', - directory: rootDirectory.id, - directoryPath: '', - children: [], - } as TreeDirectory; - const subflowsDirectory = { - id: 'subflows', - name: 'Subflows', - type: 'directory', - directory: rootDirectory.id, - directoryPath: '', - children: [], - } as TreeDirectory; - rootDirectory.children?.push(flowsDirectory, subflowsDirectory); - const treeItems = { - [rootDirectory.id]: rootDirectory, - [flowsDirectory.id]: flowsDirectory, - [subflowsDirectory.id]: subflowsDirectory, - } as Record; - - // loop directories - directories.forEach(directory => { - // if we've already created it - if (treeItems[directory.id]) { - // nothing to do - return; - } - // else, create it - this.addTreeDirectory( - treeItems, - directories, - rootDirectory.id, - directory - ); - }); - - // loop flows and subflows - flowEntities.forEach(entity => { - const directoryId = - entity.directory ?? - (entity.type === 'flow' - ? flowsDirectory.id - : subflowsDirectory.id); - const directory = treeItems[directoryId] as TreeDirectory; - const item = { - id: entity.id, - name: entity.name, - type: 'file', - directory: directoryId, - directoryPath: `${directory.directoryPath}/${directory.name}`, - } as TreeFile; - directory.children.push(item); - treeItems[item.id] = item; - }); - - return { tree: rootDirectory.children, items: treeItems }; - } - ); } diff --git a/packages/flow-client/src/app/redux/modules/flow/graph.logic.spec.ts b/packages/flow-client/src/app/redux/modules/flow/graph.logic.spec.ts new file mode 100644 index 0000000..338b08d --- /dev/null +++ b/packages/flow-client/src/app/redux/modules/flow/graph.logic.spec.ts @@ -0,0 +1,436 @@ +import { MockedFunction } from 'vitest'; +import '../../../../../vitest-esbuild-compat'; + +import { RootState } from '../../store'; +import { + PaletteNodeEntity, + selectAllPaletteNodes, +} from '../palette/node.slice'; +import { + FlowEntity, + flowActions, + selectFlowEntityById, + selectFlowNodesByFlowId, +} from './flow.slice'; +import { GraphLogic, NodeModel, SerializedGraph } from './graph.logic'; + +vi.mock('../palette/node.slice', async importOriginal => { + const originalModule = await importOriginal< + typeof import('../palette/node.slice') + >(); + return { + ...originalModule, + selectAllPaletteNodes: vi.fn(() => []), + }; +}); + +// Mock the selectFlowNodesByFlowId selector if used within the method +vi.mock('./flow.slice', async importOriginal => { + const originalModule = await importOriginal< + typeof import('./flow.slice') + >(); + return { + ...originalModule, + + selectFlowNodesByFlowId: vi.fn(() => []), + selectFlowEntityById: vi.fn(() => null), + }; +}); + +const mockDispatch = vi.fn(); +const mockGetState = vi.fn(() => ({})) as unknown as () => RootState; + +const mockedSelectAllPaletteNodes = selectAllPaletteNodes as MockedFunction< + typeof selectAllPaletteNodes +>; + +const mockedSelectFlowEntityById = selectFlowEntityById as MockedFunction< + typeof selectFlowEntityById +>; + +const mockedSelectFlowNodesByFlowId = selectFlowNodesByFlowId as MockedFunction< + typeof selectFlowNodesByFlowId +>; + +describe('graph.logic', () => { + let graphLogic: GraphLogic; + const testNode = { + id: 'node1', + type: 'default', + x: 100, + y: 200, + ports: [], + name: 'Node 1', + color: 'rgb(0,192,255)', + extras: { + entity: { + type: 'nodeType', + id: 'node1', + nodeRedId: 'node1', + name: 'Node 1', + module: 'node-module', + version: '1.0.0', + }, + }, + locked: false, + selected: false, + } as NodeModel; + + beforeEach(() => { + // Reset mocks before each test + vi.clearAllMocks(); + graphLogic = new GraphLogic(); + }); + + describe('updateFlowFromSerializedGraph', () => { + it('correctly creates a flow from a serialized graph', async () => { + const serializedGraph = { + id: 'flow1', + offsetX: 0, + offsetY: 0, + zoom: 100, + gridSize: 20, + layers: [], + locked: false, + selected: false, + extras: {}, + // Other necessary properties for the test + }; + + await graphLogic.updateFlowFromSerializedGraph(serializedGraph)( + mockDispatch, + mockGetState + ); + + expect(mockDispatch).toHaveBeenCalledWith( + flowActions.upsertFlowEntity( + expect.objectContaining({ + id: 'flow1', + type: 'flow', + // Other properties as they should be in the action payload + }) + ) + ); + }); + + it('should not override existing flow properties with new ones', async () => { + const existingFlow = { + id: 'flow1', + name: 'Existing Flow Label', + type: 'flow', + extras: { detail: 'Existing details' }, + disabled: false, + info: '', + env: [], + } as FlowEntity; + + const serializedGraph = { + id: 'flow1', + extras: { detail: 'New details' }, + layers: [], + offsetX: 0, + offsetY: 0, + zoom: 100, + gridSize: 20, + locked: false, + selected: false, + } as SerializedGraph; + + mockedSelectFlowEntityById.mockReturnValue(existingFlow); + + await graphLogic.updateFlowFromSerializedGraph(serializedGraph)( + mockDispatch, + mockGetState + ); + + expect(mockDispatch).toHaveBeenCalledWith( + flowActions.upsertFlowEntity( + expect.objectContaining({ + id: 'flow1', + name: 'Existing Flow Label', // Ensure the label is not overridden + extras: { detail: 'Existing details' }, // Ensure extras are not overridden + }) + ) + ); + }); + + it('correctly creates a node from a serialized graph', async () => { + const serializedGraph = { + id: 'flow1', + offsetX: 0, + offsetY: 0, + zoom: 100, + gridSize: 20, + locked: false, + selected: false, + layers: [ + { + type: 'diagram-nodes' as const, + models: { + node1: testNode, + }, + id: 'layer1', + isSvg: false, + transformed: true, + locked: false, + selected: false, + extras: {}, + }, + ], + }; + + await graphLogic.updateFlowFromSerializedGraph(serializedGraph)( + mockDispatch, + mockGetState + ); + + expect(mockDispatch).toHaveBeenCalledWith( + flowActions.upsertFlowNodes( + expect.arrayContaining([ + expect.objectContaining({ + id: 'node1', + // Verify other properties as needed + }), + ]) + ) + ); + }); + + it('correctly adds wires based on the serialized graph', async () => { + const serializedGraph = { + id: 'flow1', + offsetX: 0, + offsetY: 0, + zoom: 100, + gridSize: 20, + layers: [], + locked: false, + selected: false, + } as SerializedGraph; + serializedGraph.layers.push( + { + id: 'layer1', + isSvg: false, + transformed: true, + type: 'diagram-nodes' as const, + locked: false, + selected: false, + extras: {}, + models: { + node1: { + ...testNode, + id: 'node1', + x: 100, + y: 200, + locked: false, + selected: false, + extras: { + entity: {} as PaletteNodeEntity, + }, + ports: [ + { + id: 'port1', + type: 'out', + x: 0, + y: 0, + name: 'Out', + alignment: 'right', + parentNode: 'node1', + links: ['link1'], + in: false, + extras: { + label: 'Output', + }, + }, + ], + }, + node2: { + ...testNode, + id: 'node2', + x: 400, + y: 200, + locked: false, + selected: false, + extras: { + entity: {} as PaletteNodeEntity, + }, + ports: [ + { + id: 'port2', + type: 'in', + x: 0, + y: 0, + name: 'In', + alignment: 'left', + parentNode: 'node2', + links: ['link1'], + in: true, + extras: { + label: 'Input', + }, + }, + ], + }, + }, + }, + { + id: 'layer2', + isSvg: false, + transformed: true, + type: 'diagram-links' as const, + locked: false, + selected: false, + extras: {}, + models: { + link1: { + id: 'link1', + type: 'default', + source: 'node1', + sourcePort: 'port1', + target: 'node2', + targetPort: 'port2', + points: [], + labels: [], + width: 3, + color: 'black', + curvyness: 50, + selectedColor: 'blue', + locked: false, + selected: false, + extras: {}, + }, + }, + } + ); + + await graphLogic.updateFlowFromSerializedGraph(serializedGraph)( + mockDispatch, + mockGetState + ); + + // Verify that the dispatch was called with actions that reflect the correct wiring + expect(mockDispatch).toHaveBeenCalledWith( + flowActions.upsertFlowNodes( + expect.arrayContaining([ + expect.objectContaining({ + id: 'node1', + wires: [['node2']], // node1 is connected to node2 + }), + expect.objectContaining({ + id: 'node2', + wires: [], // node2 has no outgoing connections + }), + ]) + ) + ); + }); + }); + + describe('selectSerializedGraphByFlowId', () => { + it('returns null for non-existent flow', () => { + const result = graphLogic.selectSerializedGraphByFlowId.resultFunc( + {}, // Mock state + null as unknown as FlowEntity, // Mock flow (non-existent) + [] // Mock flowNodes + ); + + expect(result).toBeNull(); + }); + + it('correctly serializes a flow with nodes and links', () => { + const mockNodeEntity: PaletteNodeEntity = { + id: 'node2', + nodeRedId: 'node2', + name: 'Node 2', + type: 'custom-node', + module: 'node-module', + version: '1.0.0', + defaults: { + property1: { value: 'default1' }, + property2: { value: 42 }, + }, + inputs: 1, + outputs: 2, + color: 'rgb(255,0,0)', + icon: 'icon.png', + label: 'Node 2 Label', + labelStyle: 'node-label', + }; + + const mockFlow = { + id: 'flow1', + type: 'flow', + name: 'My Flow', + disabled: false, + info: '', + env: [], + } as FlowEntity; + + const mockNodes = [ + { + id: 'node1', + type: 'custom-node', + x: 100, + y: 200, + ports: [], + name: 'Node 1', + color: 'rgb(0,192,255)', + extras: { + entity: { + type: 'nodeType', + id: 'node1', + nodeRedId: 'node1', + name: 'Node 1', + module: 'node-module', + version: '1.0.0', + }, + config: {}, + }, + locked: false, + selected: false, + z: '123', + inputs: 1, + outputs: 1, + wires: [], + inPorts: [], + outPorts: [], + links: {}, + }, + ]; + + // Mock the selector responses + mockedSelectAllPaletteNodes.mockImplementation(() => [ + mockNodeEntity, + ]); + mockedSelectFlowEntityById.mockImplementation(() => mockFlow); + mockedSelectFlowNodesByFlowId.mockImplementation(() => mockNodes); + + const result = graphLogic.selectSerializedGraphByFlowId.resultFunc( + {}, // Mock state + mockFlow, // Mock flow + mockNodes // Mock flowNodes + ); + + expect(result).toEqual( + expect.objectContaining({ + id: 'flow1', + layers: expect.arrayContaining([ + expect.objectContaining({ + type: 'diagram-nodes', + models: expect.objectContaining({ + node1: expect.objectContaining({ + id: 'node1', + name: 'Node 1', + }), + }), + }), + expect.objectContaining({ + type: 'diagram-links', + // Test for links if necessary + }), + ]), + }) + ); + }); + }); +}); diff --git a/packages/flow-client/src/app/redux/modules/flow/graph.logic.ts b/packages/flow-client/src/app/redux/modules/flow/graph.logic.ts new file mode 100644 index 0000000..8eb2023 --- /dev/null +++ b/packages/flow-client/src/app/redux/modules/flow/graph.logic.ts @@ -0,0 +1,296 @@ +import { createSelector } from '@reduxjs/toolkit'; + +import { AppDispatch, RootState } from '../../store'; +import { + PaletteNodeEntity, + selectAllPaletteNodes, +} from '../palette/node.slice'; +import { + FlowNodeEntity, + LinkModel, + PortModel, + flowActions, + selectFlowEntityById, + selectFlowNodesByFlowId, +} from './flow.slice'; + +export type SerializedGraph = { + id: string; + offsetX: number; + offsetY: number; + zoom: number; + gridSize: number; + layers: Layer[]; + locked: boolean; +}; + +type BaseLayer = { + id: string; + isSvg: boolean; + transformed: boolean; + selected: boolean; + extras: Record; + locked: boolean; +}; + +export type NodeLayer = BaseLayer & { + type: 'diagram-nodes'; + models: { [key: string]: NodeModel }; +}; + +export type LinkLayer = BaseLayer & { + type: 'diagram-links'; + models: { [key: string]: LinkModel }; +}; + +export type Layer = NodeLayer | LinkLayer; + +export type NodeModel = { + id: string; + type: string; + x: number; + y: number; + ports: PortModel[]; + name: string; + color: string; + portsInOrder?: string[]; + portsOutOrder?: string[]; + locked: boolean; + selected: boolean; + extras: { + entity: PaletteNodeEntity; + [key: string]: unknown; + }; +}; + +export class GraphLogic { + // Method to convert and update the flow based on the serialized graph from react-diagrams + updateFlowFromSerializedGraph(graph: SerializedGraph) { + return async (dispatch: AppDispatch, getState: () => RootState) => { + // const graph = JSON.parse(serializedGraph) as SerializedGraph; + + // Assuming layers[1] contains nodes and layers[0] contains links + const nodeModels = + ( + graph.layers.find( + layer => layer.type === 'diagram-nodes' + ) as NodeLayer + )?.models || {}; + const linkModels = + ( + graph.layers.find( + layer => layer.type === 'diagram-links' + ) as LinkLayer + )?.models || {}; + const portModels = Object.fromEntries( + Object.values(nodeModels).flatMap(node => + node.ports.map(it => [it.id, it]) + ) + ); + + // get existing flow entity or create new one + const flowEntity = selectFlowEntityById(getState(), graph.id) ?? { + id: graph.id, + type: 'flow', + name: 'My Flow', // Example label, could be dynamic + disabled: false, + info: '', // Additional info about the flow + env: [], // Environment variables or other settings + }; + + // Dispatch an action to add the flow entity to the Redux state + dispatch(flowActions.upsertFlowEntity(flowEntity)); + + // Step 1: Fetch current nodes of the flow + const currentNodes = selectFlowNodesByFlowId(getState(), graph.id); + + // Step 2: Identify nodes to remove + const updatedNodeIds = Object.values(nodeModels).map(it => it.id); + const nodesToRemove = currentNodes.filter( + node => !updatedNodeIds.includes(node.id) + ); + + // Step 3: Remove the identified nodes + dispatch( + flowActions.removeFlowNodes(nodesToRemove.map(it => it.id)) + ); + + // Map all links by their out port to organize connections from out -> in + const linksByOutPort = Object.values(linkModels) + .filter( + link => + link.sourcePort in portModels && + link.targetPort in portModels + ) + .reduce>((acc, link) => { + const outPortId = portModels[link.sourcePort].in + ? link.targetPort + : link.sourcePort; + const inPortId = portModels[link.sourcePort].in + ? link.sourcePort + : link.targetPort; + const inPortNode = portModels[inPortId].parentNode; + (acc[outPortId] = acc[outPortId] || []).push(inPortNode); + return acc; + }, {}); + + // Transform nodes, incorporating links data into the wires property based on out ports + const nodes = Object.values(nodeModels).map( + (node): FlowNodeEntity => { + // For each out port of the node + const wires: string[][] = []; + const outLinks: Record = {}; + node.ports + .filter(port => !port.in) // only look at out ports + .forEach(port => { + wires.push(linksByOutPort[port.id] || []); + port.links.forEach(linkId => { + outLinks[linkId] = linkModels[linkId]; + }); + }); + + return { + ...(node.extras.config as FlowNodeEntity), + id: node.id, + type: node.extras.entity.type, + x: node.x, + y: node.y, + z: graph.id, // Assuming all nodes belong to the same flow + name: node.name, + wires, + inPorts: node.ports.filter(it => it.in), + outPorts: node.ports.filter(it => !it.in), + links: outLinks, + selected: node.selected, + locked: node.locked, + }; + } + ); + + // Dispatch an action to add the transformed nodes to the Redux state + dispatch(flowActions.upsertFlowNodes(nodes)); + }; + } + + selectSerializedGraphByFlowId = createSelector( + [state => state, selectFlowEntityById, selectFlowNodesByFlowId], + (state, flow, flowNodes) => { + if (!flow) { + return null; + } + + const nodeEntities = Object.fromEntries( + selectAllPaletteNodes(state).map(it => [it.id, it]) + ); + + // Construct NodeModels from flow nodes + const nodeModels: { [key: string]: NodeModel } = {}; + // Infer LinkModels from node wires + const linkModels: { [key: string]: LinkModel } = {}; + + flowNodes.forEach(node => { + node.wires?.forEach((portWires, index) => { + const port = node.outPorts[index]; + + // let portLinks = outPort.links; + + // if (outPort.id === null) { + // outPort = { + // id: `${node.id}-out-${index}`, + // type: 'default', + // x: 0, + // y: 0, + // name: '', + // alignment: 'right', + // parentNode: node.id, + // links: [], + // in: false, + // label: `Output ${index + 1}`, + // }; + // node.ports?.splice(index, 1, outPort); + // } + + // const port = outPort as PortModel; + + portWires.forEach((targetNodeId, targetIndex) => { + const linkId = port.links[targetIndex]; + const nodeLink = node.links?.[linkId]; + if (nodeLink) { + linkModels[linkId] = nodeLink; + } + + // linkModels[linkId] = { + // id: linkId, + // type: 'default', // Placeholder value + // source: node.id, + // sourcePort: port.id, // Assuming a port naming convention + // target: targetNodeId, + // targetPort: `${targetNodeId}-in-${targetIndex}`, // This assumes you can determine the target port index + // points: [], // Points would need to be constructed if they are used + // labels: [], // Labels would need to be constructed if they are used + // width: 1, // Placeholder value + // color: 'defaultColor', // Placeholder value + // curvyness: 0, // Placeholder value + // selectedColor: 'defaultSelectedColor', // Placeholder value + // locked: false, + // selected: false, + // extras: {}, + // }; + }); + + // port.links = portLinks; + }); + + nodeModels[node.id] = { + // default values + locked: false, + selected: false, + color: 'defaultColor', + // flow node values + ...node, + // node model values + ports: [...node.inPorts, ...node.outPorts], + type: 'custom-node', + extras: { + entity: nodeEntities[node.type], + config: node, + }, + }; + }); + + // Assemble the SerializedGraph + const serializedGraph: SerializedGraph = { + id: flow.id, + offsetX: 0, // Placeholder value + offsetY: 0, // Placeholder value + zoom: 1, // Placeholder value + gridSize: 20, // Placeholder value + locked: false, + layers: [ + { + id: 'nodeLayer', + isSvg: false, + transformed: true, + type: 'diagram-nodes', + models: nodeModels, + locked: false, + selected: false, + extras: {}, + }, + { + id: 'linkLayer', + isSvg: true, + transformed: true, + type: 'diagram-links', + models: linkModels, + locked: false, + selected: false, + extras: {}, + }, + ], + }; + + return serializedGraph; + } + ); +} diff --git a/packages/flow-client/src/app/redux/modules/flow/node.logic.spec.ts b/packages/flow-client/src/app/redux/modules/flow/node.logic.spec.ts new file mode 100644 index 0000000..fa2074a --- /dev/null +++ b/packages/flow-client/src/app/redux/modules/flow/node.logic.spec.ts @@ -0,0 +1,311 @@ +import { MockedFunction } from 'vitest'; +import '../../../../../vitest-esbuild-compat'; + +import { RootState } from '../../store'; +import { + PaletteNodeEntity, + selectPaletteNodeById, +} from '../palette/node.slice'; +import { FlowNodeEntity, flowActions, selectFlowNodeById } from './flow.slice'; +import { NodeLogic } from './node.logic'; + +vi.mock('../palette/node.slice', async importOriginal => { + const originalModule = await importOriginal< + typeof import('../palette/node.slice') + >(); + return { + ...originalModule, + + selectPaletteNodeById: vi.fn(() => null), + }; +}); + +// Mock the selectFlowNodesByFlowId selector if used within the method +vi.mock('./flow.slice', async importOriginal => { + const originalModule = await importOriginal< + typeof import('./flow.slice') + >(); + return { + ...originalModule, + + selectFlowNodeById: vi.fn(() => null), + }; +}); + +const mockDispatch = vi.fn(); +const mockGetState = vi.fn(() => ({})) as unknown as () => RootState; + +const mockedSelectPaletteNodeById = selectPaletteNodeById as MockedFunction< + typeof selectPaletteNodeById +>; + +const mockedSelectFlowNodeById = selectFlowNodeById as MockedFunction< + typeof selectFlowNodeById +>; + +describe('node.logic', () => { + let nodeLogic: NodeLogic; + + beforeEach(() => { + // Reset mocks before each test + vi.clearAllMocks(); + nodeLogic = new NodeLogic(); + }); + + describe('getNodeInputsOutputs', () => { + const baseNodeProps = { + id: 'test-node', + nodeRedId: 'test-node', + module: 'module', + version: 'version', + name: 'name', + type: 'type', + }; + + it('should extract inputs and outputs with default labels when no custom labels are provided', () => { + const entity = { + ...baseNodeProps, + id: 'test-node', + }; + + const instance = { + inputs: 2, + outputs: 1, + } as FlowNodeEntity; + + const { inputs, outputs } = nodeLogic.getNodeInputsOutputs( + instance, + entity + ); + + expect(inputs).toEqual(['Input 1', 'Input 2']); + expect(outputs).toEqual(['Output 1']); + }); + + it('should correctly deserialize and use custom input and output label functions', () => { + const entity = { + ...baseNodeProps, + id: 'test-node', + type: 'test-node', + definitionScript: ` + RED.nodes.registerType("test-node", { + inputLabels: function(index) { + return \`Custom Input \${index + 1}\`; + }, + outputLabels: function(index) { + return \`Custom Output \${index + 1}\`; + } + }); + `, + }; + + const instance = { + inputs: 2, + outputs: 2, + } as FlowNodeEntity; + + const { inputs, outputs } = nodeLogic.getNodeInputsOutputs( + instance, + entity + ); + + expect(inputs).toEqual(['Custom Input 1', 'Custom Input 2']); + expect(outputs).toEqual(['Custom Output 1', 'Custom Output 2']); + }); + + it('should handle nodes without inputs or outputs', () => { + const node = { + ...baseNodeProps, + id: 'test-node', + }; + + const { inputs, outputs } = nodeLogic.getNodeInputsOutputs( + {} as FlowNodeEntity, + node + ); + + expect(inputs).toEqual([]); + expect(outputs).toEqual([]); + }); + }); + + describe('updateFlowNode', () => { + const testNodeEntity: PaletteNodeEntity = { + id: 'node1', + type: 'custom-node', + nodeRedId: 'node1', + name: 'Test Node', + module: 'test-module', + version: '1.0.0', + }; + + const numInputs = 1; + const numOutputs = 2; + + const testFlowNodeEntity: FlowNodeEntity = { + id: 'node1', + type: 'custom-node', + x: 100, + y: 200, + z: 'flow1', + name: 'Test Node', + wires: Array.from({ length: numOutputs }, () => []), // Assuming 1 output, no connections yet + inPorts: Array.from({ length: numInputs }, (_, i) => ({ + id: `in${i}`, + type: 'default', + x: 0, + y: 0, + name: `Input Port ${i}`, + alignment: 'left', + maximumLinks: 1, + connected: false, + parentNode: 'node1', + links: [], + in: true, + extras: { + label: `Input Port ${i}`, + }, + })), // 1 input port + outPorts: Array.from({ length: numOutputs }, (_, i) => ({ + id: `out${i}`, + type: 'default', + x: 0, + y: 0, + name: `Output Port ${i}`, + alignment: 'right', + maximumLinks: 1, + connected: false, + parentNode: 'node1', + links: [], + in: false, + extras: { + label: `Output Port ${i}`, + }, + })), // 1 output port + links: {}, + inputs: numInputs, + outputs: numOutputs, + }; + beforeEach(() => { + mockedSelectFlowNodeById.mockImplementation((state, id) => { + if (id === 'node1') { + return testFlowNodeEntity; + } + return null as unknown as FlowNodeEntity; + }); + + mockedSelectPaletteNodeById.mockImplementation((state, id) => { + if (id === 'custom-node') { + return testNodeEntity; + } + return null as unknown as PaletteNodeEntity; + }); + }); + + it('updates node inputs and outputs correctly', async () => { + const changes = { + inputs: 0, + outputs: 3, + }; + + await nodeLogic.updateFlowNode('node1', changes)( + mockDispatch, + mockGetState + ); + + expect(mockDispatch).toHaveBeenCalledWith( + flowActions.updateFlowNode({ + id: 'node1', + changes: expect.objectContaining({ + inputs: 0, + outputs: 3, + // Additional checks for ports and wires if necessary + }), + }) + ); + }); + + it('handles changes in node outputs correctly', async () => { + const changes = { + outputs: '{"0": "-1", "1": "0"}', // Move output 1 to 0, remove output 0 + }; + + await nodeLogic.updateFlowNode('node1', changes)( + mockDispatch, + mockGetState + ); + + expect(mockDispatch).toHaveBeenCalledWith( + flowActions.updateFlowNode({ + id: 'node1', + changes: expect.objectContaining({ + outputs: 1, + wires: [[]], + outPorts: expect.arrayContaining([ + expect.objectContaining({ + id: 'out1', + links: [], + }), + ]), + // Verify that the output ports and wires are correctly updated + }), + }) + ); + }); + + it('updates node labels based on inputs and outputs', async () => { + const changes = { + inputs: 1, + outputs: 1, + }; + + await nodeLogic.updateFlowNode('node1', changes)( + mockDispatch, + mockGetState + ); + + // Assuming the getNodeInputsOutputs method generates labels "Input 1" and "Output 1" + expect(mockDispatch).toHaveBeenCalledWith( + flowActions.updateFlowNode({ + id: 'node1', + changes: expect.objectContaining({ + inPorts: expect.arrayContaining([ + expect.objectContaining({ + extras: expect.objectContaining({ + label: 'Input 1', + }), + }), + ]), + outPorts: expect.arrayContaining([ + expect.objectContaining({ + extras: expect.objectContaining({ + label: 'Output 1', + }), + }), + ]), + }), + }) + ); + }); + + it('removes all input ports when inputs set to 0', async () => { + const changes = { + inputs: 0, // Set inputs to 0, expecting all input ports to be removed + }; + + await nodeLogic.updateFlowNode('node1', changes)( + mockDispatch, + mockGetState + ); + + expect(mockDispatch).toHaveBeenCalledWith( + flowActions.updateFlowNode({ + id: 'node1', + changes: expect.objectContaining({ + inPorts: [], // Expecting no input ports + }), + }) + ); + }); + }); +}); diff --git a/packages/flow-client/src/app/redux/modules/flow/node.logic.ts b/packages/flow-client/src/app/redux/modules/flow/node.logic.ts new file mode 100644 index 0000000..aa0f45f --- /dev/null +++ b/packages/flow-client/src/app/redux/modules/flow/node.logic.ts @@ -0,0 +1,332 @@ +import { v4 as uuidv4 } from 'uuid'; +import { PortModelAlignment } from '@projectstorm/react-diagrams'; + +import { executeNodeFn } from '../../../red/execute-script'; +import { + PaletteNodeEntity, + selectPaletteNodeById, +} from '../palette/node.slice'; +import { + FlowNodeEntity, + PortModel, + flowActions, + selectFlowNodeById, +} from './flow.slice'; +import { AppDispatch, RootState } from '../../store'; + +type DirtyNodeChanges = Partial< + Omit & { + inputs: number | string; + outputs: number | string | null; + __outputs: number | null; + } +>; + +export class NodeLogic { + // Method to extract inputs and outputs from a NodeEntity, including deserializing inputLabels and outputLabels + getNodeInputsOutputs( + nodeInstance: FlowNodeEntity, + nodeEntity: PaletteNodeEntity + ): { + inputs: string[]; + outputs: string[]; + } { + const inputs: string[] = []; + const outputs: string[] = []; + + // Handle optional properties with defaults + const inputsCount = nodeInstance.inputs ?? 0; + const outputsCount = nodeInstance.outputs ?? 0; + + // Generate input and output labels using the deserialized functions + for (let i = 0; i < inputsCount; i++) { + inputs.push( + executeNodeFn<(index: number) => string>( + ['inputLabels', i], + nodeEntity, + nodeInstance + ) ?? `Input ${i + 1}` + ); + } + + for (let i = 0; i < outputsCount; i++) { + outputs.push( + executeNodeFn<(index: number) => string>( + ['outputLabels', i], + nodeEntity, + nodeInstance + ) ?? `Output ${i + 1}` + ); + } + + return { inputs, outputs }; + } + + private parseNodeOutputs( + changes: DirtyNodeChanges, + nodeInstance: FlowNodeEntity + ): { + outputs?: number; + outputMap?: Record; + } { + const parseNumber = (value: string | number) => { + try { + const number = parseInt(value.toString()); + return isNaN(number) ? null : number; + } catch (e) { + return null; + } + }; + + // Create a new index for our output map, using the algorithm that + // Node-RED's switch node uses + const createNewIndex = () => { + return `${Math.floor((0x99999 - 0x10000) * Math.random())}`; + }; + + // if no outputs, return nothing + if ( + typeof changes.outputs == 'undefined' || + changes.outputs === null || + changes.outputs?.toString().trim() === '' + ) { + return {}; + } + + // if we were just given a number + const outputs = parseNumber(changes.outputs); + if (outputs !== null) { + // get our existing number of outputs + const oldOutputs = nodeInstance.outputs ?? 0; + // if our number of outputs hasn't changed + if (outputs === oldOutputs) { + // just return our outputs + return { + outputs, + }; + } + // else, we either have more or fewer outputs + // we'll handle the addition/removal of ports by creating our own outputMap + const outputMap: Record = {}; + // if we have fewer outputs + if (outputs < oldOutputs) { + // truncate output ports and wires by marking excess as removed + for (let i = outputs; i < oldOutputs; i++) { + outputMap[`${i}`] = '-1'; // Marking index for removal + } + } + // else, if we have more outputs + else if (outputs > oldOutputs) { + // create new output ports and wires + for (let i = oldOutputs; i < outputs; i++) { + // a non-existent index indicates a new port + outputMap[createNewIndex()] = `${i}`; + } + } + // return our new outputs + return { + outputs, + outputMap, + }; + } + + // else, it's a map, parse it + const outputMap = JSON.parse(changes.outputs as string) as Record< + string, + string + >; + // count our outputs + let outputCount = 0; + // filter our output map + for (const [oldPort, newPort] of Object.entries(outputMap)) { + // ensure our value is a string + outputMap[oldPort] = `${newPort}`; + + // if our old port is not a number, that indicates a new output + if (parseNumber(oldPort) === null) { + // replace our non number port with a number port that still indicates a new output + outputMap[createNewIndex()] = newPort; + delete outputMap[oldPort]; + // our port is now definitely a number, so we can keep going + } + + // a value of -1 indicates the port will be removed + if (newPort === '-1') { + continue; + } + + // this definitely counts as an output + outputCount++; + + // if our port has not changed, then no updates are needed + if (oldPort === newPort) { + delete outputMap[oldPort]; + continue; + } + } + + return { + outputs: outputCount, + outputMap, + }; + } + + private updateNodeInputsOutputs( + nodeInstance: FlowNodeEntity, + nodeEntity: PaletteNodeEntity, + changes: DirtyNodeChanges + ): Partial { + // build new changes + const newChanges = { + inputs: nodeInstance.inputs, + outputs: nodeInstance.outputs, + inPorts: nodeInstance.inPorts.map(it => ({ + ...it, + extras: { ...it.extras }, + })), + outPorts: nodeInstance.outPorts.map(it => ({ + ...it, + extras: { ...it.extras }, + })), + wires: [...(nodeInstance.wires ?? [])], + }; + + // parse node outputs property + const { outputs, outputMap } = this.parseNodeOutputs( + changes, + nodeInstance + ); + + // if we have new outputs + if (typeof outputs !== 'undefined' && outputs !== newChanges.outputs) { + // record them + newChanges.outputs = outputs; + } + + // handle the output map, if returned + if (outputMap) { + // build new ports and wires collection + const outPorts: PortModel[] = []; + const wires: string[][] = []; + // first, iterate over current wires (no-changes, removals, and movers) + newChanges.wires?.forEach((portWires, index) => { + const oldPort = index; + + // if we don't have this port in our map + if (!Object.prototype.hasOwnProperty.call(outputMap, oldPort)) { + // then it has not changed + wires[oldPort] = portWires; + outPorts[oldPort] = newChanges.outPorts[oldPort]; + return; + } + + // else, this is in our output map + const newPort = parseInt(outputMap[oldPort]); + + // if this port is being removed + if (newPort === -1) { + // simply don't add it + return; + } + + // else, it must be being moved + wires[newPort] = portWires; + outPorts[newPort] = newChanges.outPorts[oldPort]; + }); + // now, iterate over our output map and add new wires + for (const [oldPort, newPort] of Object.entries(outputMap)) { + // if this port already exists, skip it + if (newChanges.wires?.[parseInt(oldPort)]) { + continue; + } + // else, it is new + wires[parseInt(newPort)] = []; + outPorts[parseInt(newPort)] = { + id: uuidv4(), + type: 'default', + x: 0, + y: 0, + name: uuidv4(), + alignment: PortModelAlignment.RIGHT, + parentNode: nodeInstance.id, + links: [], + in: false, + extras: { + label: `Output ${parseInt(newPort) + 1}`, + }, + }; + } + // update changes, replace out ports and wires + newChanges.outPorts = outPorts.filter(it => it); + newChanges.wires = wires.filter(it => it); + // TODO: Remove old links from nodeInstance.links (if necessary) + } + + let inputs = newChanges.inputs; + // parse new inputs + if (Object.prototype.hasOwnProperty.call(changes, 'inputs')) { + inputs = + typeof changes.inputs === 'string' + ? parseInt(changes.inputs) + : (changes.inputs as number); + } + // normalize inputs + inputs = Math.min(1, Math.max(0, inputs ?? 0)); + if (isNaN(inputs)) { + inputs = 0; + } + // if we have new inputs + if (inputs !== newChanges.inputs) { + // record them + newChanges.inputs = inputs; + } + if (inputs === 0) { + // remove all input nodes + newChanges.inPorts = []; + // TODO: Remove links from the source port and node (if necessary) + } + + // update port labels + const portLabels = this.getNodeInputsOutputs( + { ...nodeInstance, ...changes, ...newChanges }, + nodeEntity + ); + newChanges.inPorts.forEach((port, index) => { + const label = portLabels.inputs[index]; + port.extras.label = label; + }); + newChanges.outPorts.forEach((port, index) => { + const label = portLabels.outputs[index]; + port.extras.label = label; + }); + + return newChanges; + } + + updateFlowNode = (nodeId: string, changes: DirtyNodeChanges) => { + return async (dispatch: AppDispatch, getState: () => RootState) => { + // update node inputs and outputs + const nodeInstance = selectFlowNodeById( + getState(), + nodeId + ) as FlowNodeEntity; + const nodeEntity = selectPaletteNodeById( + getState(), + nodeInstance.type + ) as PaletteNodeEntity; + + const newChanges = { + ...changes, + ...this.updateNodeInputsOutputs( + nodeInstance, + nodeEntity, + changes + ), + } as Partial; + + dispatch( + flowActions.updateFlowNode({ id: nodeId, changes: newChanges }) + ); + }; + }; +} diff --git a/packages/flow-client/src/app/redux/modules/flow/tree.logic.spec.ts b/packages/flow-client/src/app/redux/modules/flow/tree.logic.spec.ts new file mode 100644 index 0000000..01345e2 --- /dev/null +++ b/packages/flow-client/src/app/redux/modules/flow/tree.logic.spec.ts @@ -0,0 +1,359 @@ +import { MockedFunction } from 'vitest'; +import '../../../../../vitest-esbuild-compat'; + +import { RootState } from '../../store'; +import { + DirectoryEntity, + FlowEntity, + SubflowEntity, + selectAllDirectories, + selectAllFlowEntities, +} from './flow.slice'; +import { TreeDirectory, TreeFile, TreeLogic } from './tree.logic'; + +// Mock the selectFlowNodesByFlowId selector if used within the method +vi.mock('./flow.slice', async importOriginal => { + const originalModule = await importOriginal< + typeof import('./flow.slice') + >(); + return { + ...originalModule, + selectAllFlowEntities: vi.fn(() => []), + selectAllDirectories: vi.fn(() => []), + }; +}); + +const mockGetState = vi.fn(() => ({})) as unknown as () => RootState; + +const mockedSelectAllFlowEntities = selectAllFlowEntities as MockedFunction< + typeof selectAllFlowEntities +>; +const mockedSelectAllDirectories = selectAllDirectories as MockedFunction< + typeof selectAllDirectories +>; + +describe('tree.logic', () => { + let treeLogic: TreeLogic; + + beforeEach(() => { + // Reset mocks before each test + vi.clearAllMocks(); + treeLogic = new TreeLogic(); + }); + + describe('directoryIsDefault', () => { + it('should return true for default directories', () => { + const defaultFlowDirectory: TreeDirectory = { + id: 'flows', + name: 'Flows', + type: 'directory', + directory: '', + directoryPath: '', + children: [], + }; + const defaultSubflowDirectory: TreeDirectory = { + id: 'subflows', + name: 'Subflows', + type: 'directory', + directory: '', + directoryPath: '', + children: [], + }; + expect(treeLogic.directoryIsDefault(defaultFlowDirectory)).toBe( + true + ); + expect(treeLogic.directoryIsDefault(defaultSubflowDirectory)).toBe( + true + ); + }); + + it('should return false for non-default directories', () => { + const customDirectory: TreeDirectory = { + id: 'custom', + name: 'Custom', + type: 'directory', + directory: '', + directoryPath: '', + children: [], + }; + expect(treeLogic.directoryIsDefault(customDirectory)).toBe(false); + }); + }); + + describe('getFilePath', () => { + it('should return the correct file path for a given node ID', () => { + const treeFile: TreeFile = { + id: 'node123', + name: 'node123.json', + type: 'file', + directory: 'flows', + directoryPath: '/flows/nodes', + }; + const expectedPath = `/flows/nodes/node123.json`; + expect(treeLogic.getFilePath(treeFile)).toBe(expectedPath); + }); + + it('should handle undefined or null node IDs gracefully', () => { + const nullTreeItem: TreeFile = { + id: '', + name: '', + type: 'file', + directory: '', + directoryPath: '', + }; + expect(treeLogic.getFilePath(nullTreeItem)).toBe(''); + }); + }); + + describe('selectFlowTree', () => { + it('should construct a tree with custom directories', () => { + const customDirectories: DirectoryEntity[] = [ + { + id: 'custom1', + name: 'Custom Directory 1', + directory: '', + type: 'directory', + }, + { + id: 'custom2', + name: 'Custom Directory 2', + directory: '', + type: 'directory', + }, + ]; + const customFlows: FlowEntity[] = [ + { + id: 'flow3', + name: 'Custom Flow 1', + directory: 'custom1', + type: 'flow', + disabled: false, + info: '', + env: [], + }, + { + id: 'flow4', + name: 'Custom Flow 2', + directory: 'custom2', + type: 'flow', + disabled: false, + info: '', + env: [], + }, + ]; + const customSubflows: SubflowEntity[] = [ + { + id: 'subflow3', + name: 'Custom Subflow 1', + directory: 'custom1', + type: 'subflow', + info: '', + category: '', + env: [], + color: '', + }, + { + id: 'subflow4', + name: 'Custom Subflow 2', + directory: 'custom2', + type: 'subflow', + info: '', + category: '', + env: [], + color: '', + }, + ]; + + // Mock the selectors + mockedSelectAllDirectories.mockReturnValue(customDirectories); + mockedSelectAllFlowEntities.mockReturnValue( + (customFlows as (FlowEntity | SubflowEntity)[]).concat( + customSubflows + ) + ); + + const result = treeLogic.selectFlowTree(mockGetState()); + + expect(result).toEqual({ + tree: expect.arrayContaining([ + { + id: 'custom1', + name: 'Custom Directory 1', + type: 'directory', + directory: '', + directoryPath: '', + children: [ + { + id: 'flow3', + name: 'Custom Flow 1', + type: 'file', + directory: 'custom1', + directoryPath: '/Custom Directory 1', + }, + { + id: 'subflow3', + name: 'Custom Subflow 1', + type: 'file', + directory: 'custom1', + directoryPath: '/Custom Directory 1', + }, + ], + }, + { + id: 'custom2', + name: 'Custom Directory 2', + type: 'directory', + directory: '', + directoryPath: '', + children: [ + { + id: 'flow4', + name: 'Custom Flow 2', + type: 'file', + directory: 'custom2', + directoryPath: '/Custom Directory 2', + }, + { + id: 'subflow4', + name: 'Custom Subflow 2', + type: 'file', + directory: 'custom2', + directoryPath: '/Custom Directory 2', + }, + ], + }, + ]), + items: expect.any(Object), + }); + }); + + it('should ensure default directories are correctly created and populated with flows and subflows', () => { + const flows: FlowEntity[] = [ + { + id: 'flow1', + name: 'Main Flow', + directory: 'flows', + type: 'flow', + disabled: false, + info: '', + env: [], + }, + { + id: 'flow2', + name: 'Secondary Flow', + directory: 'flows', + type: 'flow', + disabled: false, + info: '', + env: [], + }, + ]; + const subflows: SubflowEntity[] = [ + { + id: 'subflow1', + name: 'Subflow A', + directory: 'subflows', + type: 'subflow', + info: '', + category: '', + env: [], + color: '', + }, + { + id: 'subflow2', + name: 'Subflow B', + directory: 'subflows', + type: 'subflow', + info: '', + category: '', + env: [], + color: '', + }, + ]; + + // Mock the selectors + mockedSelectAllDirectories.mockReturnValue([]); // No custom directories are provided + mockedSelectAllFlowEntities.mockReturnValue( + (flows as (FlowEntity | SubflowEntity)[]).concat(subflows) + ); + + const result = treeLogic.selectFlowTree(mockGetState()); + + expect(result.tree).toEqual([ + { + id: 'flows', + name: 'Flows', + type: 'directory', + directory: '', + directoryPath: '', + children: [ + { + id: 'flow1', + name: 'Main Flow', + type: 'file', + directory: 'flows', + directoryPath: '/Flows', + }, + { + id: 'flow2', + name: 'Secondary Flow', + type: 'file', + directory: 'flows', + directoryPath: '/Flows', + }, + ], + }, + { + id: 'subflows', + name: 'Subflows', + type: 'directory', + directory: '', + directoryPath: '', + children: [ + { + id: 'subflow1', + name: 'Subflow A', + type: 'file', + directory: 'subflows', + directoryPath: '/Subflows', + }, + { + id: 'subflow2', + name: 'Subflow B', + type: 'file', + directory: 'subflows', + directoryPath: '/Subflows', + }, + ], + }, + ]); + }); + + it('should handle empty directories correctly', () => { + // Mock empty responses + mockedSelectAllDirectories.mockReturnValue([]); + mockedSelectAllFlowEntities.mockReturnValue([]); + + const result = treeLogic.selectFlowTree(mockGetState()); + + expect(result.tree).toEqual([ + { + id: 'flows', + name: 'Flows', + type: 'directory', + directory: '', + directoryPath: '', + children: [], + }, + { + id: 'subflows', + name: 'Subflows', + type: 'directory', + directory: '', + directoryPath: '', + children: [], + }, + ]); + }); + }); +}); diff --git a/packages/flow-client/src/app/redux/modules/flow/tree.logic.ts b/packages/flow-client/src/app/redux/modules/flow/tree.logic.ts new file mode 100644 index 0000000..80d58bb --- /dev/null +++ b/packages/flow-client/src/app/redux/modules/flow/tree.logic.ts @@ -0,0 +1,155 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { + DirectoryEntity, + selectAllDirectories, + selectAllFlowEntities, +} from './flow.slice'; + +type TreeItem = { + id: string; + name: string; + directory: string; + directoryPath: string; +}; + +export type TreeDirectory = TreeItem & { + type: 'directory'; + children: TreeItemData[]; +}; + +export type TreeFile = TreeItem & { + type: 'file'; +}; + +export type TreeItemData = TreeDirectory | TreeFile; + +export class TreeLogic { + public directoryIsDefault(item: TreeDirectory) { + return ['flows', 'subflows'].includes(item.id); + } + + public getFilePath(item: TreeItemData) { + const parent = item.directoryPath ? `${item.directoryPath}` : ''; + return item.name ? `${parent}/${item.name}` : parent; + } + + private createTreeDirectory( + directory: DirectoryEntity, + defaultDirectory: string + ) { + return { + id: directory.id, + name: directory.name, + type: 'directory', + directory: directory.directory ?? defaultDirectory, + directoryPath: '', + children: [], + } as TreeDirectory; + } + + private addTreeDirectory( + treeItems: Record, + directories: DirectoryEntity[], + defaultDirectory: string, + directory: DirectoryEntity + ) { + // create item + const item = this.createTreeDirectory(directory, defaultDirectory); + // get the parent directory + let parent = treeItems[item.directory] as TreeDirectory; + if (!parent) { + const parentEntity = directories.find( + it => it.id === item.directory + ); + if (!parentEntity) { + throw new Error(`Directory ${item.directory} not found`); + } + parent = this.addTreeDirectory( + treeItems, + directories, + defaultDirectory, + parentEntity + ); + } + // update item + item.directoryPath = this.getFilePath(parent); + parent.children?.push(item); + treeItems[item.id] = item; + // return item + return item; + } + + selectFlowTree = createSelector( + [state => state, selectAllDirectories, selectAllFlowEntities], + (state, directories, flowEntities) => { + // collect tree hierarchy + const rootDirectory = { + id: '', + name: '', + type: 'directory', + directory: '', + directoryPath: '', + children: [], + } as TreeDirectory; + const flowsDirectory = { + id: 'flows', + name: 'Flows', + type: 'directory', + directory: rootDirectory.id, + directoryPath: '', + children: [], + } as TreeDirectory; + const subflowsDirectory = { + id: 'subflows', + name: 'Subflows', + type: 'directory', + directory: rootDirectory.id, + directoryPath: '', + children: [], + } as TreeDirectory; + rootDirectory.children?.push(flowsDirectory, subflowsDirectory); + const treeItems = { + [rootDirectory.id]: rootDirectory, + [flowsDirectory.id]: flowsDirectory, + [subflowsDirectory.id]: subflowsDirectory, + } as Record; + + // loop directories + directories.forEach(directory => { + // if we've already created it + if (treeItems[directory.id]) { + // nothing to do + return; + } + // else, create it + this.addTreeDirectory( + treeItems, + directories, + rootDirectory.id, + directory + ); + }); + + // loop flows and subflows + flowEntities.forEach(entity => { + const directoryId = + entity.directory ?? + (entity.type === 'flow' + ? flowsDirectory.id + : subflowsDirectory.id); + const directory = treeItems[directoryId] as TreeDirectory; + const item = { + id: entity.id, + name: entity.name, + type: 'file', + directory: directoryId, + directoryPath: `${directory.directoryPath}/${directory.name}`, + } as TreeFile; + directory.children.push(item); + treeItems[item.id] = item; + }); + + return { tree: rootDirectory.children, items: treeItems }; + } + ); +}