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