Skip to content

Commit

Permalink
Fb 04 node connections (#11)
Browse files Browse the repository at this point in the history
* Disable context menu on canvas to make connection deletion easier

* Rewrite technical requirements for FB-04

* Start FB-04

* Write implementation details for FB-04 and move one technical requirement to later epic

* Move FB-04 to In Review

* Create new flow.slice and flow.logic redux state

* Add NodeEntity to flow node instance

* Add global event handling to custom react-diagrams engine

* Listen for changes to flow currently being built and send them to flow.slice in flow-canvas.tsx

* Finish FB-04
  • Loading branch information
JoshuaCWebDeveloper authored Apr 11, 2024
1 parent 5d0e885 commit 863182f
Show file tree
Hide file tree
Showing 13 changed files with 852 additions and 39 deletions.
46 changes: 33 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,18 +166,24 @@ The backlog is organized by epic, with each task having a unique ID, description
- **Utilize `react-dnd` for Drag-and-Drop Functionality**: `react-dnd` will be used to handle the drag-and-drop operations, providing a flexible and intuitive user experience for adding nodes to the canvas.
- **Visual Feedback and User Experience**: Implement visual cues during the drag-and-drop operation, such as changing the cursor, highlighting potential drop zones, and showing a "ghost" image of the node being dragged to provide clear feedback to the user.
- **Responsive Design Considerations**: Ensure that the drag-and-drop functionality is fully responsive and provides a consistent experience across different devices and screen sizes.
- **FB-04** (Priority: 4): Develop node connection functionality.
- **Objective**: Allow users to create connections between nodes on the canvas, forming logical flows.
- **FB-04** (Priority: 4): Verify and Enhance Node Connection Functionality.
- **Objective**: Ensure the existing node connection functionality is working as expected and introduce enhancements for a more intuitive user experience.
- **Technical Requirements**:
- Implement a method for users to draw connections between nodes, possibly by dragging from one node's output port to another node's input port.
- Utilize `@projectstorm/react-diagrams` for managing the rendering and logic of connections, ensuring compatibility with the library's way of handling links.
- Connections should be visually distinct and should support different styles (straight lines, curves) to enhance readability.
- Include validation to ensure that connections between incompatible node types or ports are not allowed.
- Provide visual feedback during the connection process, such as highlighting compatible ports when drawing a connection.
- **FB-05** (Priority: 5): Implement editor UI for nodes.
- **Objective**: Provide a user-friendly interface for configuring and editing node properties.
- **Technical Requirements**:
- Implement UI components for editing node properties, including individual node attributes and dialog boxes for configuration.
- Verify that users can draw connections between nodes by dragging from one node's output port to another node's input port, utilizing `@projectstorm/react-diagrams` for rendering and logic. Ensure connections are visually distinct, support different styles for enhanced readability, include validation for incompatible node types or ports, and provide visual feedback during the connection process.
- Implement state management for the flow canvas that streams changes into our application state, ensuring the state matches the spec for a Node-RED `flows.yaml` file. This will involve capturing the state of nodes, their connections, and any other relevant flow information in a format that is compatible with Node-RED, facilitating seamless integration and future features such as exporting flows.
- Explore the feasibility of enhancing the connection drawing process to allow for auto-attachment of connections to the nearest appropriate port (input or output) when a user draws a connection over a node. This feature aims to simplify the process of creating connections by reducing the precision required to attach a wire to a specific port, thereby improving the user experience.
- **Implementation Details**:
- **State Management for Flow Canvas**:
- To manage the state of the flow canvas effectively, including nodes, their connections, and other relevant flow information, new files dedicated to flow management will be introduced:
1. **Flow Slice (`flow/flow.slice.ts`)**: Manages the state of the flow canvas, including nodes, connections, and flow configurations.
2. **Flow Logic (`flow/flow.logic.ts`)**: Encapsulates the business logic for managing flows, including the creation, update, and deletion of nodes and connections.
3. **Flow Slice Tests (`flow/flow.slice.spec.ts`)**: Ensures the flow slice correctly manages the state of the flow canvas.
4. **Flow Logic Tests (`flow/flow.logic.spec.ts`)**: Validates the business logic for flow management.
- These files will work together to ensure a robust state management system for the flow canvas, enhancing node connection functionality and ensuring a seamless user experience.
- **Enhancements to Connection Drawing Process**:
- **User Experience (UX) Improvements**: Simplify the process of creating connections with intuitive auto-attachment to the nearest valid port and provide visual feedback during the process.
- **Technical Feasibility**: Implement port proximity detection and valid port identification to support auto-attachment features.
- **Implementation Strategies**: Explore extending or customizing `@projectstorm/react-diagrams` for auto-attachment functionality and develop custom drag-and-drop logic as needed.

#### Epic: Node Management Interface

Expand Down Expand Up @@ -220,6 +226,19 @@ The backlog is organized by epic, with each task having a unique ID, description
- **UX-03**: Implement responsive design.
- **Objective**: Ensure the frontend client is accessible and usable across various devices.
- **Technical Requirements**: Adopt a responsive design approach that allows the frontend client to adapt to different screen sizes and resolutions, ensuring a consistent user experience.
- **UX-04**: Implement Visual Indicators for Node Connection Compatibility.
- **Objective**: Enhance the user experience by introducing visual indicators that provide immediate feedback on the compatibility of connections between nodes during the drag-and-drop operation.
- **Technical Requirements**:
- Develop a system to visually indicate when a connection being dragged is compatible or incompatible with a potential target port.
- Customize port and link models to include compatibility information, allowing for dynamic styling based on the context of the drag-and-drop operation.
- Implement custom widgets for ports and links that change appearance (e.g., color, icons) to reflect compatibility status.
- Utilize the event system in `@projectstorm/react-diagrams` to update the appearance of ports and links in real-time during drag-and-drop actions.
- **Justification**: This feature aims to simplify the process of creating connections by reducing the need for trial and error, thereby improving the overall user experience. By providing clear visual cues, users can easily identify valid connection paths, leading to more efficient flow construction.
- **Implementation Notes**:
- Consider the development effort and complexity involved in customizing the underlying library. This task may require extensive testing to ensure a seamless integration with existing functionalities.
- Prioritize user feedback on the current version of the flow builder to determine the necessity and priority of this enhancement.
- **Future Considerations**:
- Gather user feedback on the implementation to assess its effectiveness and explore further enhancements based on real-world usage.

#### Epic: Debugging and Testing Tools

Expand Down Expand Up @@ -270,9 +289,10 @@ The backlog is organized by epic, with each task having a unique ID, description

| To Do | In Progress | In Review | Done |
| ----- | ----------- | --------- | ----- |
| FB-04 | | | FB-01 |
| FB-05 | | | FB-02 |
| FB-05 | | | FB-01 |
| | | | FB-02 |
| | | | FB-03 |
| | | | FB-04 |

### Progress Tracking

Expand Down
4 changes: 2 additions & 2 deletions packages/flow-client/src/app/app.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ vi.mock('./redux/modules/api/node.api', async importOriginal => {
};
});

