Merge remote-tracking branch 'origin/dev' into feat/show-connected-robots
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Coverage report
|
||||
coverage
|
||||
3
__mocks__/@neodrag/react.ts
Normal file
3
__mocks__/@neodrag/react.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
jest.mock('@neodrag/react', () => ({
|
||||
useDraggable: jest.fn(),
|
||||
}));
|
||||
@@ -2,13 +2,15 @@ export default {
|
||||
preset: 'ts-jest/presets/default-esm',
|
||||
testEnvironment: 'jsdom',
|
||||
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
||||
setupFilesAfterEnv: ['<rootDir>/test/setupTests.ts'],
|
||||
setupFilesAfterEnv: ['<rootDir>/test/setupTests.ts', '<rootDir>/test/setupFlowTests.ts' ],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'\\.(css|scss|sass)$': 'identity-obj-proxy'
|
||||
},
|
||||
testMatch: ['<rootDir>/test/*.test.(ts|tsx)'],
|
||||
testMatch: ['<rootDir>/test/**/*.test.(ts|tsx)'],
|
||||
transform: {
|
||||
'^.+\\.(ts|tsx)$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.jest.json' }]
|
||||
}
|
||||
},
|
||||
collectCoverage:true,
|
||||
collectCoverageFrom: ['<rootDir>/src/**/*.{ts,tsx,js,jsx}'],
|
||||
};
|
||||
61
package-lock.json
generated
61
package-lock.json
generated
@@ -12,7 +12,8 @@
|
||||
"@xyflow/react": "^12.8.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router": "^7.9.3"
|
||||
"react-router": "^7.9.3",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
@@ -3337,6 +3338,37 @@
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
"node_modules/@xyflow/react/node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
>>>>>>> origin/dev
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.70",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz",
|
||||
@@ -7709,9 +7741,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
<<<<<<< HEAD
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
=======
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||
>>>>>>> origin/dev
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -8201,6 +8239,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
<<<<<<< HEAD
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
@@ -8215,6 +8254,20 @@
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
=======
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
>>>>>>> origin/dev
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -8225,6 +8278,12 @@
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
>>>>>>> origin/dev
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"@xyflow/react": "^12.8.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router": "^7.9.3"
|
||||
"react-router": "^7.9.3",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
|
||||
@@ -4,7 +4,11 @@ import './App.css'
|
||||
import TemplatePage from './pages/TemplatePage/Template.tsx'
|
||||
import Home from './pages/Home/Home.tsx'
|
||||
import Robot from './pages/Robot/Robot.tsx';
|
||||
<<<<<<< HEAD
|
||||
import ConnectedRobots from './pages/ConnectedRobots/ConnectedRobots.tsx'
|
||||
=======
|
||||
import VisProg from "./pages/VisProgPage/VisProg.tsx";
|
||||
>>>>>>> origin/dev
|
||||
|
||||
function App(){
|
||||
|
||||
@@ -27,6 +31,7 @@ function App(){
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/template" element={<TemplatePage />} />
|
||||
<Route path="/editor" element={<VisProg />} />
|
||||
<Route path="/robot" element={<Robot />} />
|
||||
<Route
|
||||
path="/ConnectedRobots"
|
||||
|
||||
@@ -12,6 +12,7 @@ function Home() {
|
||||
</div>
|
||||
<div className={styles.links}>
|
||||
<Link to={"/robot"}>Robot Interaction →</Link>
|
||||
<Link to={"/editor"}>Editor →</Link>
|
||||
<Link to={"/template"}>Template →</Link>
|
||||
<Link to={"/ConnectedRobots"}>Connected Robots →</Link>
|
||||
</div>
|
||||
|
||||
129
src/pages/VisProgPage/VisProg.module.css
Normal file
129
src/pages/VisProgPage/VisProg.module.css
Normal file
@@ -0,0 +1,129 @@
|
||||
/* editor UI */
|
||||
|
||||
.outer-editor-container {
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
justify-self: center;
|
||||
padding: 10px;
|
||||
align-items: center;
|
||||
width: 80vw;
|
||||
height: 80vh;
|
||||
}
|
||||
|
||||
.inner-editor-container {
|
||||
outline-style: solid;
|
||||
border-radius: 10pt;
|
||||
width: 90%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.dnd-panel {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: auto;
|
||||
background-color: canvas;
|
||||
align-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top:auto;
|
||||
width: 50%;
|
||||
height:7%;
|
||||
}
|
||||
|
||||
.inner-dnd-panel {
|
||||
outline: 2.5pt solid black;
|
||||
border-radius: 0 0 5pt 5pt;
|
||||
border-color: dimgrey;
|
||||
background-color: canvas;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dnd-node-container {
|
||||
background-color: canvas;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Node Styles */
|
||||
|
||||
.default-node {
|
||||
padding: 10px 15px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
outline: black solid 2pt;
|
||||
filter: drop-shadow(0 0 0.75rem black);
|
||||
}
|
||||
|
||||
.default-node-norm {
|
||||
padding: 10px 15px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
outline: forestgreen solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem forestgreen);
|
||||
}
|
||||
|
||||
.default-node-phase {
|
||||
padding: 10px 15px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
outline: dodgerblue solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem dodgerblue);
|
||||
}
|
||||
|
||||
.default-node-start {
|
||||
padding: 10px 15px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
outline: orange solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem orange);
|
||||
}
|
||||
|
||||
.default-node-end {
|
||||
padding: 10px 15px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
outline: red solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem red);
|
||||
}
|
||||
|
||||
.draggable-node {
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
outline: black solid 2pt;
|
||||
filter: drop-shadow(0 0 0.75rem black);
|
||||
}
|
||||
|
||||
.draggable-node-norm {
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
outline: forestgreen solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem forestgreen);
|
||||
}
|
||||
|
||||
.draggable-node-phase {
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
outline: dodgerblue solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem dodgerblue);
|
||||
}
|
||||
|
||||
.draggable-node-start {
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
outline: orange solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem orange);
|
||||
}
|
||||
|
||||
.draggable-node-end {
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
outline: red solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem red);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<<<<<<< HEAD
|
||||
import VisProgUI from "../../visualProgrammingUI/VisProgUI.tsx";
|
||||
|
||||
function VisProgPage() {
|
||||
@@ -6,6 +7,144 @@ function VisProgPage() {
|
||||
<VisProgUI />
|
||||
</>
|
||||
)
|
||||
=======
|
||||
import {
|
||||
Background,
|
||||
Controls,
|
||||
Panel,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
MarkerType,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import {useShallow} from 'zustand/react/shallow';
|
||||
|
||||
import {
|
||||
StartNode,
|
||||
EndNode,
|
||||
PhaseNode,
|
||||
NormNode
|
||||
} from './visualProgrammingUI/components/NodeDefinitions.tsx';
|
||||
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
||||
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
||||
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
||||
import styles from './VisProg.module.css'
|
||||
|
||||
// --| config starting params for flow |--
|
||||
|
||||
/**
|
||||
* contains the types of all nodes that are available in the editor
|
||||
*/
|
||||
const NODE_TYPES = {
|
||||
start: StartNode,
|
||||
end: EndNode,
|
||||
phase: PhaseNode,
|
||||
norm: NormNode
|
||||
};
|
||||
|
||||
/**
|
||||
* defines how the default edge looks inside the editor
|
||||
*/
|
||||
const DEFAULT_EDGE_OPTIONS = {
|
||||
type: 'default',
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: '#505050',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* defines what functions in the FlowState store map to which names,
|
||||
* @param state
|
||||
*/
|
||||
const selector = (state: FlowState) => ({
|
||||
nodes: state.nodes,
|
||||
edges: state.edges,
|
||||
onNodesChange: state.onNodesChange,
|
||||
onEdgesChange: state.onEdgesChange,
|
||||
onConnect: state.onConnect,
|
||||
onReconnectStart: state.onReconnectStart,
|
||||
onReconnectEnd: state.onReconnectEnd,
|
||||
onReconnect: state.onReconnect
|
||||
});
|
||||
|
||||
// --| define ReactFlow editor |--
|
||||
|
||||
/**
|
||||
* Defines the ReactFlow visual programming editor component
|
||||
* any implementations of editor logic should be encapsulated where possible
|
||||
* so the Component definition stays as readable as possible
|
||||
* @constructor
|
||||
*/
|
||||
const VisProgUI = () => {
|
||||
const {
|
||||
nodes, edges,
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
onConnect,
|
||||
onReconnect,
|
||||
onReconnectStart,
|
||||
onReconnectEnd
|
||||
} = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore
|
||||
|
||||
return (
|
||||
<div className={styles.outerEditorContainer}>
|
||||
<div className={styles.innerEditorContainer}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
||||
nodeTypes={NODE_TYPES}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onReconnect={onReconnect}
|
||||
onReconnectStart={onReconnectStart}
|
||||
onReconnectEnd={onReconnectEnd}
|
||||
onConnect={onConnect}
|
||||
snapToGrid
|
||||
fitView
|
||||
proOptions={{hideAttribution: true}}
|
||||
>
|
||||
<Panel position="top-center" className={styles.dndPanel}>
|
||||
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
|
||||
</Panel>
|
||||
<Controls/>
|
||||
<Background/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Places the VisProgUI component inside a ReactFlowProvider
|
||||
*
|
||||
* Wrapping the editor component inside a ReactFlowProvider
|
||||
* allows us to access and interact with the components inside the editor, outside the editor definition,
|
||||
* thus facilitating the addition of node specific functions inside their node definitions
|
||||
*/
|
||||
function VisualProgrammingUI() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<VisProgUI/>
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* houses the entire page, so also UI elements
|
||||
* that are not a part of the Visual Programming UI
|
||||
* @constructor
|
||||
*/
|
||||
function VisProgPage() {
|
||||
return (
|
||||
<>
|
||||
<VisualProgrammingUI/>
|
||||
</>
|
||||
)
|
||||
>>>>>>> origin/dev
|
||||
}
|
||||
|
||||
export default VisProgPage
|
||||
111
src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx
Normal file
111
src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import {create} from 'zustand';
|
||||
import {
|
||||
applyNodeChanges,
|
||||
applyEdgeChanges,
|
||||
addEdge,
|
||||
reconnectEdge, type Edge, type Connection
|
||||
} from '@xyflow/react';
|
||||
|
||||
import {type FlowState} from './VisProgTypes.tsx';
|
||||
|
||||
/**
|
||||
* contains the nodes that are created when the editor is loaded,
|
||||
* should contain at least a start and an end node
|
||||
*/
|
||||
const initialNodes = [
|
||||
{
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
position: {x: 0, y: 0},
|
||||
data: {label: 'start'}
|
||||
},
|
||||
{
|
||||
id: 'genericPhase',
|
||||
type: 'phase',
|
||||
position: {x: 0, y: 150},
|
||||
data: {label: 'Generic Phase', number: 1},
|
||||
},
|
||||
{
|
||||
id: 'end',
|
||||
type: 'end',
|
||||
position: {x: 0, y: 300},
|
||||
data: {label: 'End'}
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* contains the initial edges that are created when the editor is loaded
|
||||
*/
|
||||
const initialEdges = [
|
||||
{
|
||||
id: 'start-end',
|
||||
source: 'start',
|
||||
target: 'end'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* The useFlowStore hook contains the implementation for editor functionality and state
|
||||
* we can use this inside our editor component to access the current state
|
||||
* and use any implemented functionality
|
||||
*/
|
||||
const useFlowStore = create<FlowState>((set, get) => ({
|
||||
nodes: initialNodes,
|
||||
edges: initialEdges,
|
||||
edgeReconnectSuccessful: true,
|
||||
onNodesChange: (changes) => {
|
||||
set({
|
||||
nodes: applyNodeChanges(changes, get().nodes)
|
||||
});
|
||||
},
|
||||
onEdgesChange: (changes) => {
|
||||
set({
|
||||
edges: applyEdgeChanges(changes, get().edges)
|
||||
});
|
||||
},
|
||||
// handles connection of newly created edges
|
||||
onConnect: (connection) => {
|
||||
set({
|
||||
edges: addEdge(connection, get().edges)
|
||||
});
|
||||
},
|
||||
// handles attempted reconnections of a previously disconnected edge
|
||||
onReconnect: (oldEdge: Edge, newConnection: Connection) => {
|
||||
get().edgeReconnectSuccessful = true;
|
||||
set({
|
||||
edges: reconnectEdge(oldEdge, newConnection, get().edges)
|
||||
});
|
||||
},
|
||||
// Handles initiation of reconnection of edges that are manually disconnected from a node
|
||||
onReconnectStart: () => {
|
||||
set({
|
||||
edgeReconnectSuccessful: false
|
||||
});
|
||||
},
|
||||
// Drops the edge from the set of edges, removing it from the flow, if no successful reconnection occurred
|
||||
onReconnectEnd: (_: unknown, edge: { id: string; }) => {
|
||||
if (!get().edgeReconnectSuccessful) {
|
||||
set({
|
||||
edges: get().edges.filter((e) => e.id !== edge.id),
|
||||
});
|
||||
}
|
||||
set({
|
||||
edgeReconnectSuccessful: true
|
||||
});
|
||||
},
|
||||
deleteNode: (nodeId: string) => {
|
||||
set({
|
||||
nodes: get().nodes.filter((n) => n.id !== nodeId),
|
||||
edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId)
|
||||
});
|
||||
},
|
||||
setNodes: (nodes) => {
|
||||
set({nodes});
|
||||
},
|
||||
setEdges: (edges) => {
|
||||
set({edges});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export default useFlowStore;
|
||||
34
src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx
Normal file
34
src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
type Edge,
|
||||
type Node,
|
||||
type OnNodesChange,
|
||||
type OnEdgesChange,
|
||||
type OnConnect,
|
||||
type OnReconnect,
|
||||
} from '@xyflow/react';
|
||||
|
||||
/**
|
||||
* a type meant to house different node types, currently not used
|
||||
* but will allow us to more clearly define nodeTypes when we implement
|
||||
* computation of the Graph inside the ReactFlow editor
|
||||
*/
|
||||
export type AppNode = Node;
|
||||
|
||||
|
||||
/**
|
||||
* The type for the Zustand store object used to manage the state of the ReactFlow editor
|
||||
*/
|
||||
export type FlowState = {
|
||||
nodes: AppNode[];
|
||||
edges: Edge[];
|
||||
edgeReconnectSuccessful: boolean;
|
||||
onNodesChange: OnNodesChange;
|
||||
onEdgesChange: OnEdgesChange;
|
||||
onConnect: OnConnect;
|
||||
onReconnect: OnReconnect;
|
||||
onReconnectStart: () => void;
|
||||
onReconnectEnd: (_: unknown, edge: { id: string }) => void;
|
||||
deleteNode: (nodeId: string) => void;
|
||||
setNodes: (nodes: AppNode[]) => void;
|
||||
setEdges: (edges: Edge[]) => void;
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import {useDraggable} from '@neodrag/react';
|
||||
import {
|
||||
useReactFlow,
|
||||
type XYPosition
|
||||
} from '@xyflow/react';
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
import styles from "../../VisProg.module.css"
|
||||
|
||||
|
||||
/**
|
||||
* DraggableNodeProps dictates the type properties of a DraggableNode
|
||||
*/
|
||||
interface DraggableNodeProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
nodeType: string;
|
||||
onDrop: (nodeType: string, position: XYPosition) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Definition of a node inside the drag and drop toolbar,
|
||||
* these nodes require an onDrop function to be supplied
|
||||
* that dictates how the node is created in the graph.
|
||||
*
|
||||
* @param className
|
||||
* @param children
|
||||
* @param nodeType
|
||||
* @param onDrop
|
||||
* @constructor
|
||||
*/
|
||||
function DraggableNode({className, children, nodeType, onDrop}: DraggableNodeProps) {
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState<XYPosition>({x: 0, y: 0});
|
||||
|
||||
// @ts-expect-error comes from a package and doesn't appear to play nicely with strict typescript typing
|
||||
useDraggable(draggableRef, {
|
||||
position: position,
|
||||
onDrag: ({offsetX, offsetY}) => {
|
||||
// Calculate position relative to the viewport
|
||||
setPosition({
|
||||
x: offsetX,
|
||||
y: offsetY,
|
||||
});
|
||||
},
|
||||
onDragEnd: ({event}) => {
|
||||
setPosition({x: 0, y: 0});
|
||||
onDrop(nodeType, {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className} ref={draggableRef}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function addNode(nodeType: string, position: XYPosition) {
|
||||
const {setNodes} = useFlowStore.getState();
|
||||
const nds = useFlowStore.getState().nodes;
|
||||
const newNode = () => {
|
||||
switch (nodeType) {
|
||||
case "phase":
|
||||
{
|
||||
const phaseNumber = nds.filter((node) => node.type === 'phase').length;
|
||||
return {
|
||||
id: `phase-${phaseNumber}`,
|
||||
type: nodeType,
|
||||
position,
|
||||
data: {label: 'new', number: phaseNumber},
|
||||
};
|
||||
}
|
||||
case "norm":
|
||||
{
|
||||
const normNumber = nds.filter((node) => node.type === 'norm').length;
|
||||
return {
|
||||
id: `norm-${normNumber}`,
|
||||
type: nodeType,
|
||||
position,
|
||||
data: {label: `new norm node`},
|
||||
};
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Node ${nodeType} not found`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setNodes(nds.concat(newNode()));
|
||||
}
|
||||
|
||||
/**
|
||||
* the DndToolbar defines how the drag and drop toolbar component works
|
||||
* and includes the default onDrop behavior through handleNodeDrop
|
||||
* @constructor
|
||||
*/
|
||||
export function DndToolbar() {
|
||||
const {screenToFlowPosition} = useReactFlow();
|
||||
/**
|
||||
* handleNodeDrop implements the default onDrop behavior
|
||||
*/
|
||||
const handleNodeDrop = useCallback(
|
||||
(nodeType: string, screenPosition: XYPosition) => {
|
||||
const flow = document.querySelector('.react-flow');
|
||||
const flowRect = flow?.getBoundingClientRect();
|
||||
const isInFlow =
|
||||
flowRect &&
|
||||
screenPosition.x >= flowRect.left &&
|
||||
screenPosition.x <= flowRect.right &&
|
||||
screenPosition.y >= flowRect.top &&
|
||||
screenPosition.y <= flowRect.bottom;
|
||||
|
||||
// Create a new node and add it to the flow
|
||||
if (isInFlow) {
|
||||
const position = screenToFlowPosition(screenPosition);
|
||||
addNode(nodeType, position);
|
||||
}
|
||||
},
|
||||
[screenToFlowPosition],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex-col gap-lg padding-md ${styles.innerDndPanel}`}>
|
||||
<div className="description">
|
||||
You can drag these nodes to the pane to create new nodes.
|
||||
</div>
|
||||
<div className={`flex-row gap-lg ${styles.dndNodeContainer}`}>
|
||||
<DraggableNode className={styles.draggableNodePhase} nodeType="phase" onDrop={handleNodeDrop}>
|
||||
phase Node
|
||||
</DraggableNode>
|
||||
<DraggableNode className={styles.draggableNodeNorm} nodeType="norm" onDrop={handleNodeDrop}>
|
||||
norm Node
|
||||
</DraggableNode>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {Handle, NodeToolbar, Position} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import styles from '../../VisProg.module.css';
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
|
||||
// Contains the datatypes for the data inside our NodeTypes
|
||||
// this has to be improved or adapted to suit our implementation for computing the graph
|
||||
// into a format that is useful for the Control Backend
|
||||
|
||||
type defaultNodeData = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
type startNodeData = defaultNodeData;
|
||||
type endNodeData = defaultNodeData;
|
||||
type normNodeData = defaultNodeData;
|
||||
type phaseNodeData = defaultNodeData & {
|
||||
number: number;
|
||||
};
|
||||
|
||||
export type nodeData = defaultNodeData | startNodeData | phaseNodeData | endNodeData;
|
||||
|
||||
// Node Toolbar definition, contains node delete functionality
|
||||
|
||||
type ToolbarProps = {
|
||||
nodeId: string;
|
||||
allowDelete: boolean;
|
||||
};
|
||||
|
||||
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
|
||||
const {deleteNode} = useFlowStore();
|
||||
|
||||
const deleteParentNode = ()=> {
|
||||
deleteNode(nodeId);
|
||||
}
|
||||
return (
|
||||
<NodeToolbar>
|
||||
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
|
||||
</NodeToolbar>);
|
||||
}
|
||||
|
||||
|
||||
// Definitions of Nodes
|
||||
|
||||
// Start Node definition:
|
||||
|
||||
type StartNodeProps = {
|
||||
id: string;
|
||||
data: startNodeData;
|
||||
};
|
||||
|
||||
export const StartNode = ({id, data}: StartNodeProps) => {
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={id} allowDelete={false}/>
|
||||
<div className={styles.defaultNodeStart}>
|
||||
<div> data test {data.label} </div>
|
||||
<Handle type="source" position={Position.Right} id="start"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// End node definition:
|
||||
|
||||
type EndNodeProps = {
|
||||
id: string;
|
||||
data: endNodeData;
|
||||
};
|
||||
|
||||
export const EndNode = ({id, data}: EndNodeProps) => {
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={id} allowDelete={false}/>
|
||||
<div className={styles.defaultNodeEnd}>
|
||||
<div> {data.label} </div>
|
||||
<Handle type="target" position={Position.Left} id="end"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// Phase node definition:
|
||||
|
||||
type PhaseNodeProps = {
|
||||
id: string;
|
||||
data: phaseNodeData;
|
||||
};
|
||||
|
||||
export const PhaseNode = ({id, data}: PhaseNodeProps) => {
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={id} allowDelete={true}/>
|
||||
<div className={styles.defaultNodePhase}>
|
||||
<div> phase {data.number} {data.label} </div>
|
||||
<Handle type="target" position={Position.Left} id="target"/>
|
||||
<Handle type="target" position={Position.Bottom} id="norms"/>
|
||||
<Handle type="source" position={Position.Right} id="source"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// Norm node definition:
|
||||
|
||||
type NormNodeProps = {
|
||||
id: string;
|
||||
data: normNodeData;
|
||||
};
|
||||
|
||||
export const NormNode = ({id, data}: NormNodeProps) => {
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={id} allowDelete={true}/>
|
||||
<div className={styles.defaultNodeNorm}>
|
||||
<div> Norm {data.label} </div>
|
||||
<Handle type="source" position={Position.Right} id="NormSource"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
import {act} from '@testing-library/react';
|
||||
import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
|
||||
import { mockReactFlow } from '../../../setupFlowTests.ts';
|
||||
|
||||
beforeAll(() => {
|
||||
mockReactFlow();
|
||||
});
|
||||
|
||||
describe('FlowStore Functionality', () => {
|
||||
describe('Node changes', () => {
|
||||
// currently just using a single function from the ReactFlow library,
|
||||
// so testing would mean we are testing already tested behavior.
|
||||
// if implementation gets modified tests should be added for custom behavior
|
||||
});
|
||||
describe('Edge changes', () => {
|
||||
// currently just using a single function from the ReactFlow library,
|
||||
// so testing would mean we are testing already tested behavior.
|
||||
// if implementation gets modified tests should be added for custom behavior
|
||||
})
|
||||
describe('ReactFlow onConnect', () => {
|
||||
test('adds an edge when onConnect is triggered', () => {
|
||||
const {onConnect} = useFlowStore.getState();
|
||||
|
||||
act(() => {
|
||||
onConnect({
|
||||
source: 'A',
|
||||
target: 'B',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
});
|
||||
});
|
||||
|
||||
const updatedEdges = useFlowStore.getState().edges;
|
||||
expect(updatedEdges).toHaveLength(1);
|
||||
expect(updatedEdges[0]).toMatchObject({
|
||||
source: 'A',
|
||||
target: 'B',
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('ReactFlow onReconnect', () => {
|
||||
test('reconnects an existing edge when onReconnect is triggered', () => {
|
||||
const {onReconnect} = useFlowStore.getState();
|
||||
const oldEdge = {
|
||||
id: 'xy-edge__A-B',
|
||||
source: 'A',
|
||||
target: 'B'
|
||||
};
|
||||
const newConnection = {
|
||||
source: 'A',
|
||||
target: 'C',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
};
|
||||
act(() => {
|
||||
useFlowStore.setState({
|
||||
edges: [oldEdge]
|
||||
});
|
||||
onReconnect(oldEdge, newConnection);
|
||||
});
|
||||
|
||||
const updatedEdges = useFlowStore.getState().edges;
|
||||
expect(updatedEdges).toHaveLength(1);
|
||||
expect(updatedEdges[0]).toMatchObject({
|
||||
id: 'xy-edge__A-C',
|
||||
source: 'A',
|
||||
target: 'C',
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('ReactFlow onReconnectStart', () => {
|
||||
test('does correct setup for edge reconnection sequences', () => {
|
||||
const {onReconnectStart} = useFlowStore.getState();
|
||||
|
||||
act(() => {
|
||||
onReconnectStart();
|
||||
});
|
||||
|
||||
const updatedState = useFlowStore.getState().edgeReconnectSuccessful;
|
||||
expect(updatedState).toEqual(false);
|
||||
});
|
||||
});
|
||||
describe('ReactFlow onReconnectEnd', () => {
|
||||
// prepares the state to have an edge in the edge array
|
||||
beforeEach(() => {
|
||||
useFlowStore.setState({edges: [
|
||||
{
|
||||
id: 'xy-edge__A-B',
|
||||
source: 'A',
|
||||
target: 'B'
|
||||
}
|
||||
]}
|
||||
);
|
||||
});
|
||||
|
||||
test('successfully removes edge if no successful reconnect occurred', () => {
|
||||
const {onReconnectEnd} = useFlowStore.getState();
|
||||
useFlowStore.setState({edgeReconnectSuccessful: false});
|
||||
|
||||
act(() => {
|
||||
onReconnectEnd(null, {id: 'xy-edge__A-B'});
|
||||
});
|
||||
|
||||
const updatedState = useFlowStore.getState();
|
||||
expect(updatedState.edgeReconnectSuccessful).toBe(true);
|
||||
expect(updatedState.edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not remove reconnecting edge if successful reconnect occurred', () => {
|
||||
const {onReconnectEnd} = useFlowStore.getState();
|
||||
|
||||
act(() => {
|
||||
onReconnectEnd(null, {id: 'xy-edge__A-B'});
|
||||
});
|
||||
|
||||
const updatedState = useFlowStore.getState();
|
||||
expect(updatedState.edgeReconnectSuccessful).toBe(true);
|
||||
expect(updatedState.edges).toHaveLength(1);
|
||||
expect(updatedState.edges).toMatchObject([
|
||||
{
|
||||
id: 'xy-edge__A-B',
|
||||
source: 'A',
|
||||
target: 'B'
|
||||
}]
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('ReactFlow deleteNode', () => {
|
||||
// test deleting A and B, so we make sure the connecting edge gets deleted regardless of
|
||||
test.each([['A','B'],['B','A']])('deletes a node and its connected edges', (nodeId, undeletedNodeId) => {
|
||||
const {deleteNode} = useFlowStore.getState();
|
||||
useFlowStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: 'A',
|
||||
type: 'default',
|
||||
position: {x: 0, y: 0},
|
||||
data: {label: 'A'}
|
||||
},
|
||||
{
|
||||
id: 'B',
|
||||
type: 'default',
|
||||
position: {x: 0, y: 300},
|
||||
data: {label: 'A'}
|
||||
}],
|
||||
edges: [
|
||||
{
|
||||
id: 'xy-edge__A-B',
|
||||
source: 'A',
|
||||
target: 'B'
|
||||
}]
|
||||
});
|
||||
|
||||
act(()=> {
|
||||
deleteNode(nodeId);
|
||||
});
|
||||
|
||||
const updatedState = useFlowStore.getState();
|
||||
expect(updatedState.edges).toHaveLength(0);
|
||||
expect(updatedState.nodes).toHaveLength(1);
|
||||
expect(updatedState.nodes[0].id).toBe(undeletedNodeId);
|
||||
});
|
||||
});
|
||||
describe('ReactFlow setNodes', () => {
|
||||
test('sets nodes to the provided list of nodes', () => {
|
||||
const {setNodes} = useFlowStore.getState();
|
||||
|
||||
act(() => {
|
||||
setNodes([
|
||||
{
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
position: {x: 0, y: 0},
|
||||
data: {label: 'start'}
|
||||
},
|
||||
{
|
||||
id: 'end',
|
||||
type: 'end',
|
||||
position: {x: 0, y: 300},
|
||||
data: {label: 'End'}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
const updatedNodes = useFlowStore.getState().nodes;
|
||||
expect(updatedNodes).toHaveLength(2);
|
||||
expect(updatedNodes[0]).toMatchObject({
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
position: {x: 0, y: 0},
|
||||
data: {label: 'start'}
|
||||
});
|
||||
expect(updatedNodes[1]).toMatchObject({
|
||||
id: 'end',
|
||||
type: 'end',
|
||||
position: {x: 0, y: 300},
|
||||
data: {label: 'End'}
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('ReactFlow setEdges', () => {
|
||||
test('sets edges to the provided list of edges', () => {
|
||||
const {setEdges} = useFlowStore.getState();
|
||||
|
||||
act(() => {
|
||||
setEdges([
|
||||
{
|
||||
id: 'start-end',
|
||||
source: 'start',
|
||||
target: 'end'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
const updatedEdges = useFlowStore.getState().edges;
|
||||
expect(updatedEdges).toHaveLength(1);
|
||||
expect(updatedEdges[0]).toMatchObject({
|
||||
id: 'start-end',
|
||||
source: 'start',
|
||||
target: 'end'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { mockReactFlow } from '../../../../setupFlowTests.ts';
|
||||
import {act} from "@testing-library/react";
|
||||
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
||||
import {addNode} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx";
|
||||
|
||||
|
||||
beforeAll(() => {
|
||||
mockReactFlow();
|
||||
});
|
||||
|
||||
describe('Drag-and-Drop sidebar', () => {
|
||||
test.each(['phase', 'phase'])('new nodes get added correctly', (nodeType: string) => {
|
||||
act(()=> {
|
||||
addNode(nodeType, {x:100, y:100});
|
||||
})
|
||||
const updatedState = useFlowStore.getState();
|
||||
expect(updatedState.nodes.length).toBe(1);
|
||||
expect(updatedState.nodes[0].type).toBe(nodeType);
|
||||
});
|
||||
test.each(['phase', 'norm'])('new nodes get correct Id', (nodeType) => {
|
||||
act(()=> {
|
||||
addNode(nodeType, {x:100, y:100});
|
||||
addNode(nodeType, {x:100, y:100});
|
||||
})
|
||||
const updatedState = useFlowStore.getState();
|
||||
expect(updatedState.nodes.length).toBe(2);
|
||||
expect(updatedState.nodes[0].id).toBe(`${nodeType}-0`);
|
||||
expect(updatedState.nodes[1].id).toBe(`${nodeType}-1`);
|
||||
});
|
||||
test('throws error on unexpected node type', () => {
|
||||
expect(() => addNode('I do not Exist', {x:100, y:100})).toThrow("Node I do not Exist not found");
|
||||
})
|
||||
});
|
||||
84
test/setupFlowTests.ts
Normal file
84
test/setupFlowTests.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import useFlowStore from '../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
|
||||
|
||||
|
||||
// To make sure that the tests are working, it's important that you are using
|
||||
// this implementation of ResizeObserver and DOMMatrixReadOnly
|
||||
class ResizeObserver {
|
||||
callback: globalThis.ResizeObserverCallback;
|
||||
|
||||
constructor(callback: globalThis.ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
observe(target: Element) {
|
||||
this.callback([{ target } as globalThis.ResizeObserverEntry], this);
|
||||
}
|
||||
|
||||
unobserve() {}
|
||||
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
class DOMMatrixReadOnly {
|
||||
m22: number;
|
||||
constructor(transform: string) {
|
||||
const scale = transform?.match(/scale\(([1-9.])\)/)?.[1];
|
||||
this.m22 = scale !== undefined ? +scale : 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Only run the shim once when requested
|
||||
let init = false;
|
||||
|
||||
export const mockReactFlow = () => {
|
||||
if (init) return;
|
||||
init = true;
|
||||
|
||||
globalThis.ResizeObserver = ResizeObserver;
|
||||
|
||||
// @ts-expect-error included in advised setup code provided in ReactFlow documentation
|
||||
global.DOMMatrixReadOnly = DOMMatrixReadOnly;
|
||||
|
||||
Object.defineProperties(globalThis.HTMLElement.prototype, {
|
||||
offsetHeight: {
|
||||
get() {
|
||||
return parseFloat(this.style.height) || 1;
|
||||
},
|
||||
},
|
||||
offsetWidth: {
|
||||
get() {
|
||||
return parseFloat(this.style.width) || 1;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-expect-error included in advised setup code provided in ReactFlow documentation
|
||||
(globalThis.SVGElement as never).prototype.getBBox = () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 200,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
beforeAll(() => {
|
||||
useFlowStore.setState({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
edgeReconnectSuccessful: true
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useFlowStore.setState({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
edgeReconnectSuccessful: true
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user