diff --git a/package-lock.json b/package-lock.json
index a52343e..31d1339 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,9 +10,11 @@
"dependencies": {
"@neodrag/react": "^2.3.1",
"@xyflow/react": "^12.8.6",
+ "clsx": "^2.1.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.9.3",
+ "reactflow": "^11.11.4",
"zustand": "^5.0.8"
},
"devDependencies": {
@@ -2053,6 +2055,276 @@
"url": "https://opencollective.com/pkgr"
}
},
+ "node_modules/@reactflow/background": {
+ "version": "11.3.14",
+ "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
+ "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/background/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/@reactflow/controls": {
+ "version": "11.2.14",
+ "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
+ "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/controls/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/@reactflow/core": {
+ "version": "11.11.4",
+ "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
+ "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3": "^7.4.0",
+ "@types/d3-drag": "^3.0.1",
+ "@types/d3-selection": "^3.0.3",
+ "@types/d3-zoom": "^3.0.1",
+ "classcat": "^5.0.3",
+ "d3-drag": "^3.0.0",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/core/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/@reactflow/minimap": {
+ "version": "11.7.14",
+ "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
+ "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "@types/d3-selection": "^3.0.3",
+ "@types/d3-zoom": "^3.0.1",
+ "classcat": "^5.0.3",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/minimap/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/@reactflow/node-resizer": {
+ "version": "2.2.14",
+ "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
+ "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "classcat": "^5.0.4",
+ "d3-drag": "^3.0.0",
+ "d3-selection": "^3.0.0",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/node-resizer/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/@reactflow/node-toolbar": {
+ "version": "1.3.14",
+ "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
+ "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/core": "11.11.4",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@reactflow/node-toolbar/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/@rolldown/pluginutils": {
"version": "1.0.0-beta.35",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz",
@@ -2647,12 +2919,102 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/d3": {
+ "version": "7.4.3",
+ "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
+ "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/d3-axis": "*",
+ "@types/d3-brush": "*",
+ "@types/d3-chord": "*",
+ "@types/d3-color": "*",
+ "@types/d3-contour": "*",
+ "@types/d3-delaunay": "*",
+ "@types/d3-dispatch": "*",
+ "@types/d3-drag": "*",
+ "@types/d3-dsv": "*",
+ "@types/d3-ease": "*",
+ "@types/d3-fetch": "*",
+ "@types/d3-force": "*",
+ "@types/d3-format": "*",
+ "@types/d3-geo": "*",
+ "@types/d3-hierarchy": "*",
+ "@types/d3-interpolate": "*",
+ "@types/d3-path": "*",
+ "@types/d3-polygon": "*",
+ "@types/d3-quadtree": "*",
+ "@types/d3-random": "*",
+ "@types/d3-scale": "*",
+ "@types/d3-scale-chromatic": "*",
+ "@types/d3-selection": "*",
+ "@types/d3-shape": "*",
+ "@types/d3-time": "*",
+ "@types/d3-time-format": "*",
+ "@types/d3-timer": "*",
+ "@types/d3-transition": "*",
+ "@types/d3-zoom": "*"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-axis": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
+ "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-brush": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
+ "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-chord": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
+ "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
+ "license": "MIT"
+ },
"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-contour": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
+ "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-dispatch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
+ "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
+ "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",
@@ -2662,6 +3024,54 @@
"@types/d3-selection": "*"
}
},
+ "node_modules/@types/d3-dsv": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
+ "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-fetch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
+ "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-dsv": "*"
+ }
+ },
+ "node_modules/@types/d3-force": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+ "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-format": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
+ "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-geo": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+ "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-hierarchy": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
+ "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
+ "license": "MIT"
+ },
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -2671,12 +3081,78 @@
"@types/d3-color": "*"
}
},
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-polygon": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
+ "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-quadtree": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
+ "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-random": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
+ "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
+ "license": "MIT"
+ },
"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-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-time-format": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
+ "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "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",
@@ -2703,6 +3179,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@@ -3971,6 +4453,15 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -6945,9 +7436,9 @@
}
},
"node_modules/react-router": {
- "version": "7.9.3",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
- "integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
+ "version": "7.12.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
+ "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -6966,6 +7457,24 @@
}
}
},
+ "node_modules/reactflow": {
+ "version": "11.11.4",
+ "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
+ "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
+ "license": "MIT",
+ "dependencies": {
+ "@reactflow/background": "11.3.14",
+ "@reactflow/controls": "11.2.14",
+ "@reactflow/core": "11.11.4",
+ "@reactflow/minimap": "11.7.14",
+ "@reactflow/node-resizer": "2.2.14",
+ "@reactflow/node-toolbar": "1.3.14"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
diff --git a/package.json b/package.json
index 45f66df..a9837a1 100644
--- a/package.json
+++ b/package.json
@@ -14,9 +14,11 @@
"dependencies": {
"@neodrag/react": "^2.3.1",
"@xyflow/react": "^12.8.6",
+ "clsx": "^2.1.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.9.3",
+ "reactflow": "^11.11.4",
"zustand": "^5.0.8"
},
"devDependencies": {
diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx
index 919e1af..590f99e 100644
--- a/src/pages/VisProgPage/VisProg.tsx
+++ b/src/pages/VisProgPage/VisProg.tsx
@@ -4,13 +4,16 @@ import {
Panel,
ReactFlow,
ReactFlowProvider,
- MarkerType,
+ MarkerType, getOutgoers
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
+import warningStyles from './visualProgrammingUI/components/WarningSidebar.module.css'
import {type CSSProperties, useEffect, useState} from "react";
import {useShallow} from 'zustand/react/shallow';
import useProgramStore from "../../utils/programStore.ts";
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
+import {type EditorWarning, globalWarning} from "./visualProgrammingUI/components/EditorWarnings.tsx";
+import {WarningsSidebar} from "./visualProgrammingUI/components/WarningSidebar.tsx";
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
import styles from './VisProg.module.css'
@@ -42,6 +45,7 @@ const selector = (state: FlowState) => ({
nodes: state.nodes,
edges: state.edges,
onNodesChange: state.onNodesChange,
+ onNodesDelete: state.onNodesDelete,
onEdgesDelete: state.onEdgesDelete,
onEdgesChange: state.onEdgesChange,
onConnect: state.onConnect,
@@ -67,6 +71,7 @@ const VisProgUI = () => {
const {
nodes, edges,
onNodesChange,
+ onNodesDelete,
onEdgesDelete,
onEdgesChange,
onConnect,
@@ -89,15 +94,36 @@ const VisProgUI = () => {
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
+ const {unregisterWarning, registerWarning} = useFlowStore();
+ useEffect(() => {
+
+ if (checkPhaseChain()) {
+ unregisterWarning(globalWarning,'INCOMPLETE_PROGRAM');
+ } else {
+ // create global warning for incomplete program chain
+ const incompleteProgramWarning : EditorWarning = {
+ scope: {
+ id: globalWarning,
+ handleId: undefined
+ },
+ type: 'INCOMPLETE_PROGRAM',
+ severity: "ERROR",
+ description: "there is no complete phase chain from the startNode to the EndNode"
+ }
+
+ registerWarning(incompleteProgramWarning);
+ }
+ },[edges, registerWarning, unregisterWarning])
return (
-
+
{
onNodeDragStop={endBatchAction}
preventScrolling={scrollable}
onMove={(_, viewport) => setZoom(viewport.zoom)}
+ reconnectRadius={15}
snapToGrid
fitView
proOptions={{hideAttribution: true}}
+ style={{flexGrow: 3}}
>
{/* contains the drag and drop panel for nodes */}
@@ -122,9 +150,13 @@ const VisProgUI = () => {
+
+
+
+
);
};
@@ -143,7 +175,24 @@ function VisualProgrammingUI() {
);
}
-
+
+const checkPhaseChain = (): boolean => {
+ const {nodes, edges} = useFlowStore.getState();
+
+ function checkForCompleteChain(currentNodeId: string): boolean {
+ const outgoingPhases = getOutgoers({id: currentNodeId}, nodes, edges)
+ .filter(node => ["end", "phase"].includes(node.type!));
+
+ if (outgoingPhases.length === 0) return false;
+ if (outgoingPhases.some(node => node.type === "end" )) return true;
+
+ const next = outgoingPhases.map(node => checkForCompleteChain(node.id))
+ .find(result => result);
+ return !!next;
+ }
+
+ return checkForCompleteChain('start');
+};
/**
* houses the entire page, so also UI elements
@@ -152,9 +201,20 @@ function VisualProgrammingUI() {
*/
function VisProgPage() {
const [showSimpleProgram, setShowSimpleProgram] = useState(false);
+ const [programValidity, setProgramValidity] = useState
(true);
+ const {isProgramValid, severityIndex} = useFlowStore();
const setProgramState = useProgramStore((state) => state.setProgramState);
- const runProgram = () => {
+ const validity = () => {return isProgramValid();}
+
+ useEffect(() => {
+ setProgramValidity(validity);
+ // the following eslint disable is required as it wants us to use all possible dependencies for the useEffect statement,
+ // however this would cause unneeded updates
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [severityIndex]);
+
+ const processProgram = () => {
const phases = graphReducer(); // reduce graph
setProgramState({ phases }); // <-- save to store
setShowSimpleProgram(true); // show SimpleProgram
@@ -165,17 +225,19 @@ function VisProgPage() {
return (
);
}
+
+
return (
<>
-
+
>
)
}
diff --git a/src/pages/VisProgPage/VisProgLogic.tsx b/src/pages/VisProgPage/VisProgLogic.tsx
new file mode 100644
index 0000000..3753a3f
--- /dev/null
+++ b/src/pages/VisProgPage/VisProgLogic.tsx
@@ -0,0 +1,43 @@
+import useProgramStore from "../../utils/programStore";
+import orderPhaseNodeArray from "../../utils/orderPhaseNodes";
+import useFlowStore from './visualProgrammingUI/VisProgStores';
+import { NodeReduces } from './visualProgrammingUI/NodeRegistry';
+import type { PhaseNode } from "./visualProgrammingUI/nodes/PhaseNode";
+
+/**
+ * Reduces the graph into its phases' information and recursively calls their reducing function
+ */
+export function graphReducer() {
+ const { nodes } = useFlowStore.getState();
+ return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode [])
+ .map((n) => {
+ const reducer = NodeReduces['phase'];
+ return reducer(n, nodes)
+ });
+}
+
+
+/**
+ * Outputs the prepared program to the console and sends it to the backend
+ */
+export function runProgram() {
+ const phases = graphReducer();
+ const program = {phases}
+ console.log(JSON.stringify(program, null, 2));
+ fetch(
+ "http://localhost:8000/program",
+ {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify(program),
+ }
+ ).then((res) => {
+ if (!res.ok) throw new Error("Failed communicating with the backend.")
+ console.log("Successfully sent the program to the backend.");
+
+ // store reduced program in global program store for further use in the UI
+ // when the program was sent to the backend successfully:
+ useProgramStore.getState().setProgramState(structuredClone(program));
+ }).catch(() => console.log("Failed to send program to the backend."));
+ console.log(program);
+}
\ No newline at end of file
diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts
index 6ad705d..4e45148 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts
+++ b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts
@@ -1,10 +1,18 @@
import type {Edge, Node} from "@xyflow/react";
import type {StateCreator, StoreApi } from 'zustand/vanilla';
+import type {
+ SeverityIndex,
+ WarningRegistry
+} from "./components/EditorWarnings.tsx";
import type {FlowState} from "./VisProgTypes.tsx";
export type FlowSnapshot = {
nodes: Node[];
edges: Edge[];
+ warnings: {
+ warningRegistry: WarningRegistry;
+ severityIndex: SeverityIndex;
+ }
}
/**
@@ -41,7 +49,11 @@ export const UndoRedo = (
*/
const getSnapshot = (state : BaseFlowState) : FlowSnapshot => (structuredClone({
nodes: state.nodes,
- edges: state.edges
+ edges: state.edges,
+ warnings: {
+ warningRegistry: state.editorWarningRegistry,
+ severityIndex: state.severityIndex,
+ }
}));
const initialState = config(set, get, api);
@@ -78,6 +90,8 @@ export const UndoRedo = (
set({
nodes: snapshot.nodes,
edges: snapshot.edges,
+ editorWarningRegistry: snapshot.warnings.warningRegistry,
+ severityIndex: snapshot.warnings.severityIndex,
});
state.future.push(currentSnapshot); // push current to redo
@@ -97,6 +111,8 @@ export const UndoRedo = (
set({
nodes: snapshot.nodes,
edges: snapshot.edges,
+ editorWarningRegistry: snapshot.warnings.warningRegistry,
+ severityIndex: snapshot.warnings.severityIndex,
});
state.past.push(currentSnapshot); // push current to undo
diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx
index 2831748..65df21f 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx
@@ -10,6 +10,7 @@ import {
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {type ConnectionContext, validateConnectionWithRules} from "./HandleRuleLogic.ts";
+import {editorWarningRegistry} from "./components/EditorWarnings.tsx";
import type { FlowState } from './VisProgTypes';
import {
NodeDefaults,
@@ -44,19 +45,18 @@ function createNode(id: string, type: string, position: XYPosition, data: Record
}
}
- //* Initial nodes, created by using createNode. */
- // Start and End don't need to apply the UUID, since they are technically never compiled into a program.
- const startNode = createNode('start', 'start', {x: 110, y: 100}, {label: "Start"}, false)
- const endNode = createNode('end', 'end', {x: 590, y: 100}, {label: "End"}, false)
- const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:235, y:100}, {label: "Phase 1", children : [], isFirstPhase: false, nextPhaseId: null})
+//* Initial nodes, created by using createNode. */
+// Start and End don't need to apply the UUID, since they are technically never compiled into a program.
+const startNode = createNode('start', 'start', {x: 110, y: 100}, {label: "Start"}, false)
+const endNode = createNode('end', 'end', {x: 590, y: 100}, {label: "End"}, false)
+const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:235, y:100}, {label: "Phase 1", children : [], isFirstPhase: false, nextPhaseId: null})
- const initialNodes : Node[] = [startNode, endNode, initialPhaseNode,];
+const initialNodes : Node[] = [startNode, endNode, initialPhaseNode];
// Initial edges, leave empty as setting initial edges...
// ...breaks logic that is dependent on connection events
const initialEdges: Edge[] = [];
-
/**
* useFlowStore contains the implementation for all editor functionality
* and stores the current state of the visual programming editor
@@ -87,7 +87,9 @@ const useFlowStore = create(UndoRedo((set, get) => ({
*/
onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}),
- onNodesDelete: (nodes) => nodes.forEach(node => get().unregisterNodeRules(node.id)),
+ onNodesDelete: (nodes) => nodes.forEach((_node) => {
+ return;
+ }),
onEdgesDelete: (edges) => {
// we make sure any affected nodes get updated to reflect removal of edges
@@ -217,19 +219,32 @@ const useFlowStore = create(UndoRedo((set, get) => ({
* Deletes a node by ID, respecting NodeDeletes rules.
* Also removes all edges connected to that node.
*/
- deleteNode: (nodeId) => {
+ deleteNode: (nodeId, deleteElements) => {
get().pushSnapshot();
// Let's find our node to check if they have a special deletion function
const ourNode = get().nodes.find((n)=>n.id==nodeId);
const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1]
+
+
// If there's no function, OR, our function tells us we can delete it, let's do so...
if (ourFunction == undefined || ourFunction()) {
- set({
- nodes: get().nodes.filter((n) => n.id !== nodeId),
- edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
- })}
+ if (deleteElements){
+ deleteElements({
+ nodes: get().nodes.filter((n) => n.id === nodeId),
+ edges: get().edges.filter((e) => e.source !== nodeId && e.target === nodeId)}
+ ).then(() => {
+ get().unregisterNodeRules(nodeId);
+ get().unregisterWarningsForId(nodeId);
+ });
+ } else {
+ set({
+ nodes: get().nodes.filter((n) => n.id !== nodeId),
+ edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
+ })
+ }
+ }
},
/**
@@ -341,8 +356,12 @@ const useFlowStore = create(UndoRedo((set, get) => ({
})
return { ruleRegistry: registry };
})
- }
+ },
+ ...editorWarningRegistry(get, set),
}))
);
+
+
export default useFlowStore;
+
diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx
index 8ae3cad..a34a3e7 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx
@@ -7,8 +7,9 @@ import type {
OnReconnect,
Node,
OnEdgesDelete,
- OnNodesDelete
+ OnNodesDelete, DeleteElementsOptions
} from '@xyflow/react';
+import type {EditorWarningRegistry} from "./components/EditorWarnings.tsx";
import type {HandleRule} from "./HandleRuleLogic.ts";
import type { NodeTypes } from './NodeRegistry';
import type {FlowSnapshot} from "./EditorUndoRedo.ts";
@@ -68,7 +69,10 @@ export type FlowState = {
* Deletes a node and any connected edges.
* @param nodeId - the ID of the node to delete
*/
- deleteNode: (nodeId: string) => void;
+ deleteNode: (nodeId: string, deleteElements?: (params: DeleteElementsOptions) => Promise<{
+ deletedNodes: Node[]
+ deletedEdges: Edge[]
+ }>) => void;
/**
* Replaces the current nodes array in the store.
@@ -94,7 +98,7 @@ export type FlowState = {
* @param node - the Node object to add
*/
addNode: (node: Node) => void;
-} & UndoRedoState & HandleRuleRegistry;
+} & UndoRedoState & HandleRuleRegistry & EditorWarningRegistry;
export type UndoRedoState = {
// UndoRedo Types
@@ -129,4 +133,7 @@ export type HandleRuleRegistry = {
// cleans up all registered rules of all handles of the provided node
unregisterNodeRules: (nodeId: string) => void
-}
\ No newline at end of file
+}
+
+
+
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx
new file mode 100644
index 0000000..497aac6
--- /dev/null
+++ b/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx
@@ -0,0 +1,245 @@
+/* contains all logic for the VisProgEditor warning system
+*
+* Missing but desirable features:
+* - Warning filtering:
+* - if there is no completely connected chain of startNode-[PhaseNodes]-EndNode
+* then hide any startNode, phaseNode, or endNode specific warnings
+*/
+import useFlowStore from "../VisProgStores.tsx";
+import type {FlowState} from "../VisProgTypes.tsx";
+
+// --| Type definitions |--
+
+export type WarningId = NodeId | "GLOBAL_WARNINGS";
+export type NodeId = string;
+
+
+export type WarningType =
+ | 'MISSING_INPUT'
+ | 'MISSING_OUTPUT'
+ | 'PLAN_IS_UNDEFINED'
+ | 'INCOMPLETE_PROGRAM'
+ | 'NOT_CONNECTED_TO_PROGRAM'
+ | string
+
+export type WarningSeverity =
+ | 'INFO' // Acceptable, but important to be aware of
+ | 'WARNING' // Acceptable, but probably undesirable behavior
+ | 'ERROR' // Prevents running program, should be fixed before running program is allowed
+
+/**
+ * warning scope, include a handleId if the warning is handle specific
+ */
+export type WarningScope = {
+ id: string;
+ handleId?: string;
+}
+
+export type EditorWarning = {
+ scope: WarningScope;
+ type: WarningType;
+ severity: WarningSeverity;
+ description: string;
+};
+
+/**
+ * a scoped WarningKey,
+ * the handleId scoping is only needed for handle specific errors
+ *
+ * "`WarningType`:`handleId`"
+ */
+export type WarningKey = string; // for warnings that can occur on a per-handle basis
+
+/**
+ * a composite key used in the severityIndex
+ *
+ * "`WarningId`|`WarningKey`"
+ */
+export type CompositeWarningKey = string;
+
+export type WarningRegistry = Map>;
+export type SeverityIndex = Map>;
+
+type ZustandSet = (partial: Partial | ((state: FlowState) => Partial)) => void;
+type ZustandGet = () => FlowState;
+
+export type EditorWarningRegistry = {
+ /**
+ * stores all editor warnings
+ */
+ editorWarningRegistry: WarningRegistry;
+ /**
+ * index of warnings by severity
+ */
+ severityIndex: SeverityIndex;
+
+ /**
+ * gets all warnings and returns them as a list of warnings
+ * @returns {EditorWarning[]}
+ */
+ getWarnings: () => EditorWarning[];
+
+ /**
+ * gets all warnings with the current severity
+ * @param {WarningSeverity} warningSeverity
+ * @returns {EditorWarning[]}
+ */
+ getWarningsBySeverity: (warningSeverity: WarningSeverity) => EditorWarning[];
+
+ /**
+ * checks if there are no warnings of breaking severity
+ * @returns {boolean}
+ */
+ isProgramValid: () => boolean;
+
+ /**
+ * registers a warning to the warningRegistry and the SeverityIndex
+ * @param {EditorWarning} warning
+ */
+ registerWarning: (warning: EditorWarning) => void;
+
+ /**
+ * unregisters a warning from the warningRegistry and the SeverityIndex
+ * @param {EditorWarning} warning
+ */
+ unregisterWarning: (id: WarningId, warningKey: WarningKey) => void
+
+ /**
+ * unregisters warnings from the warningRegistry and the SeverityIndex
+ * @param {WarningId} warning
+ */
+ unregisterWarningsForId: (id: WarningId) => void;
+}
+
+// --| implemented logic |--
+
+/**
+ * the id to use for global editor warnings
+ * @type {string}
+ */
+export const globalWarning = "GLOBAL_WARNINGS";
+
+export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : EditorWarningRegistry { return {
+ editorWarningRegistry: new Map>(),
+ severityIndex: new Map([
+ ['INFO', new Set()],
+ ['WARNING', new Set()],
+ ['ERROR', new Set()],
+ ]),
+
+ getWarningsBySeverity: (warningSeverity) => {
+ const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
+ const sIndex = new Map(get().severityIndex);
+ const warningKeys = sIndex.get(warningSeverity);
+ const warnings: EditorWarning[] = [];
+
+ warningKeys?.forEach(
+ (compositeKey) => {
+ const [id, warningKey] = compositeKey.split('|');
+ const warning = wRegistry.get(id)?.get(warningKey);
+
+ if (warning) {
+ warnings.push(warning);
+ }
+ }
+ )
+
+ return warnings;
+ },
+
+ isProgramValid: () => {
+ const sIndex = get().severityIndex;
+ return (sIndex.get("ERROR")!.size === 0);
+ },
+
+ getWarnings: () => Array.from(get().editorWarningRegistry.values())
+ .flatMap(innerMap => Array.from(innerMap.values())),
+
+
+ registerWarning: (warning) => {
+ const { scope: {id, handleId}, type, severity } = warning;
+ const warningKey = handleId ? `${type}:${handleId}` : type;
+ const compositeKey = `${id}|${warningKey}`;
+ const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
+ const sIndex = new Map(get().severityIndex);
+ // add to warning registry
+ if (!wRegistry.has(id)) {
+ wRegistry.set(id, new Map());
+ }
+ wRegistry.get(id)!.set(warningKey, warning);
+
+
+ // add to severityIndex
+ if (!sIndex.get(severity)!.has(compositeKey)) {
+ sIndex.get(severity)!.add(compositeKey);
+ }
+
+ set({
+ editorWarningRegistry: wRegistry,
+ severityIndex: sIndex
+ })
+ },
+
+ unregisterWarning: (id, warningKey) => {
+ const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
+ const sIndex = new Map(get().severityIndex);
+ // verify if the warning was created already
+ const warning = wRegistry.get(id)?.get(warningKey);
+ if (!warning) return;
+
+ // remove from warning registry
+ wRegistry.get(id)!.delete(warningKey);
+
+
+ // remove from severityIndex
+ sIndex.get(warning.severity)!.delete(`${id}|${warningKey}`);
+
+ set({
+ editorWarningRegistry: wRegistry,
+ severityIndex: sIndex
+ })
+ },
+
+ unregisterWarningsForId: (id) => {
+ const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
+ const sIndex = new Map(get().severityIndex);
+
+ const nodeWarnings = wRegistry.get(id);
+
+ // remove from severity index
+ if (nodeWarnings) {
+ nodeWarnings.forEach((warning) => {
+ const warningKey = warning.scope.handleId
+ ? `${warning.type}:${warning.scope.handleId}`
+ : warning.type;
+ sIndex.get(warning.severity)?.delete(`${id}|${warningKey}`);
+ });
+ }
+
+ // remove from warning registry
+ wRegistry.delete(id);
+
+ set({
+ editorWarningRegistry: wRegistry,
+ severityIndex: sIndex
+ })
+ },
+}}
+
+
+
+/**
+ * returns a summary of the warningRegistry
+ * @returns {{info: number, warning: number, error: number, isValid: boolean}}
+ */
+export function warningSummary() {
+ const {severityIndex, isProgramValid} = useFlowStore.getState();
+ return {
+ info: severityIndex.get('INFO')!.size,
+ warning: severityIndex.get('WARNING')!.size,
+ error: severityIndex.get('ERROR')!.size,
+ isValid: isProgramValid(),
+ };
+}
+
+
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx
index 38f03a1..2d9bbd8 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx
@@ -1,4 +1,4 @@
-import {NodeToolbar} from '@xyflow/react';
+import {NodeToolbar, useReactFlow} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {type JSX, useState} from "react";
import {createPortal} from "react-dom";
@@ -30,10 +30,11 @@ type ToolbarProps = {
*/
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
const {nodes, deleteNode} = useFlowStore();
-
+ const { deleteElements } = useReactFlow();
const deleteParentNode = () => {
- deleteNode(nodeId);
+
+ deleteNode(nodeId, deleteElements);
};
const nodeType = nodes.find((node) => node.id === nodeId)?.type as keyof typeof NodeTooltips;
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css
index e0aa5de..582ec2d 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css
+++ b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css
@@ -1,7 +1,16 @@
+:global(.react-flow__handle.source){
+ border-radius: 100%;
+}
+:global(.react-flow__handle.target){
+ border-radius: 15%;
+}
+
+
+
:global(.react-flow__handle.connected) {
background: lightgray;
border-color: green;
- filter: drop-shadow(0 0 0.25rem green);
+ filter: drop-shadow(0 0 0.15rem green);
}
:global(.singleConnectionHandle.connected) {
@@ -16,19 +25,19 @@
:global(.singleConnectionHandle.unconnected){
background: lightsalmon;
border-color: #ff6060;
- filter: drop-shadow(0 0 0.25rem #ff6060);
+ filter: drop-shadow(0 0 0.15rem #ff6060);
}
:global(.react-flow__handle.connectingto) {
background: #ff6060;
border-color: coral;
- filter: drop-shadow(0 0 0.25rem coral);
+ filter: drop-shadow(0 0 0.15rem coral);
}
:global(.react-flow__handle.valid) {
background: #55dd99;
border-color: green;
- filter: drop-shadow(0 0 0.25rem green);
+ filter: drop-shadow(0 0 0.15rem green);
}
:global(.react-flow__handle) {
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx
index 2d3299d..2026b00 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx
@@ -4,7 +4,6 @@ import {
type Connection,
useNodeId, useNodeConnections
} from '@xyflow/react';
-import {useState} from 'react';
import { type HandleRule, useHandleRules} from "../HandleRuleLogic.ts";
import "./RuleBasedHandle.module.css";
@@ -29,21 +28,16 @@ export function MultiConnectionHandle({
handleId: id!
})
- // initialise the handles state with { isValid: true } to show that connections are possible
- const [handleState, setHandleState] = useState<{ isSatisfied: boolean, message?: string }>({ isSatisfied: true });
-
return (
{
const result = validate(connection as Connection);
- setHandleState(result);
return result.isSatisfied;
}}
- title={handleState.message}
/>
);
}
@@ -66,22 +60,18 @@ export function SingleConnectionHandle({
handleId: id!
})
- // initialise the handles state with { isValid: true } to show that connections are possible
- const [handleState, setHandleState] = useState<{ isSatisfied: boolean, message?: string }>({ isSatisfied: true });
return (
{
const result = validate(connection as Connection);
- setHandleState(result);
return result.isSatisfied;
}}
- title={handleState.message}
/>
);
}
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css
new file mode 100644
index 0000000..82168dc
--- /dev/null
+++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css
@@ -0,0 +1,203 @@
+.warnings-sidebar {
+ min-width: auto;
+ max-width: 340px;
+ margin-right: 0;
+ height: 100%;
+ background: canvas;
+ display: flex;
+ flex-direction: row;
+}
+
+.warnings-toggle-bar {
+ background-color: ButtonFace;
+ justify-items: center;
+ align-content: center;
+ width: 1rem;
+ cursor: pointer;
+}
+
+.warnings-toggle-bar.error:first-child:has(.arrow-right){
+ background-color: hsl(from red h s 75%);
+}
+.warnings-toggle-bar.warning:first-child:has(.arrow-right) {
+ background-color: hsl(from orange h s 75%);
+}
+.warnings-toggle-bar.info:first-child:has(.arrow-right) {
+ background-color: hsl(from steelblue h s 75%);
+}
+
+.warnings-toggle-bar:hover {
+ background-color: GrayText !important ;
+ .arrow-left {
+ border-right-color: ButtonFace;
+ transition: transform 0.15s ease-in-out;
+ transform: rotateY(180deg);
+ }
+ .arrow-right {
+ border-left-color: ButtonFace;
+ transition: transform 0.15s ease-in-out;
+ transform: rotateY(180deg);
+ }
+}
+
+
+.warnings-content {
+ width: 320px;
+ flex: 1;
+ flex-direction: column;
+ border-left: 2px solid CanvasText;
+}
+
+.warnings-header {
+ padding: 12px;
+ border-bottom: 2px solid CanvasText;
+}
+
+.severity-tabs {
+ display: flex;
+ gap: 4px;
+}
+
+.severity-tab {
+ flex: 1;
+ padding: 4px;
+ background: ButtonFace;
+ color: GrayText;
+ border: none;
+ cursor: pointer;
+}
+
+.count {
+ padding: 4px;
+ color: GrayText;
+ border: none;
+ cursor: pointer;
+}
+
+.severity-tab.active {
+ color: ButtonText;
+ border: 2px solid currentColor;
+ .count {
+ color: ButtonText;
+ }
+}
+
+.warning-group-header {
+ background: ButtonFace;
+ padding: 6px;
+ font-weight: bold;
+}
+
+.warnings-list {
+ flex: 1;
+ min-height: 0;
+ overflow-y: scroll;
+}
+
+.warnings-empty {
+ margin: auto;
+}
+
+.warning-item {
+ display: flex;
+ flex-direction: column;
+ margin: 5px;
+ gap: 2px;
+ padding: 0;
+ border-radius: 5px;
+ cursor: pointer;
+ color: GrayText;
+}
+
+.warning-item:hover {
+ background: ButtonFace;
+}
+
+.warning-item--error {
+ border: 2px solid red;
+ background-color: hsl(from red h s 96%);
+ .item-header{
+ background-color: red;
+ .type{
+ color: hsl(from red h s 96%);
+ }
+ }
+
+}
+
+.warning-item--error:hover {
+ background-color: hsl(from red h s 75%);
+}
+
+.warning-item--warning {
+ border: 2px solid orange;
+ background-color: hsl(from orange h s 96%);
+ .item-header{
+ background-color: orange;
+ .type{
+ color: hsl(from orange h s 96%);
+ }
+ }
+}
+
+.warning-item--warning:hover {
+ background-color: hsl(from orange h s 75%);
+}
+
+.warning-item--info {
+ border: 2px solid steelblue;
+ background-color: hsl(from steelblue h s 96%);
+ .item-header{
+ background-color: steelblue;
+ .type{
+ color: hsl(from steelblue h s 96%);
+ }
+ }
+}
+
+.warning-item--info:hover {
+ background-color: hsl(from steelblue h s 75%);
+}
+
+.warning-item .item-header {
+ padding: 8px 8px;
+ opacity: 1;
+ font-weight: bolder;
+}
+.warning-item .item-header .type{
+ padding: 2px 8px;
+ font-size: 0.9rem;
+}
+
+.warning-item .description {
+ padding: 5px 10px;
+ font-size: 0.8rem;
+}
+
+.auto-hide {
+ background-color: Canvas;
+ border-top: 2px solid CanvasText;
+ margin-top: auto;
+ width: 100%;
+ height: 2.5rem;
+ display: flex;
+ align-items: center;
+ padding: 0 12px;
+}
+
+/* arrows for toggleBar */
+.arrow-right {
+ width: 0;
+ height: 0;
+ border-top: 0.5rem solid transparent;
+ border-bottom: 0.5rem solid transparent;
+ border-left: 0.6rem solid GrayText;
+}
+
+.arrow-left {
+ width: 0;
+ height: 0;
+ border-top: 0.5rem solid transparent;
+ border-bottom: 0.5rem solid transparent;
+ border-right: 0.6rem solid GrayText;
+}
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx
new file mode 100644
index 0000000..27a4684
--- /dev/null
+++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx
@@ -0,0 +1,225 @@
+import {useReactFlow, useStoreApi} from "@xyflow/react";
+import clsx from "clsx";
+import {useEffect, useState} from "react";
+import useFlowStore from "../VisProgStores.tsx";
+import {
+ warningSummary,
+ type WarningSeverity,
+ type EditorWarning, globalWarning
+} from "./EditorWarnings.tsx";
+import styles from "./WarningSidebar.module.css";
+
+/**
+ * the warning sidebar, shows all warnings
+ *
+ * @returns {React.JSX.Element}
+ * @constructor
+ */
+export function WarningsSidebar() {
+ const warnings = useFlowStore.getState().getWarnings();
+ const [hide, setHide] = useState(false);
+ const [severityFilter, setSeverityFilter] = useState('ALL');
+ const [autoHide, setAutoHide] = useState(false);
+
+ // let autohide change hide status only when autohide is toggled
+ // and allow for user to change the hide state even if autohide is enabled
+ const hasWarnings = warnings.length > 0;
+ useEffect(() => {
+ if (autoHide) {
+ setHide(!hasWarnings);
+ }
+ }, [autoHide, hasWarnings]);
+
+ const filtered = severityFilter === 'ALL'
+ ? warnings
+ : warnings.filter(w => w.severity === severityFilter);
+
+
+ const summary = warningSummary();
+ // Finds the first key where the count > 0
+ const getHighestSeverity = () => {
+ if (summary.error > 0) return styles.error;
+ if (summary.warning > 0) return styles.warning;
+ if (summary.info > 0) return styles.info;
+ return '';
+ };
+
+ return (
+
+
+ );
+}
+
+/**
+ * the header of the warning sidebar, contains severity filtering buttons
+ *
+ * @param {WarningSeverity | "ALL"} severityFilter
+ * @param {(severity: (WarningSeverity | "ALL")) => void} onChange
+ * @returns {React.JSX.Element}
+ * @constructor
+ */
+function WarningsHeader({
+ severityFilter,
+ onChange,
+}: {
+ severityFilter: WarningSeverity | 'ALL';
+ onChange: (severity: WarningSeverity | 'ALL') => void;
+}) {
+ const summary = warningSummary();
+
+ return (
+
+
Warnings
+
+ {(['ALL', 'ERROR', 'WARNING', 'INFO'] as const).map(severity => (
+
+ ))}
+
+
+ );
+}
+
+
+/**
+ * the list of warnings in the warning sidebar
+ *
+ * @param {{warnings: EditorWarning[]}} props
+ * @returns {React.JSX.Element}
+ * @constructor
+ */
+function WarningsList(props: { warnings: EditorWarning[] }) {
+ const splitWarnings = {
+ global: props.warnings.filter(w => w.scope.id === globalWarning),
+ other: props.warnings.filter(w => w.scope.id !== globalWarning),
+ }
+ if (props.warnings.length === 0) {
+ return (
+
+ No warnings!
+
+ )
+ }
+ return (
+
+
global:
+
+ {splitWarnings.global.map((warning) => (
+
+ ))}
+ {splitWarnings.global.length === 0 && "No global warnings!"}
+
+
other:
+
+ {splitWarnings.other.map((warning) => (
+
+ ))}
+ {splitWarnings.other.length === 0 && "No other warnings!"}
+
+
+ );
+}
+
+/**
+ * a single warning in the warning sidebar
+ *
+ * @param {{warning: EditorWarning, key: string}} props
+ * @returns {React.JSX.Element}
+ * @constructor
+ */
+function WarningListItem(props: { warning: EditorWarning, key: string}) {
+ const jumpToNode = useJumpToNode();
+
+ return (
+ jumpToNode(props.warning.scope.id)}
+ >
+
+ {props.warning.type}
+
+
+
+ {props.warning.description}
+
+
+ );
+}
+
+/**
+ * moves the editor to the provided node
+ * @returns {(nodeId: string) => void}
+ */
+function useJumpToNode() {
+ const { getNode, setCenter, getViewport } = useReactFlow();
+ const { addSelectedNodes } = useStoreApi().getState();
+
+
+ return (nodeId: string) => {
+ // user can't jump to global warning, so prevent further logic from running if the warning is a globalWarning
+ if (nodeId === globalWarning) return;
+ const node = getNode(nodeId);
+ if (!node) return;
+
+ const nodeElement = document.querySelector(`.react-flow__node[data-id="${nodeId}"]`) as HTMLElement;
+ const { position } = node;
+ const viewport = getViewport();
+ const { width, height } = nodeElement.getBoundingClientRect();
+
+ //move to node
+ setCenter(
+ position!.x + ((width / viewport.zoom) / 2),
+ position!.y + ((height / viewport.zoom) / 2),
+ {duration: 300, interpolate: "smooth" }
+ ).then(() => {
+ addSelectedNodes([nodeId]);
+ });
+
+
+
+ };
+}
\ No newline at end of file
diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx
index d877aed..3e5d446 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx
@@ -192,7 +192,7 @@ export default function BasicBeliefNode(props: NodeProps) {
+ ]} title="Connect to any number of trigger and/or normNode(-s)"/>
>
);
diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx
index 5c456b5..3bbfa14 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx
@@ -1,12 +1,15 @@
import {
type NodeProps,
Position,
- type Node,
+ type Node, useNodeConnections
} from '@xyflow/react';
+import {useEffect} from "react";
+import type {EditorWarning} from "../components/EditorWarnings.tsx";
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
+import useFlowStore from "../VisProgStores.tsx";
@@ -27,6 +30,27 @@ export type EndNode = Node
* @returns React.JSX.Element
*/
export default function EndNode(props: NodeProps) {
+ const {registerWarning, unregisterWarning} = useFlowStore.getState();
+ const connections = useNodeConnections({
+ id: props.id,
+ handleId: 'target'
+ })
+
+ useEffect(() => {
+ const noConnectionWarning : EditorWarning = {
+ scope: {
+ id: props.id,
+ handleId: 'target'
+ },
+ type: 'MISSING_INPUT',
+ severity: "ERROR",
+ description: "the endNode does not have an incoming connection from a phaseNode"
+ }
+
+ if (connections.length === 0) { registerWarning(noConnectionWarning); }
+ else { unregisterWarning(props.id, `${noConnectionWarning.type}:target`); }
+ }, [connections.length, props.id, registerWarning, unregisterWarning]);
+
return (
<>
@@ -36,7 +60,7 @@ export default function EndNode(props: NodeProps) {
+ ]} title="Connect to a phaseNode"/>
>
);
diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx
index fea9914..1974e99 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx
@@ -1,8 +1,10 @@
import {
type NodeProps,
Position,
- type Node,
+ type Node
} from '@xyflow/react';
+import {useEffect} from "react";
+import type {EditorWarning} from "../components/EditorWarnings.tsx";
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import { TextField } from '../../../../components/TextField';
@@ -44,7 +46,7 @@ export type GoalNode = Node
* @returns React.JSX.Element
*/
export default function GoalNode({id, data}: NodeProps) {
- const {updateNodeData} = useFlowStore();
+ const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
const _nodes = useFlowStore().nodes;
const text_input_id = `goal_${id}_text_input`;
@@ -64,6 +66,24 @@ export default function GoalNode({id, data}: NodeProps) {
updateNodeData(id, {...data, can_fail: value});
}
+
+ useEffect(() => {
+ const noPlanWarning : EditorWarning = {
+ scope: {
+ id: id,
+ handleId: undefined
+ },
+ type: 'PLAN_IS_UNDEFINED',
+ severity: 'ERROR',
+ description: "This goalNode is missing a plan, please make sure to create a plan by using the create plan button"
+ };
+
+ if (!data.plan){
+ registerWarning(noPlanWarning);
+ return;
+ }
+ unregisterWarning(id, noPlanWarning.type);
+ },[data.plan, id, registerWarning, unregisterWarning])
return <>
@@ -118,9 +138,11 @@ export default function GoalNode({id, data}: NodeProps) {
+ ]} title="Connect to any number of phase and/or goalNode(-s)"/>
-
+
diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx
index be5d4ec..924517b 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx
@@ -1,6 +1,7 @@
-import {getConnectedEdges, type Node, type NodeProps, Position} from '@xyflow/react';
-import {useState} from "react";
+import {getConnectedEdges, type Node, type NodeProps, Position, useNodeConnections} from '@xyflow/react';
+import {useEffect, useState} from "react";
import styles from '../../VisProg.module.css';
+import type {EditorWarning} from "../components/EditorWarnings.tsx";
import {Toolbar} from '../components/NodeComponents.tsx';
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
@@ -91,7 +92,7 @@ export const InferredBeliefTooltip = `
*/
export default function InferredBeliefNode(props: NodeProps) {
const data = props.data;
- const { updateNodeData } = useFlowStore();
+ const { updateNodeData, registerWarning, unregisterWarning } = useFlowStore();
// start of as an AND operator, true: "AND", false: "OR"
const [enforceAllBeliefs, setEnforceAllBeliefs] = useState(true);
@@ -109,6 +110,29 @@ export default function InferredBeliefNode(props: NodeProps)
});
}
+ const beliefConnections = useNodeConnections({
+ id: props.id,
+ handleType: "target",
+ })
+
+ useEffect(() => {
+ const noBeliefsWarning : EditorWarning = {
+ scope: {
+ id: props.id,
+ handleId: undefined
+ },
+ type: 'MISSING_INPUT',
+ severity: 'ERROR',
+ description: `This AND/OR node is missing one or more beliefs,
+ please make sure to use both inputs of an AND/OR node`
+ };
+
+ if (beliefConnections.length < 2){
+ registerWarning(noBeliefsWarning);
+ return;
+ }
+ unregisterWarning(props.id, noBeliefsWarning.type);
+ },[beliefConnections.length, props.id, registerWarning, unregisterWarning])
return (
<>
diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx
index 8ee5462..29a03df 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx
@@ -79,10 +79,10 @@ export default function NormNode(props: NodeProps) {
+ ]} title="Connect to any number of phaseNode(-s)"/>
+ ]} title="Connect to a beliefNode or a set of beliefs combined using the AND/OR node"/>
>;
};
diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx
index 50e81b6..e5f2b9b 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx
@@ -1,8 +1,10 @@
import {
type NodeProps,
Position,
- type Node
+ type Node, useNodeConnections
} from '@xyflow/react';
+import {useEffect, useRef} from "react";
+import {type EditorWarning} from "../components/EditorWarnings.tsx";
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
@@ -37,10 +39,107 @@ export type PhaseNode = Node
*/
export default function PhaseNode(props: NodeProps) {
const data = props.data;
- const {updateNodeData} = useFlowStore();
+ const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value});
const label_input_id = `phase_${props.id}_label_input`;
+ const connections = useNodeConnections({
+ id: props.id,
+ handleType: "target",
+ handleId: 'data'
+ })
+
+ const phaseOutCons = useNodeConnections({
+ id: props.id,
+ handleType: "source",
+ handleId: 'source',
+ })
+
+ const phaseInCons = useNodeConnections({
+ id: props.id,
+ handleType: "target",
+ handleId: 'target',
+ })
+
+
+
+ useEffect(() => {
+ const noConnectionWarning : EditorWarning = {
+ scope: {
+ id: props.id,
+ handleId: 'data'
+ },
+ type: 'MISSING_INPUT',
+ severity: "WARNING",
+ description: "the phaseNode has no incoming goals, norms, and/or triggers"
+ }
+
+ if (connections.length === 0) { registerWarning(noConnectionWarning); return; }
+ unregisterWarning(props.id, `${noConnectionWarning.type}:data`);
+ }, [connections.length, props.id, registerWarning, unregisterWarning]);
+
+ useEffect(() => {
+ const notConnectedInfo : EditorWarning = {
+ scope: {
+ id: props.id,
+ handleId: undefined,
+ },
+ type: 'NOT_CONNECTED_TO_PROGRAM',
+ severity: "INFO",
+ description: "The PhaseNode is not connected to other nodes"
+ };
+ const noIncomingPhaseWarning : EditorWarning = {
+ scope: {
+ id: props.id,
+ handleId: 'target'
+ },
+ type: 'MISSING_INPUT',
+ severity: "WARNING",
+ description: "the phaseNode has no incoming connection from a phase or the startNode"
+ }
+ const noOutgoingPhaseWarning : EditorWarning = {
+ scope: {
+ id: props.id,
+ handleId: 'source'
+ },
+ type: 'MISSING_OUTPUT',
+ severity: "WARNING",
+ description: "the phaseNode has no outgoing connection to a phase or the endNode"
+ }
+
+ // register relevant warning and unregister others
+ if (phaseInCons.length === 0 && phaseOutCons.length === 0) {
+ registerWarning(notConnectedInfo);
+ unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
+ unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
+ return;
+ }
+ if (phaseOutCons.length === 0) {
+ registerWarning(noOutgoingPhaseWarning);
+ unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
+ unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
+ return;
+ }
+ if (phaseInCons.length === 0) {
+ registerWarning(noIncomingPhaseWarning);
+ unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
+ unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
+ return;
+ }
+ // unregister all warnings if none should be present
+ unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
+ unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
+ unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
+ }, [phaseInCons.length, phaseOutCons.length, props.id, registerWarning, unregisterWarning]);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (ref.current) {
+ const { width, height } = ref.current.getBoundingClientRect();
+
+ console.log('Node width:', width, 'height:', height);
+ }
+ }, []);
return (
<>
@@ -57,14 +156,14 @@ export default function PhaseNode(props: NodeProps) {
+ ]} title="Connect to a phase or the startNode"/>
+ ]} title="Connect to any number of norm, goal, and TriggerNode(-s)"/>
+ ]} title="Connect to a phase or the endNode"/>
>
);
diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx
index 741b190..925d1dd 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx
@@ -1,12 +1,15 @@
import {
type NodeProps,
Position,
- type Node,
+ type Node, useNodeConnections
} from '@xyflow/react';
+import {useEffect} from "react";
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
+import {type EditorWarning} from "../components/EditorWarnings.tsx";
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
+import useFlowStore from "../VisProgStores.tsx";
export type StartNodeData = {
@@ -25,6 +28,27 @@ export type StartNode = Node
* @returns React.JSX.Element
*/
export default function StartNode(props: NodeProps) {
+ const {registerWarning, unregisterWarning} = useFlowStore.getState();
+ const connections = useNodeConnections({
+ id: props.id,
+ handleId: 'source'
+ })
+
+ useEffect(() => {
+ const noConnectionWarning : EditorWarning = {
+ scope: {
+ id: props.id,
+ handleId: 'source'
+ },
+ type: 'MISSING_OUTPUT',
+ severity: "ERROR",
+ description: "the startNode does not have an outgoing connection to a phaseNode"
+ }
+
+ if (connections.length === 0) { registerWarning(noConnectionWarning); }
+ else { unregisterWarning(props.id, `${noConnectionWarning.type}:source`); }
+ }, [connections.length, props.id, registerWarning, unregisterWarning]);
+
return (
<>
@@ -34,7 +58,7 @@ export default function StartNode(props: NodeProps) {
+ ]} title="Connect to a phaseNode"/>
>
);
diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx
index 3ca0f7a..09c07b3 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx
@@ -1,8 +1,10 @@
import {
type NodeProps,
Position,
- type Node,
+ type Node, useNodeConnections
} from '@xyflow/react';
+import {useEffect} from "react";
+import type {EditorWarning} from "../components/EditorWarnings.tsx";
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
@@ -45,12 +47,77 @@ export type TriggerNode = Node
*/
export default function TriggerNode(props: NodeProps) {
const data = props.data;
- const {updateNodeData} = useFlowStore();
+ const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
const setName= (value: string) => {
updateNodeData(props.id, {...data, name: value})
}
+ const beliefInput = useNodeConnections({
+ id: props.id,
+ handleType: "target",
+ handleId: "TriggerBeliefs"
+ })
+
+ const outputCons = useNodeConnections({
+ id: props.id,
+ handleType: "source",
+ handleId: "TriggerSource"
+ })
+
+ useEffect(() => {
+ const noPhaseConnectionWarning : EditorWarning = {
+ scope: {
+ id: props.id,
+ handleId: 'TriggerSource'
+ },
+ type: 'MISSING_OUTPUT',
+ severity: 'INFO',
+ description: "This triggerNode is missing a condition/belief, please make sure to connect a belief node to "
+ };
+
+ if (outputCons.length === 0){
+ registerWarning(noPhaseConnectionWarning);
+ return;
+ }
+ unregisterWarning(props.id, `${noPhaseConnectionWarning.type}:${noPhaseConnectionWarning.scope.handleId}`);
+ },[outputCons.length, props.id, registerWarning, unregisterWarning])
+
+ useEffect(() => {
+ const noBeliefWarning : EditorWarning = {
+ scope: {
+ id: props.id,
+ handleId: 'TriggerBeliefs'
+ },
+ type: 'MISSING_INPUT',
+ severity: 'ERROR',
+ description: "This triggerNode is missing a condition/belief, please make sure to connect a belief node to "
+ };
+
+ if (beliefInput.length === 0 && outputCons.length !== 0){
+ registerWarning(noBeliefWarning);
+ return;
+ }
+ unregisterWarning(props.id, `${noBeliefWarning.type}:${noBeliefWarning.scope.handleId}`);
+ },[beliefInput.length, outputCons.length, props.id, registerWarning, unregisterWarning])
+
+ useEffect(() => {
+ const noPlanWarning : EditorWarning = {
+ scope: {
+ id: props.id,
+ handleId: undefined
+ },
+ type: 'PLAN_IS_UNDEFINED',
+ severity: 'ERROR',
+ description: "This triggerNode is missing a plan, please make sure to create a plan by using the create plan button"
+ };
+
+ if (!data.plan && outputCons.length !== 0){
+ registerWarning(noPlanWarning);
+ return;
+ }
+ unregisterWarning(props.id, noPlanWarning.type);
+ },[data.plan, outputCons.length, props.id, registerWarning, unregisterWarning])
return <>
@@ -65,7 +132,7 @@ export default function TriggerNode(props: NodeProps) {
Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}
+ ]} title="Connect to any number of phaseNodes"/>
) {
rules={[
allowOnlyConnectionsFromType(['basic_belief']),
]}
+ title="Connect to a beliefNode or a set of beliefs combined using the AND/OR node"
/>
) {
rules={[
allowOnlyConnectionsFromType(['goal']),
]}
+ title="Connect to any number of goalNodes"
/>
{
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
- },
+ }
],
- edges: []
+ edges: [],
+ warnings: {
+ warningRegistry: new Map(),
+ severityIndex: new Map()
+ }
}],
+ ruleRegistry: new Map(),
+ editorWarningRegistry: new Map(),
+ severityIndex: new Map()
});
act(() => {
@@ -53,7 +60,11 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'A'}
}],
- edges: []
+ edges: [],
+ warnings: {
+ warningRegistry: {},
+ severityIndex: {}
+ }
});
expect(state.future).toEqual([]);
});
@@ -80,7 +91,9 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'A'}
}],
- edges: []
+ edges: [],
+ editorWarningRegistry: new Map(),
+ severityIndex: new Map()
});
act(() => {
@@ -114,7 +127,11 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'B'}
}],
- edges: []
+ edges: [],
+ warnings: {
+ warningRegistry: {},
+ severityIndex: {}
+ }
});
});
@@ -140,7 +157,9 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'A'}
}],
- edges: []
+ edges: [],
+ editorWarningRegistry: new Map(),
+ severityIndex: new Map()
});
act(() => {
@@ -176,7 +195,11 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'A'}
}],
- edges: []
+ edges: [],
+ warnings: {
+ warningRegistry: {},
+ severityIndex: {}
+ }
});
});
@@ -199,7 +222,9 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'A'}
}],
- edges: []
+ edges: [],
+ editorWarningRegistry: new Map(),
+ severityIndex: new Map()
});
act(() => { store.getState().beginBatchAction(); });
diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx
index fa98048..d53d1bc 100644
--- a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx
+++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx
@@ -1,5 +1,9 @@
import {act} from '@testing-library/react';
-import type {Connection, Edge, Node} from "@xyflow/react";
+import {
+ type Connection,
+ type Edge,
+ type Node,
+} from "@xyflow/react";
import type {HandleRule, RuleResult} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
import { NodeDisconnections } from "../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts";
import type {PhaseNodeData} from "../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
@@ -398,6 +402,7 @@ describe('FlowStore Functionality', () => {
}]
});
+
act(()=> {
deleteNode(nodeId);
});
diff --git a/test/pages/visProgPage/visualProgrammingUI/components/EditorWarnings.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/EditorWarnings.test.tsx
new file mode 100644
index 0000000..8351c8d
--- /dev/null
+++ b/test/pages/visProgPage/visualProgrammingUI/components/EditorWarnings.test.tsx
@@ -0,0 +1,152 @@
+import { describe, it, expect} from '@jest/globals';
+import {
+ type EditorWarning, warningSummary
+} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx";
+import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
+
+
+function makeWarning(
+ overrides?: Partial
+): EditorWarning {
+ return {
+ scope: { id: 'node-1' },
+ type: 'MISSING_INPUT',
+ severity: 'ERROR',
+ description: 'Missing input',
+ ...overrides,
+ };
+}
+
+describe("editorWarnings", () => {
+ describe('registerWarning', () => {
+ it('registers a node-level warning', () => {
+ const warning = makeWarning();
+ const {registerWarning, getWarnings} = useFlowStore.getState()
+ registerWarning(warning);
+
+ const warnings = getWarnings();
+ expect(warnings).toHaveLength(1);
+ expect(warnings[0]).toEqual(warning);
+ });
+
+ it('registers a handle-level warning with scoped key', () => {
+ const warning = makeWarning({
+ scope: { id: 'node-1', handleId: 'input-1' },
+ });
+ const {registerWarning} = useFlowStore.getState()
+ registerWarning(warning);
+ const nodeWarnings = useFlowStore.getState().editorWarningRegistry.get('node-1');
+ expect(nodeWarnings?.has('MISSING_INPUT:input-1') === true).toBe(true);
+ });
+
+ it('updates severityIndex correctly', () => {
+ const {registerWarning, severityIndex} = useFlowStore.getState()
+ registerWarning(makeWarning());
+ expect(severityIndex.get('ERROR')!.size).toBe(1);
+ });
+ });
+
+ describe('getWarningsBySeverity', () => {
+ it('returns only warnings of requested severity', () => {
+ const {registerWarning, getWarningsBySeverity} = useFlowStore.getState()
+ registerWarning(
+ makeWarning({ severity: 'ERROR' })
+ );
+
+ registerWarning(
+ makeWarning({
+ severity: 'WARNING',
+ type: 'MISSING_OUTPUT',
+ })
+ );
+
+ const errors = getWarningsBySeverity('ERROR');
+ const warnings = getWarningsBySeverity('WARNING');
+
+ expect(errors).toHaveLength(1);
+ expect(warnings).toHaveLength(1);
+ });
+ });
+
+ describe('isProgramValid', () => {
+ it('returns true when no ERROR warnings exist', () => {
+ expect(useFlowStore.getState().isProgramValid()).toBe(true);
+ });
+
+ it('returns false when ERROR warnings exist', () => {
+ const {registerWarning, isProgramValid} = useFlowStore.getState()
+ registerWarning(makeWarning());
+ expect(isProgramValid()).toBe(false);
+ });
+ });
+
+ describe('unregisterWarning', () => {
+ it('removes warning from registry and severityIndex', () => {
+ const warning = makeWarning();
+ const {
+ registerWarning,
+ getWarnings,
+ unregisterWarning,
+ severityIndex
+ } = useFlowStore.getState()
+
+ registerWarning(warning);
+
+ unregisterWarning('node-1', 'MISSING_INPUT');
+
+ expect(getWarnings()).toHaveLength(0);
+ expect(severityIndex.get('ERROR')!.size).toBe(0);
+ });
+
+ it('does nothing if warning does not exist', () => {
+ expect(() =>
+ useFlowStore.getState().unregisterWarning('node-1', 'DOES_NOT_EXIST')
+ ).not.toThrow();
+ });
+ });
+
+ describe('unregisterWarningsForId', () => {
+ it('removes all warnings for a node', () => {
+ const {registerWarning, unregisterWarningsForId, getWarnings, severityIndex} = useFlowStore.getState()
+ registerWarning(
+ makeWarning({
+ scope: { id: 'node-1', handleId: 'h1' },
+ })
+ );
+
+ registerWarning(
+ makeWarning({
+ scope: { id: 'node-1' },
+ type: 'MISSING_OUTPUT',
+ severity: 'WARNING',
+ })
+ );
+
+ unregisterWarningsForId('node-1');
+
+ expect(getWarnings()).toHaveLength(0);
+ expect(
+ severityIndex.get('ERROR')!.size
+ ).toBe(0);
+ expect(
+ severityIndex.get('WARNING')!.size
+ ).toBe(0);
+ });
+ });
+
+ describe('warningSummary', () => {
+ it('returns correct counts and validity', () => {
+ const {registerWarning} = useFlowStore.getState()
+ registerWarning(
+ makeWarning({ severity: 'ERROR' })
+ );
+
+ const summary = warningSummary();
+
+ expect(summary.error).toBe(1);
+ expect(summary.warning).toBe(0);
+ expect(summary.info).toBe(0);
+ expect(summary.isValid).toBe(false);
+ });
+ });
+})
diff --git a/test/pages/visProgPage/visualProgrammingUI/components/WarningSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/WarningSidebar.test.tsx
new file mode 100644
index 0000000..9ccf735
--- /dev/null
+++ b/test/pages/visProgPage/visualProgrammingUI/components/WarningSidebar.test.tsx
@@ -0,0 +1,138 @@
+import {fireEvent, render, screen} from '@testing-library/react';
+import '@testing-library/jest-dom';
+import {useReactFlow, useStoreApi} from "@xyflow/react";
+import {
+ type EditorWarning,
+ globalWarning
+} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx";
+import {WarningsSidebar} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx";
+import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
+
+
+jest.mock('@xyflow/react', () => ({
+ useReactFlow: jest.fn(),
+ useStoreApi: jest.fn(),
+}));
+
+jest.mock('../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx');
+
+function makeWarning(
+ overrides?: Partial
+): EditorWarning {
+ return {
+ scope: { id: 'node-1' },
+ type: 'MISSING_INPUT',
+ severity: 'ERROR',
+ description: 'Missing input',
+ ...overrides,
+ };
+}
+
+describe('WarningsSidebar', () => {
+ let getStateSpy: jest.SpyInstance;
+
+ const setCenter = jest.fn(() => Promise.resolve());
+ const getNode = jest.fn();
+ const addSelectedNodes = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // React Flow hooks
+ (useReactFlow as jest.Mock).mockReturnValue({
+ getNode,
+ setCenter,
+ });
+ (useStoreApi as jest.Mock).mockReturnValue({
+ getState: () => ({ addSelectedNodes }),
+ });
+
+ // Use spyOn to override store
+ const mockWarnings = [
+ makeWarning({ description: 'Node warning', scope: { id: 'node-1' } }),
+ makeWarning({
+ description: 'Global warning',
+ scope: { id: globalWarning },
+ type: 'INCOMPLETE_PROGRAM',
+ severity: 'WARNING',
+ }),
+ makeWarning({
+ description: 'Info warning',
+ scope: { id: 'node-2' },
+ severity: 'INFO',
+ }),
+ ];
+
+ getStateSpy = jest
+ .spyOn(useFlowStore, 'getState')
+ .mockReturnValue({
+ getWarnings: () => mockWarnings,
+ } as any);
+ });
+
+ afterEach(() => {
+ getStateSpy.mockRestore();
+ });
+
+ it('renders warnings header', () => {
+ render();
+ expect(screen.getByText('Warnings')).toBeInTheDocument();
+ });
+
+ it('renders all warning descriptions', () => {
+ render();
+ expect(screen.getByText('Node warning')).toBeInTheDocument();
+ expect(screen.getByText('Global warning')).toBeInTheDocument();
+ expect(screen.getByText('Info warning')).toBeInTheDocument();
+ });
+
+ it('splits global and other warnings correctly', () => {
+ render();
+ expect(screen.getByText('global:')).toBeInTheDocument();
+ expect(screen.getByText('other:')).toBeInTheDocument();
+ });
+
+ it('shows empty state when no warnings exist', () => {
+ getStateSpy.mockReturnValueOnce({
+ getWarnings: () => [],
+ } as any);
+
+ render();
+ expect(screen.getByText('No warnings!')).toBeInTheDocument();
+ });
+
+ it('filters by severity', () => {
+ render();
+ fireEvent.click(screen.getByText('ERROR'));
+
+ expect(screen.getByText('Node warning')).toBeInTheDocument();
+ expect(screen.queryByText('Global warning')).not.toBeInTheDocument();
+ expect(screen.queryByText('Info warning')).not.toBeInTheDocument();
+ });
+
+ it('filters INFO severity correctly', () => {
+ render();
+ fireEvent.click(screen.getByText('INFO'));
+
+ expect(screen.getByText('Info warning')).toBeInTheDocument();
+ expect(screen.queryByText('Node warning')).not.toBeInTheDocument();
+ expect(screen.queryByText('Global warning')).not.toBeInTheDocument();
+ });
+
+ it('clicking global warning does NOT jump', () => {
+ render();
+ fireEvent.click(screen.getByText('Global warning'));
+
+ expect(setCenter).not.toHaveBeenCalled();
+ expect(addSelectedNodes).not.toHaveBeenCalled();
+ });
+
+ it('does nothing if node does not exist', () => {
+ getNode.mockReturnValue(undefined);
+
+ render();
+ fireEvent.click(screen.getByText('Node warning'));
+
+ expect(setCenter).not.toHaveBeenCalled();
+ });
+});
\ No newline at end of file
diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts
index e3382c6..caeda94 100644
--- a/test/setupFlowTests.ts
+++ b/test/setupFlowTests.ts
@@ -1,5 +1,9 @@
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
+import {
+ type CompositeWarningKey,
+ type SeverityIndex,
+} from "../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx";
import useFlowStore from '../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
if (!globalThis.structuredClone) {
@@ -69,8 +73,6 @@ export const mockReactFlow = () => {
};
-
-
beforeAll(() => {
useFlowStore.setState({
nodes: [],
@@ -79,7 +81,13 @@ beforeAll(() => {
future: [],
isBatchAction: false,
edgeReconnectSuccessful: true,
- ruleRegistry: new Map()
+ ruleRegistry: new Map(),
+ editorWarningRegistry: new Map(),
+ severityIndex: new Map([
+ ['INFO', new Set()],
+ ['WARNING', new Set()],
+ ['ERROR', new Set()],
+ ]) as SeverityIndex,
});
});
@@ -92,7 +100,13 @@ afterEach(() => {
future: [],
isBatchAction: false,
edgeReconnectSuccessful: true,
- ruleRegistry: new Map()
+ ruleRegistry: new Map(),
+ editorWarningRegistry: new Map(),
+ severityIndex: new Map([
+ ['INFO', new Set()],
+ ['WARNING', new Set()],
+ ['ERROR', new Set()],
+ ]) as SeverityIndex,
});
});
diff --git a/test/test-utils/test-utils.tsx b/test/test-utils/test-utils.tsx
index a39e01a..157ea19 100644
--- a/test/test-utils/test-utils.tsx
+++ b/test/test-utils/test-utils.tsx
@@ -2,6 +2,9 @@
import { render, type RenderOptions } from '@testing-library/react';
import { type ReactElement, type ReactNode } from 'react';
import { ReactFlowProvider } from '@xyflow/react';
+import {mockReactFlow} from "../setupFlowTests.ts";
+
+mockReactFlow();
/**
* Custom render function that wraps components with necessary providers