vi.mock('./components/flow-canvas/flow-canvas-container', () => ({
FlowCanvasContainer: () => <div>Flow Container</div>,
vi.mock('./components/flow-canvas/flow-canvas', () => ({
FlowCanvas: () => <div>Flow Container</div>,
}));

describe('App', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/flow-client/src/app/app.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import styled from 'styled-components';

import { FlowCanvasContainer } from './components/flow-canvas/flow-canvas-container'; // Ensure the path is correct
import { FlowCanvas } from './components/flow-canvas/flow-canvas'; // Ensure the path is correct
import NodePalette from './components/node-palette/node-palette'; // Import NodePalette

// StyledApp defines the main application container styles.
Expand Down Expand Up @@ -43,7 +43,7 @@ export function App() {
</header>
<div className="builder-container">
<NodePalette />
<FlowCanvasContainer />
<FlowCanvas />
</div>
</StyledApp>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
AbstractReactFactory,
BaseEvent,
CanvasEngineOptions,
DefaultDiagramState,
DefaultLabelFactory,
DefaultLinkFactory,
Expand All @@ -14,8 +16,38 @@ import {
} from '@projectstorm/react-diagrams';

import { CustomNodeFactory } from './node';
import { CustomDiagramModel } from './model';

export class CustomEngine extends DiagramEngine {
constructor(options?: CanvasEngineOptions) {
super(options);

this.registerListener({
eventDidFire: (event: BaseEvent) => {
const e = event as unknown as BaseEvent & {
function: string;
globalName: string;
global: boolean;
};
// ignore global events
if (e.function === '_globalEngine' || e.global) {
return;
}
e.globalName = `CustomEngine:${e.function}`;
this.fireEvent(e, '_globalEngine');
},
_globalEngine: (event: BaseEvent) => {
const e = event as unknown as BaseEvent & {
function: string;
globalName: string;
global: boolean;
};
e.global = true;
this.fireEvent(e, e.globalName);
},
});
}

public increaseZoomLevel(event: WheelEvent): void {
const model = this.getModel();
if (model) {
Expand Down Expand Up @@ -43,6 +75,36 @@ export class CustomEngine extends DiagramEngine {
this.repaintCanvas();
}
}

public setModel(model: CustomDiagramModel): void {
const ret = super.setModel(model);

// Add a global event listener to the model
const handleGlobalEvent = (event: BaseEvent) => {
const e = event as unknown as BaseEvent & {
function: string;
entity: () => void;
globalName: string;
};
e.globalName = `${e.entity.constructor.name}:${e.function}`;
this.fireEvent(e, '_globalEngine');
};
model.registerListener({
eventDidFire: (event: BaseEvent) => {
const e = event as unknown as BaseEvent & {
function: string;
};
// ignore global events
if (e.function === '_globalPassthrough') {
return;
}
handleGlobalEvent(e);
},
_globalPassthrough: handleGlobalEvent,
});

return ret;
}
}

export const createEngine = (options = {}) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import { CanvasWidget } from '@projectstorm/react-canvas-core';
import { CanvasWidget, ListenerHandle } from '@projectstorm/react-canvas-core';
import {
DefaultLinkModel,
DefaultPortModel,
DiagramModel,
PortModelAlignment,
} from '@projectstorm/react-diagrams';
import React, { useEffect } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useDrop } from 'react-dnd';
import styled from 'styled-components';

import { ItemTypes } from '../node/draggable-item-types'; // Assuming ItemTypes is defined elsewhere

import { useAppDispatch, useAppLogic, useAppSelector } from '../../redux/hooks';
import { SerializedGraph } from '../../redux/modules/flow/flow.logic';
import { selectAllEntities } from '../../redux/modules/flow/flow.slice';
import { NodeEntity } from '../../redux/modules/node/node.slice';
import { createEngine } from './custom-engine';
import { ItemTypes } from '../node/draggable-item-types'; // Assuming ItemTypes is defined elsewhere
import { createEngine } from './engine';
import { CustomDiagramModel } from './model';
import { CustomNodeModel } from './node';
import { useAppLogic } from '../../redux/hooks';

const StyledCanvasWidget = styled(CanvasWidget)`
background-color: #f0f0f0; /* Light grey background */
Expand Down Expand Up @@ -50,9 +51,38 @@ const StyledCanvasWidget = styled(CanvasWidget)`
overflow: auto; /* Allows scrolling within the canvas */
`;

const debounce = (func: (...args: unknown[]) => void, wait: number) => {
let timeout: NodeJS.Timeout;
return function executedFunction(...args: unknown[]) {
const later = () => {
clearTimeout(timeout);
func(...args);
};

clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};

const LogFlowSlice = () => {
const flowSlice = useAppSelector(selectAllEntities);

useEffect(() => {
console.log('Flow state: ', flowSlice);
}, [flowSlice]);

return null; // This component does not render anything
};

const engine = createEngine();

export type FlowCanvasContainerProps = {
// engine.registerListener({
// _globalEngine: (event: BaseEvent) => {
// console.log('Global Engine event fired: ', event);
// },
// });

export type FlowCanvasProps = {
initialDiagram?: {
nodes?: CustomNodeModel[];
links?: DefaultLinkModel[];
Expand All @@ -62,37 +92,78 @@ export type FlowCanvasContainerProps = {
// FlowCanvasContainer initializes and displays the flow canvas.
// It sets up the diagram engine and model for rendering nodes and connections.
// It now accepts initialDiagram as props to allow hardcoded diagrams with nodes and links.
export const FlowCanvasContainer: React.FC<FlowCanvasContainerProps> = ({
export const FlowCanvas: React.FC<FlowCanvasProps> = ({
initialDiagram = {},
}) => {
const dispatch = useAppDispatch();
const nodeLogic = useAppLogic().node;
const flowLogic = useAppLogic().flow;

const [model] = useState(new CustomDiagramModel());

// Inside your component
const listenerHandleRef = useRef<ListenerHandle | null>(null);

useMemo(() => {
model.setGridSize(20);

// Add initial nodes and links to the model if any
initialDiagram.nodes?.forEach(node => model.addNode(node));
initialDiagram.links?.forEach(link => model.addLink(link));

// Configure engine and model as needed
engine.setModel(model);
}, [initialDiagram.links, initialDiagram.nodes, model]);

const model = new DiagramModel();
model.setGridSize(20);
useEffect(() => {
// Event listener for any change in the model
const handleModelChange = debounce(() => {
// Serialize the current state of the diagram
// serialize() is defined with the incorrect type
const serializedModel =
model.serialize() as unknown as SerializedGraph;
// Dispatch an action to update the Redux state with the serialized model
dispatch(flowLogic.updateFlowFromSerializedGraph(serializedModel));
}, 500);

// Your existing setup code for adding nodes and links to the model
// Register event listeners and store the handle in the ref
listenerHandleRef.current = engine.registerListener({
'CustomDiagramModel:nodesUpdated': handleModelChange,
'CustomNodeModel:positionChanged': handleModelChange,
'DefaultLinkModel:targetPortChanged': handleModelChange,
// Add more listeners as needed
});

engine.setModel(model);
return () => {
// Cleanup: use the ref to access the handle for deregistering listeners
if (listenerHandleRef.current) {
engine.deregisterListener(listenerHandleRef.current);
listenerHandleRef.current = null; // Reset the ref after cleanup
}
};
}, [
dispatch,
flowLogic,
initialDiagram.links,
initialDiagram.nodes,
model,
]);

useEffect(() => {
const canvas = document.querySelector('.flow-canvas');
const handleZoom = (event: Event) =>
engine.increaseZoomLevel(event as WheelEvent);
const disableContextMenu = (event: Event) => event.preventDefault();

canvas?.addEventListener('wheel', handleZoom);
canvas?.addEventListener('contextmenu', disableContextMenu);

return () => {
canvas?.removeEventListener('wheel', handleZoom);
canvas?.removeEventListener('contextmenu', disableContextMenu);
};
}, []);

// Add initial nodes and links to the model if any
initialDiagram.nodes?.forEach(node => model.addNode(node));
initialDiagram.links?.forEach(link => model.addLink(link));

// Configure engine and model as needed
engine.setModel(model);

const [, drop] = useDrop(() => ({
accept: ItemTypes.NODE,
drop: (entity: NodeEntity, monitor) => {
Expand Down Expand Up @@ -180,9 +251,10 @@ export const FlowCanvasContainer: React.FC<FlowCanvasContainerProps> = ({
// The "canvas-widget" className can be targeted for custom styling.
return (
<div ref={drop} style={{ height: '100%', width: '100%' }}>
<LogFlowSlice />
<StyledCanvasWidget engine={engine} className="flow-canvas" />
</div>
);
};

export default FlowCanvasContainer;
export default FlowCanvas;
44 changes: 44 additions & 0 deletions packages/flow-client/src/app/components/flow-canvas/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
DiagramModel,
NodeModel,
LinkModel,
BaseEvent,
PortModel,
} from '@projectstorm/react-diagrams';

export class CustomDiagramModel extends DiagramModel {
// Custom method to add a node and register an event listener
addNode(node: NodeModel): NodeModel {
const ret = super.addNode(node);
// Register an event listener for the node
node.registerListener({
eventDidFire: (e: BaseEvent) => {
//console.log(`Node event fired: `, event);
this.fireEvent(e, '_globalPassthrough');
},
});

// also fire the event for every port on our node
Object.values(node.getPorts()).forEach((port: PortModel) => {
port.registerListener({
eventDidFire: (event: BaseEvent) => {
this.fireEvent(event, '_globalPassthrough');
},
});
});
return ret;
}

// Custom method to add a link and register an event listener
addLink(link: LinkModel): LinkModel {
const ret = super.addLink(link);
// Register an event listener for the link
link.registerListener({
eventDidFire: (e: BaseEvent) => {
//console.log(`Link event fired: `, event);
this.fireEvent(e, '_globalPassthrough');
},
});
return ret;
}
}
Loading

0 comments on commit 863182f

Please sign in to comment.