diff --git a/.gitignore b/.gitignore index a547bf3..4147656 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# Coverage report +coverage \ No newline at end of file diff --git a/__mocks__/@neodrag/react.ts b/__mocks__/@neodrag/react.ts new file mode 100644 index 0000000..7ea5455 --- /dev/null +++ b/__mocks__/@neodrag/react.ts @@ -0,0 +1,3 @@ +jest.mock('@neodrag/react', () => ({ + useDraggable: jest.fn(), +})); \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index ea33067..819a05d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,13 +2,15 @@ export default { preset: 'ts-jest/presets/default-esm', testEnvironment: 'jsdom', extensionsToTreatAsEsm: ['.ts', '.tsx'], - setupFilesAfterEnv: ['/test/setupTests.ts'], + setupFilesAfterEnv: ['/test/setupTests.ts', '/test/setupFlowTests.ts' ], moduleNameMapper: { '^@/(.*)$': '/src/$1', '\\.(css|scss|sass)$': 'identity-obj-proxy' }, - testMatch: ['/test/*.test.(ts|tsx)'], + testMatch: ['/test/**/*.test.(ts|tsx)'], transform: { '^.+\\.(ts|tsx)$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.jest.json' }] - } + }, + collectCoverage:true, + collectCoverageFrom: ['/src/**/*.{ts,tsx,js,jsx}'], }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 11ca4e5..a1ed79f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,12 @@ "name": "pepperplus-ui", "version": "0.0.0", "dependencies": { + "@neodrag/react": "^2.3.1", + "@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", @@ -1965,6 +1968,12 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@neodrag/react": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@neodrag/react/-/react-2.3.1.tgz", + "integrity": "sha512-mOVefo3mFmaVLs9PB5F5wMXnnclG81qjOaPHyf8YZUnw/Ciz0pAqyJDwDJk0nPTIK5I2x1JdjXSchGNdCxZNRQ==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2572,6 +2581,55 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2650,7 +2708,7 @@ "version": "19.1.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -3265,6 +3323,66 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.8.6", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz", + "integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.70", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "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 + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz", + "integrity": "sha512-PpC//u9zxdjj0tfTSmZrg3+sRbTz6kop/Amky44U2Dl51sxzDTIUfXMwETOYpmr2dqICWXBIJwXL2a9QWtX2XA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3686,6 +3804,12 @@ "dev": true, "license": "MIT" }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3865,9 +3989,114 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -7508,6 +7737,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "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==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -7990,6 +8228,35 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 10b7a45..cb88357 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,12 @@ "preview": "vite preview" }, "dependencies": { + "@neodrag/react": "^2.3.1", + "@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", diff --git a/src/App.tsx b/src/App.tsx index 803b84c..968c979 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import './App.css' import TemplatePage from './pages/TemplatePage/Template.tsx' import Home from './pages/Home/Home.tsx' import Robot from './pages/Robot/Robot.tsx'; +import VisProg from "./pages/VisProgPage/VisProg.tsx"; function App(){ return ( @@ -14,6 +15,7 @@ function App(){ } /> } /> + } /> } /> diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index cb70de0..3a74d8e 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -12,6 +12,7 @@ function Home() {
Robot Interaction → + Editor → Template →
diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css new file mode 100644 index 0000000..f2f90c7 --- /dev/null +++ b/src/pages/VisProgPage/VisProg.module.css @@ -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); +} + diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx new file mode 100644 index 0000000..4b8944c --- /dev/null +++ b/src/pages/VisProgPage/VisProg.tsx @@ -0,0 +1,139 @@ +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 ( +
+
+ + + {/* contains the drag and drop panel for nodes */} + + + + +
+
+ ); +}; + + +/** + * 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 ( + + + + ); +} + +/** + * houses the entire page, so also UI elements + * that are not a part of the Visual Programming UI + * @constructor + */ +function VisProgPage() { + return ( + <> + + + ) +} + +export default VisProgPage \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx new file mode 100644 index 0000000..10d0142 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -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((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; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx new file mode 100644 index 0000000..f5ede86 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -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; +}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx new file mode 100644 index 0000000..383f72c --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -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(null); + const [position, setPosition] = useState({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 ( +
+ {children} +
+ ); +} + + +// 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 ( +
+
+ You can drag these nodes to the pane to create new nodes. +
+
+ + phase Node + + + norm Node + +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx new file mode 100644 index 0000000..63765b5 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx @@ -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 ( + + + ); +} + + +// Definitions of Nodes + +// Start Node definition: + +type StartNodeProps = { + id: string; + data: startNodeData; +}; + +export const StartNode = ({id, data}: StartNodeProps) => { + return ( + <> + +
+
data test {data.label}
+ +
+ + ); +}; + + +// End node definition: + +type EndNodeProps = { + id: string; + data: endNodeData; +}; + +export const EndNode = ({id, data}: EndNodeProps) => { + return ( + <> + +
+
{data.label}
+ +
+ + ); +}; + + +// Phase node definition: + +type PhaseNodeProps = { + id: string; + data: phaseNodeData; +}; + +export const PhaseNode = ({id, data}: PhaseNodeProps) => { + return ( + <> + +
+
phase {data.number} {data.label}
+ + + +
+ + ); +}; + + +// Norm node definition: + +type NormNodeProps = { + id: string; + data: normNodeData; +}; + +export const NormNode = ({id, data}: NormNodeProps) => { + return ( + <> + +
+
Norm {data.label}
+ +
+ + ); +}; \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx new file mode 100644 index 0000000..9b3ab80 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -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' + }); + }); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx new file mode 100644 index 0000000..ae9b88c --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx @@ -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"); + }) +}); \ No newline at end of file diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts new file mode 100644 index 0000000..21a4945 --- /dev/null +++ b/test/setupFlowTests.ts @@ -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 + }); +}); +