Merge remote-tracking branch 'origin/temp_screenshot_manual' into feat/experiment-logs
# Conflicts: # src/pages/MonitoringPage/MonitoringPage.tsx
This commit is contained in:
515
package-lock.json
generated
515
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -93,11 +93,6 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.restartPhase{
|
||||
background-color: rgb(255, 123, 0);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.restartExperiment{
|
||||
background-color: red;
|
||||
color: white;
|
||||
|
||||
@@ -31,11 +31,6 @@ import {
|
||||
RobotConnected
|
||||
} from './MonitoringPageComponents';
|
||||
import ExperimentLogs from "./components/ExperimentLogs.tsx";
|
||||
import Replay from "../../components/Icons/Replay.tsx";
|
||||
import Pause from "../../components/Icons/Pause.tsx";
|
||||
import Play from "../../components/Icons/Play.tsx";
|
||||
import Next from "../../components/Icons/Next.tsx";
|
||||
import Redo from "../../components/Icons/Redo.tsx";
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 1. State management
|
||||
@@ -107,7 +102,6 @@ function useExperimentLogic() {
|
||||
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex, phaseNames]);
|
||||
|
||||
const handleStatusUpdate = useCallback((data: unknown) => {
|
||||
|
||||
const payload = data as CondNormsStateUpdate;
|
||||
if (payload.type !== 'cond_norms_state_update') return;
|
||||
|
||||
@@ -147,7 +141,7 @@ function useExperimentLogic() {
|
||||
}
|
||||
}, [setProgramState]);
|
||||
|
||||
const handleControlAction = async (action: "pause" | "play" | "nextPhase" | "resetPhase") => {
|
||||
const handleControlAction = async (action: "pause" | "play" | "nextPhase") => {
|
||||
try {
|
||||
setLoading(true);
|
||||
switch (action) {
|
||||
@@ -162,7 +156,6 @@ function useExperimentLogic() {
|
||||
case "nextPhase":
|
||||
await nextPhase();
|
||||
break;
|
||||
// Case for resetPhase if implemented in API
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -230,7 +223,7 @@ function ControlPanel({
|
||||
}: {
|
||||
loading: boolean,
|
||||
isPlaying: boolean,
|
||||
onAction: (a: "pause" | "play" | "nextPhase" | "resetPhase") => void,
|
||||
onAction: (a: "pause" | "play" | "nextPhase") => void,
|
||||
onReset: () => void
|
||||
}) {
|
||||
return (
|
||||
@@ -241,31 +234,25 @@ function ControlPanel({
|
||||
className={!isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}
|
||||
onClick={() => onAction("pause")}
|
||||
disabled={loading}
|
||||
><Pause /></button>
|
||||
>❚❚</button>
|
||||
|
||||
<button
|
||||
className={isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}
|
||||
onClick={() => onAction("play")}
|
||||
disabled={loading}
|
||||
><Play /></button>
|
||||
>▶</button>
|
||||
|
||||
<button
|
||||
className={styles.next}
|
||||
onClick={() => onAction("nextPhase")}
|
||||
disabled={loading}
|
||||
><Next /></button>
|
||||
|
||||
<button
|
||||
className={styles.restartPhase}
|
||||
onClick={() => onAction("resetPhase")}
|
||||
disabled={loading}
|
||||
><Redo /></button>
|
||||
>⏭</button>
|
||||
|
||||
<button
|
||||
className={styles.restartExperiment}
|
||||
onClick={onReset}
|
||||
disabled={loading}
|
||||
><Replay /></button>
|
||||
>⟲</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -285,14 +272,17 @@ function PhaseDashboard({
|
||||
setActiveIds: React.Dispatch<React.SetStateAction<Record<string, boolean>>>,
|
||||
goalIndex: number
|
||||
}) {
|
||||
const getGoals = useProgramStore((s) => s.getGoalsInPhase);
|
||||
const getGoalsWithDepth = useProgramStore((s) => s.getGoalsWithDepth);
|
||||
const getTriggers = useProgramStore((s) => s.getTriggersInPhase);
|
||||
const getNorms = useProgramStore((s) => s.getNormsInPhase);
|
||||
|
||||
// Prepare data view models
|
||||
const goals = (getGoals(phaseId) as GoalNode[]).map(g => ({
|
||||
const goals = getGoalsWithDepth(phaseId).map((g) => ({
|
||||
...g,
|
||||
achieved: activeIds[g.id] ?? false,
|
||||
id: g.id as string,
|
||||
name: g.name as string,
|
||||
achieved: activeIds[g.id as string] ?? false,
|
||||
level: g.level, // Pass this new property to the UI
|
||||
}));
|
||||
|
||||
const triggers = (getTriggers(phaseId) as TriggerNode[]).map(t => ({
|
||||
|
||||
@@ -32,16 +32,6 @@ export async function nextPhase(): Promise<void> {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sends an API call to the CB for going to reset the currect phase
|
||||
* In case we can't go to the next phase, the function will throw an error.
|
||||
*/
|
||||
export async function resetPhase(): Promise<void> {
|
||||
const type = "reset_phase"
|
||||
const context = ""
|
||||
sendAPICall(type, context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an API call to the CB for going to pause experiment
|
||||
*/
|
||||
|
||||
@@ -91,13 +91,14 @@ export const DirectSpeechInput: React.FC = () => {
|
||||
};
|
||||
|
||||
// --- interface for goals/triggers/norms/conditional norms ---
|
||||
type StatusItem = {
|
||||
export type StatusItem = {
|
||||
id?: string | number;
|
||||
achieved?: boolean;
|
||||
description?: string;
|
||||
label?: string;
|
||||
norm?: string;
|
||||
name?: string;
|
||||
level?: number;
|
||||
};
|
||||
|
||||
interface StatusListProps {
|
||||
@@ -129,7 +130,7 @@ export const StatusList: React.FC<StatusListProps> = ({
|
||||
const isCurrentGoal = type === 'goal' && idx === currentGoalIndex;
|
||||
const canOverride = (showIndicator && !isActive) || (type === 'cond_norm' && isActive);
|
||||
|
||||
|
||||
const indentation = (item.level || 0) * 20;
|
||||
|
||||
const handleOverrideClick = () => {
|
||||
if (!canOverride) return;
|
||||
@@ -147,7 +148,10 @@ export const StatusList: React.FC<StatusListProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<li key={item.id ?? idx} className={styles.statusItem}>
|
||||
<li key={item.id ?? idx}
|
||||
className={styles.statusItem}
|
||||
style={{ paddingLeft: `${indentation}px` }}
|
||||
>
|
||||
{showIndicator && (
|
||||
<span
|
||||
className={`${styles.statusIndicator} ${isActive ? styles.active : styles.inactive} ${canOverride ? styles.clickable : ''}`}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
/* ---------- Layout ---------- */
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1e1e1e;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: clamp(0.75rem, 2vw, 1.25rem);
|
||||
background: #2a2a2a;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
font-size: clamp(1rem, 2.2vw, 1.4rem);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: #111;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.controls button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ---------- Content ---------- */
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 2%;
|
||||
}
|
||||
|
||||
/* ---------- Grid ---------- */
|
||||
|
||||
.phaseGrid {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
gap: 2%;
|
||||
}
|
||||
|
||||
/* ---------- Box ---------- */
|
||||
|
||||
.box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
color: #1e1e1e;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.boxHeader {
|
||||
padding: 0.6rem 0.9rem;
|
||||
background: linear-gradient(135deg, #dcdcdc, #e9e9e9);
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-size: clamp(0.9rem, 1.5vw, 1.05rem);
|
||||
border-bottom: 1px solid #cfcfcf;
|
||||
}
|
||||
|
||||
.boxContent {
|
||||
flex: 1;
|
||||
padding: 0.8rem 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ---------- Lists ---------- */
|
||||
|
||||
.iconList {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.iconList li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: clamp(0.85rem, 1.3vw, 1rem);
|
||||
}
|
||||
|
||||
.bulletList {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.bulletList li {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* ---------- Icons ---------- */
|
||||
|
||||
.successIcon,
|
||||
.failIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.successIcon {
|
||||
background: #3cb371;
|
||||
}
|
||||
|
||||
.failIcon {
|
||||
background: #e5533d;
|
||||
}
|
||||
|
||||
/* ---------- Empty ---------- */
|
||||
|
||||
.empty {
|
||||
opacity: 0.55;
|
||||
font-style: italic;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.phaseGrid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: repeat(4, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.leftControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
background: transparent;
|
||||
border: 1px solid #555;
|
||||
color: #ddd;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.backButton:hover {
|
||||
background: #333;
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import React from "react";
|
||||
import styles from "./SimpleProgram.module.css";
|
||||
import useProgramStore from "../../utils/programStore.ts";
|
||||
|
||||
/**
|
||||
* Generic container box with a header and content area.
|
||||
*/
|
||||
type BoxProps = {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const Box: React.FC<BoxProps> = ({ title, children }) => (
|
||||
<div className={styles.box}>
|
||||
<div className={styles.boxHeader}>{title}</div>
|
||||
<div className={styles.boxContent}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders a list of goals for a phase.
|
||||
* Expects goal-like objects from the program store.
|
||||
*/
|
||||
const GoalList: React.FC<{ goals: unknown[] }> = ({ goals }) => {
|
||||
if (!goals.length) {
|
||||
return <p className={styles.empty}>No goals defined.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={styles.iconList}>
|
||||
{goals.map((g, idx) => {
|
||||
const goal = g as {
|
||||
id?: string;
|
||||
description?: string;
|
||||
achieved?: boolean;
|
||||
};
|
||||
|
||||
return (
|
||||
<li key={goal.id ?? idx}>
|
||||
<span
|
||||
className={
|
||||
goal.achieved ? styles.successIcon : styles.failIcon
|
||||
}
|
||||
>
|
||||
{goal.achieved ? "✔" : "✖"}
|
||||
</span>
|
||||
{goal.description ?? "Unnamed goal"}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a list of triggers for a phase.
|
||||
*/
|
||||
const TriggerList: React.FC<{ triggers: unknown[] }> = ({ triggers }) => {
|
||||
if (!triggers.length) {
|
||||
return <p className={styles.empty}>No triggers defined.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={styles.iconList}>
|
||||
{triggers.map((t, idx) => {
|
||||
const trigger = t as {
|
||||
id?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
return (
|
||||
<li key={trigger.id ?? idx}>
|
||||
<span className={styles.failIcon}>✖</span>
|
||||
{trigger.label ?? "Unnamed trigger"}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a list of norms for a phase.
|
||||
*/
|
||||
const NormList: React.FC<{ norms: unknown[] }> = ({ norms }) => {
|
||||
if (!norms.length) {
|
||||
return <p className={styles.empty}>No norms defined.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={styles.bulletList}>
|
||||
{norms.map((n, idx) => {
|
||||
const norm = n as {
|
||||
id?: string;
|
||||
norm?: string;
|
||||
};
|
||||
|
||||
return <li key={norm.id ?? idx}>{norm.norm ?? "Unnamed norm"}</li>;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays all phase-related information in a grid layout.
|
||||
*/
|
||||
type PhaseGridProps = {
|
||||
norms: unknown[];
|
||||
goals: unknown[];
|
||||
triggers: unknown[];
|
||||
};
|
||||
|
||||
const PhaseGrid: React.FC<PhaseGridProps> = ({
|
||||
norms,
|
||||
goals,
|
||||
triggers,
|
||||
}) => (
|
||||
<div className={styles.phaseGrid}>
|
||||
<Box title="Norms">
|
||||
<NormList norms={norms} />
|
||||
</Box>
|
||||
|
||||
<Box title="Triggers">
|
||||
<TriggerList triggers={triggers} />
|
||||
</Box>
|
||||
|
||||
<Box title="Goals">
|
||||
<GoalList goals={goals} />
|
||||
</Box>
|
||||
|
||||
<Box title="Conditional Norms">
|
||||
<p className={styles.empty}>No conditional norms defined.</p>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Main program viewer.
|
||||
* Reads all data from the program store and allows
|
||||
* navigating between phases.
|
||||
*/
|
||||
const SimpleProgram: React.FC = () => {
|
||||
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
|
||||
const getNormsInPhase = useProgramStore((s) => s.getNormsInPhase);
|
||||
const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase);
|
||||
const getTriggersInPhase = useProgramStore((s) => s.getTriggersInPhase);
|
||||
|
||||
const phaseIds = getPhaseIds();
|
||||
const [phaseIndex, setPhaseIndex] = React.useState(0);
|
||||
|
||||
if (phaseIds.length === 0) {
|
||||
return <p className={styles.empty}>No program loaded.</p>;
|
||||
}
|
||||
|
||||
const phaseId = phaseIds[phaseIndex];
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<header className={styles.header}>
|
||||
<h2>
|
||||
Phase {phaseIndex + 1} / {phaseIds.length}
|
||||
</h2>
|
||||
|
||||
<div className={styles.controls}>
|
||||
<button
|
||||
disabled={phaseIndex === 0}
|
||||
onClick={() => setPhaseIndex((i) => i - 1)}
|
||||
>
|
||||
◀ Prev
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={phaseIndex === phaseIds.length - 1}
|
||||
onClick={() => setPhaseIndex((i) => i + 1)}
|
||||
>
|
||||
Next ▶
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={styles.content}>
|
||||
<PhaseGrid
|
||||
norms={getNormsInPhase(phaseId)}
|
||||
goals={getGoalsInPhase(phaseId)}
|
||||
triggers={getTriggersInPhase(phaseId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleProgram;
|
||||
@@ -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 (
|
||||
<div className={`${styles.innerEditorContainer} round-lg border-lg`} style={({'--flow-zoom': zoom} as CSSProperties)}>
|
||||
<div className={`${styles.innerEditorContainer} round-lg border-lg flex-row`} style={({'--flow-zoom': zoom} as CSSProperties)}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
||||
nodeTypes={NodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onNodesDelete={onNodesDelete}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onReconnect={onReconnect}
|
||||
@@ -108,9 +134,11 @@ const VisProgUI = () => {
|
||||
onNodeDragStop={endBatchAction}
|
||||
preventScrolling={scrollable}
|
||||
onMove={(_, viewport) => setZoom(viewport.zoom)}
|
||||
reconnectRadius={15}
|
||||
snapToGrid
|
||||
fitView
|
||||
proOptions={{hideAttribution: true}}
|
||||
style={{flexGrow: 3}}
|
||||
>
|
||||
<Panel position="top-center" className={styles.dndPanel}>
|
||||
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
|
||||
@@ -119,12 +147,16 @@ const VisProgUI = () => {
|
||||
<SaveLoadPanel></SaveLoadPanel>
|
||||
</Panel>
|
||||
<Panel position="bottom-center">
|
||||
<button onClick={() => undo()}>undo</button>
|
||||
<button onClick={() => undo()}>Undo</button>
|
||||
<button onClick={() => redo()}>Redo</button>
|
||||
</Panel>
|
||||
<Panel position="center-right" className={warningStyles.warningsSidebar}>
|
||||
<WarningsSidebar/>
|
||||
</Panel>
|
||||
<Controls/>
|
||||
<Background/>
|
||||
</ReactFlow>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -143,7 +175,24 @@ function VisualProgrammingUI() {
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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<boolean>(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 (
|
||||
<div>
|
||||
<button className={styles.backButton} onClick={() => setShowSimpleProgram(false)}>
|
||||
Back to Editor ◀
|
||||
Back to Editor ◀
|
||||
</button>
|
||||
<MonitoringPage/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<VisualProgrammingUI/>
|
||||
<button onClick={runProgram}>run program</button>
|
||||
<button onClick={processProgram} disabled={!programValidity}>Run Program</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
43
src/pages/VisProgPage/VisProgLogic.tsx
Normal file
43
src/pages/VisProgPage/VisProgLogic.tsx
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -107,4 +107,16 @@ export function useHandleRules(
|
||||
// finally we return a function that evaluates all rules using the created context
|
||||
return evaluateRules(targetRules, connection, context);
|
||||
};
|
||||
}
|
||||
|
||||
export function validateConnectionWithRules(
|
||||
connection: Connection,
|
||||
context: ConnectionContext
|
||||
): RuleResult {
|
||||
const rules = useFlowStore.getState().getTargetRules(
|
||||
connection.target!,
|
||||
connection.targetHandle!
|
||||
);
|
||||
|
||||
return evaluateRules(rules,connection, context);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type XYPosition,
|
||||
} 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,
|
||||
@@ -43,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
|
||||
@@ -86,7 +87,9 @@ const useFlowStore = create<FlowState>(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
|
||||
@@ -129,7 +132,41 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
|
||||
* Handles reconnecting an edge between nodes.
|
||||
*/
|
||||
onReconnect: (oldEdge, newConnection) => {
|
||||
get().edgeReconnectSuccessful = true;
|
||||
|
||||
function createContext(
|
||||
source: {id: string, handleId: string},
|
||||
target: {id: string, handleId: string}
|
||||
) : ConnectionContext {
|
||||
const edges = get().edges;
|
||||
const targetConnections = edges.filter(edge => edge.target === target.id && edge.targetHandle === target.handleId).length
|
||||
return {
|
||||
connectionCount: targetConnections,
|
||||
source: source,
|
||||
target: target
|
||||
}
|
||||
}
|
||||
|
||||
// connection validation
|
||||
const context: ConnectionContext = oldEdge.source === newConnection.source
|
||||
? createContext({id: newConnection.source, handleId: newConnection.sourceHandle!}, {id: newConnection.target, handleId: newConnection.targetHandle!})
|
||||
: createContext({id: newConnection.target, handleId: newConnection.targetHandle!}, {id: newConnection.source, handleId: newConnection.sourceHandle!});
|
||||
|
||||
const result = validateConnectionWithRules(
|
||||
newConnection,
|
||||
context
|
||||
);
|
||||
|
||||
if (!result.isSatisfied) {
|
||||
set({
|
||||
edges: get().edges.map(e =>
|
||||
e.id === oldEdge.id ? oldEdge : e
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// further reconnect logic
|
||||
set({ edgeReconnectSuccessful: true });
|
||||
set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) });
|
||||
|
||||
// We make sure to perform any required data updates on the newly reconnected nodes
|
||||
@@ -182,19 +219,32 @@ const useFlowStore = create<FlowState>(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),
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -306,8 +356,12 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
|
||||
})
|
||||
return { ruleRegistry: registry };
|
||||
})
|
||||
}
|
||||
},
|
||||
...editorWarningRegistry(get, set),
|
||||
}))
|
||||
);
|
||||
|
||||
|
||||
|
||||
export default useFlowStore;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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<WarningId , Map<WarningKey, EditorWarning>>;
|
||||
export type SeverityIndex = Map<WarningSeverity, Set<CompositeWarningKey>>;
|
||||
|
||||
type ZustandSet = (partial: Partial<FlowState> | ((state: FlowState) => Partial<FlowState>)) => 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<NodeId, Map<WarningKey, EditorWarning>>(),
|
||||
severityIndex: new Map([
|
||||
['INFO', new Set<CompositeWarningKey>()],
|
||||
['WARNING', new Set<CompositeWarningKey>()],
|
||||
['ERROR', new Set<CompositeWarningKey>()],
|
||||
]),
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<Handle
|
||||
{...otherProps}
|
||||
id={id}
|
||||
type={type}
|
||||
className={"multiConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected")}
|
||||
className={"multiConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected") + ` ${type}`}
|
||||
isValidConnection={(connection) => {
|
||||
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 (
|
||||
<Handle
|
||||
{...otherProps}
|
||||
id={id}
|
||||
type={type}
|
||||
className={"singleConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected")}
|
||||
className={"singleConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected") + ` ${type}`}
|
||||
isConnectable={connections.length === 0}
|
||||
isValidConnection={(connection) => {
|
||||
const result = validate(connection as Connection);
|
||||
setHandleState(result);
|
||||
return result.isSatisfied;
|
||||
}}
|
||||
title={handleState.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ export default function SaveLoadPanel() {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text) as SavedProject;
|
||||
if (!parsed.nodes || !parsed.edges) throw new Error("Invalid file format");
|
||||
const {nodes, unregisterWarningsForId} = useFlowStore.getState();
|
||||
nodes.forEach((node) => {unregisterWarningsForId(node.id);});
|
||||
setNodes(parsed.nodes);
|
||||
setEdges(parsed.edges);
|
||||
} catch (e) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<WarningSeverity | 'ALL'>('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 (
|
||||
<aside className={`flex-row`} >
|
||||
<div
|
||||
className={`${styles.warningsToggleBar} ${getHighestSeverity()}`}
|
||||
onClick={() => setHide(!hide)}
|
||||
title={"toggle warnings"}
|
||||
>
|
||||
<div className={`${hide ? styles.arrowRight : styles.arrowLeft}`}></div>
|
||||
</div>
|
||||
<div
|
||||
id="warningSidebar"
|
||||
className={styles.warningsContent}
|
||||
style={hide ? {display: "none"} : {display: "flex"}}
|
||||
>
|
||||
<WarningsHeader
|
||||
severityFilter={severityFilter}
|
||||
onChange={setSeverityFilter}
|
||||
/>
|
||||
|
||||
<WarningsList warnings={filtered} />
|
||||
<div className={styles.autoHide}>
|
||||
<input
|
||||
id="autoHideSwitch"
|
||||
type={"checkbox"}
|
||||
checked={autoHide}
|
||||
onChange={(e) => setAutoHide(e.target.checked)}
|
||||
/><label>Hide if there are no warnings</label>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className={styles.warningsHeader}>
|
||||
<h3>Warnings</h3>
|
||||
<div className={styles.severityTabs}>
|
||||
{(['ALL', 'ERROR', 'WARNING', 'INFO'] as const).map(severity => (
|
||||
<button
|
||||
key={severity}
|
||||
className={clsx(styles.severityTab, severityFilter === severity && styles.active)}
|
||||
onClick={() => onChange(severity)}
|
||||
>
|
||||
{severity}
|
||||
{severity !== 'ALL' && (
|
||||
<span className={styles.count}>
|
||||
{summary[severity.toLowerCase() as keyof typeof summary]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className={styles.warningsEmpty}>
|
||||
No warnings!
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={styles.warningsList}>
|
||||
<div className={styles.warningGroupHeader}>global:</div>
|
||||
<div className={styles.warningsGroup}>
|
||||
{splitWarnings.global.map((warning) => (
|
||||
<WarningListItem warning={warning} key={`${warning.scope.id}|${warning.type}` + (warning.scope.handleId
|
||||
? `:${warning.scope.handleId}`
|
||||
: "")}
|
||||
/>
|
||||
))}
|
||||
{splitWarnings.global.length === 0 && "No global warnings!"}
|
||||
</div>
|
||||
<div className={styles.warningGroupHeader}>other:</div>
|
||||
<div className={styles.warningsGroup}>
|
||||
{splitWarnings.other.map((warning) => (
|
||||
<WarningListItem warning={warning} key={`${warning.scope.id}|${warning.type}` + (warning.scope.handleId
|
||||
? `:${warning.scope.handleId}`
|
||||
: "")}
|
||||
/>
|
||||
))}
|
||||
{splitWarnings.other.length === 0 && "No other warnings!"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div
|
||||
className={clsx(styles.warningItem, styles[`warning-item--${props.warning.severity.toLowerCase()}`],)}
|
||||
onClick={() => jumpToNode(props.warning.scope.id)}
|
||||
>
|
||||
<div className={styles.itemHeader}>
|
||||
<span className={styles.type}>{props.warning.type}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.description}>
|
||||
{props.warning.description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]);
|
||||
});
|
||||
|
||||
|
||||
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
|
||||
import useFlowStore from '../VisProgStores.tsx';
|
||||
import { TextField } from '../../../../components/TextField.tsx';
|
||||
import { MultilineTextField } from '../../../../components/MultilineTextField.tsx';
|
||||
import {noMatchingLeftRightBelief} from "./BeliefGlobals.ts";
|
||||
|
||||
/**
|
||||
* The default data structure for a BasicBelief node
|
||||
@@ -112,9 +113,7 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
|
||||
updateNodeData(props.id, {...data, belief: {...data.belief, description: value}});
|
||||
}
|
||||
|
||||
// Use this
|
||||
const emotionOptions = ["Happy", "Angry", "Sad", "Cheerful"]
|
||||
|
||||
const emotionOptions = ["sad", "angry", "surprise", "fear", "happy", "disgust", "neutral"];
|
||||
|
||||
let placeholder = ""
|
||||
let wrapping = ""
|
||||
@@ -189,8 +188,9 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
|
||||
</div>
|
||||
)}
|
||||
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"},{nodeType:"InferredBelief",handleId:"inferred_belief"}]),
|
||||
]}/>
|
||||
noMatchingLeftRightBelief,
|
||||
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"},{nodeType:"InferredBelief",handleId:"inferred_belief"}]),
|
||||
]} title="Connect to any number of trigger and/or normNode(-s)"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<EndNodeData>
|
||||
* @returns React.JSX.Element
|
||||
*/
|
||||
export default function EndNode(props: NodeProps<EndNode>) {
|
||||
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 (
|
||||
<>
|
||||
<Toolbar nodeId={props.id} allowDelete={false}/>
|
||||
@@ -36,7 +60,7 @@ export default function EndNode(props: NodeProps<EndNode>) {
|
||||
</div>
|
||||
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
|
||||
allowOnlyConnectionsFromType(["phase"])
|
||||
]}/>
|
||||
]} title="Connect to a phaseNode"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<GoalNodeData>
|
||||
* @returns React.JSX.Element
|
||||
*/
|
||||
export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
||||
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<GoalNode>) {
|
||||
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 <>
|
||||
<Toolbar nodeId={id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeGoal} flex-col gap-sm`}>
|
||||
@@ -118,9 +138,11 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
||||
</div>
|
||||
<MultiConnectionHandle type="source" position={Position.Right} id="GoalSource" rules={[
|
||||
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
|
||||
]}/>
|
||||
]} title="Connect to any number of phase and/or goalNode(-s)"/>
|
||||
|
||||
<MultiConnectionHandle type="target" position={Position.Bottom} id="GoalTarget" rules={[allowOnlyConnectionsFromType(["goal"])]}/>
|
||||
<MultiConnectionHandle type="target" position={Position.Bottom} id="GoalTarget" rules={[
|
||||
allowOnlyConnectionsFromType(["goal"])]
|
||||
} title="Connect to any number of goalNode(-s)"/>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { InferredBeliefNodeData } from "./InferredBeliefNode.tsx";
|
||||
* Default data for this node
|
||||
*/
|
||||
export const InferredBeliefNodeDefaults: InferredBeliefNodeData = {
|
||||
label: "Inferred Belief",
|
||||
label: "AND/OR",
|
||||
droppable: true,
|
||||
inferredBelief: {
|
||||
left: undefined,
|
||||
|
||||
@@ -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<InferredBeliefNode>) {
|
||||
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<InferredBeliefNode>)
|
||||
});
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||
|
||||
@@ -79,10 +79,10 @@ export default function NormNode(props: NodeProps<NormNode>) {
|
||||
|
||||
<MultiConnectionHandle type="source" position={Position.Right} id="norms" rules={[
|
||||
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}])
|
||||
]}/>
|
||||
]} title="Connect to any number of phaseNode(-s)"/>
|
||||
<SingleConnectionHandle type="target" position={Position.Bottom} id="NormBeliefs" rules={[
|
||||
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"])
|
||||
]}/>
|
||||
]} title="Connect to a beliefNode or a set of beliefs combined using the AND/OR node"/>
|
||||
</div>
|
||||
</>;
|
||||
};
|
||||
|
||||
@@ -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<PhaseNodeData>
|
||||
*/
|
||||
export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
||||
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<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const { width, height } = ref.current.getBoundingClientRect();
|
||||
|
||||
console.log('Node width:', width, 'height:', height);
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||
@@ -57,14 +156,14 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
||||
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
|
||||
noSelfConnections,
|
||||
allowOnlyConnectionsFromType(["phase", "start"]),
|
||||
]}/>
|
||||
]} title="Connect to a phase or the startNode"/>
|
||||
<MultiConnectionHandle type="target" position={Position.Bottom} id="data" rules={[
|
||||
allowOnlyConnectionsFromType(["norm", "goal", "trigger"])
|
||||
]}/>
|
||||
]} title="Connect to any number of norm, goal, and TriggerNode(-s)"/>
|
||||
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||
noSelfConnections,
|
||||
allowOnlyConnectionsFromType(["phase", "end"]),
|
||||
]}/>
|
||||
]} title="Connect to a phase or the endNode"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<StartNodeData>
|
||||
* @returns React.JSX.Element
|
||||
*/
|
||||
export default function StartNode(props: NodeProps<StartNode>) {
|
||||
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 (
|
||||
<>
|
||||
<Toolbar nodeId={props.id} allowDelete={false}/>
|
||||
@@ -34,7 +58,7 @@ export default function StartNode(props: NodeProps<StartNode>) {
|
||||
</div>
|
||||
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"target"}])
|
||||
]}/>
|
||||
]} title="Connect to a phaseNode"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,14 +47,79 @@ export type TriggerNode = Node<TriggerNodeData>
|
||||
*/
|
||||
export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
||||
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 <>
|
||||
|
||||
|
||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
|
||||
<TextField
|
||||
@@ -65,15 +132,16 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
||||
<div className={"flex-row gap-md"}>Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}</div>
|
||||
<MultiConnectionHandle type="source" position={Position.Right} id="TriggerSource" rules={[
|
||||
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
|
||||
]}/>
|
||||
]} title="Connect to any number of phaseNodes"/>
|
||||
<SingleConnectionHandle
|
||||
type="target"
|
||||
position={Position.Bottom}
|
||||
id="TriggerBeliefs"
|
||||
style={{ left: '40%' }}
|
||||
style={{ left: '40%' }}
|
||||
rules={[
|
||||
allowOnlyConnectionsFromType(['basic_belief', "inferred_belief"]),
|
||||
allowOnlyConnectionsFromType(['basic_belief','inferred_belief']),
|
||||
]}
|
||||
title="Connect to a beliefNode or a set of beliefs combined using the AND/OR node"
|
||||
/>
|
||||
|
||||
<MultiConnectionHandle
|
||||
@@ -84,6 +152,7 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
||||
rules={[
|
||||
allowOnlyConnectionsFromType(['goal']),
|
||||
]}
|
||||
title="Connect to any number of goalNodes"
|
||||
/>
|
||||
|
||||
<PlanEditorDialog
|
||||
@@ -136,7 +205,7 @@ export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string)
|
||||
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
|
||||
if (!otherNode) return;
|
||||
|
||||
if (otherNode.type === 'basic_belief'|| otherNode.type ==='inferred_belief') {
|
||||
if (['basic_belief', 'inferred_belief'].includes(otherNode.type!)) {
|
||||
data.condition = _sourceNodeId;
|
||||
}
|
||||
|
||||
@@ -172,7 +241,7 @@ export function TriggerDisconnectionTarget(_thisNode: Node, _sourceNodeId: strin
|
||||
const data = _thisNode.data as TriggerNodeData;
|
||||
// remove if the target of disconnection was our condition
|
||||
if (_sourceNodeId == data.condition) data.condition = undefined
|
||||
|
||||
|
||||
data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import {create} from "zustand";
|
||||
// the type of a reduced program
|
||||
export type ReducedProgram = { phases: Record<string, unknown>[] };
|
||||
|
||||
export type GoalWithDepth = Record<string, unknown> & { level: number };
|
||||
|
||||
/**
|
||||
* the type definition of the programStore
|
||||
*/
|
||||
@@ -18,6 +20,7 @@ export type ProgramState = {
|
||||
getPhaseNames: () => string[];
|
||||
getNormsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||
getGoalsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||
getGoalsWithDepth: (currentPhaseId: string) => GoalWithDepth[];
|
||||
getTriggersInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||
// if more specific utility functions are needed they can be added here:
|
||||
}
|
||||
@@ -70,6 +73,50 @@ const useProgramStore = create<ProgramState>((set, get) => ({
|
||||
}
|
||||
throw new Error(`phase with id:"${currentPhaseId}" not found`)
|
||||
},
|
||||
|
||||
getGoalsWithDepth: (currentPhaseId: string) => {
|
||||
const program = get().currentProgram;
|
||||
const phase = program.phases.find(val => val["id"] === currentPhaseId);
|
||||
|
||||
if (!phase) {
|
||||
throw new Error(`phase with id:"${currentPhaseId}" not found`);
|
||||
}
|
||||
|
||||
const rootGoals = phase["goals"] as Record<string, unknown>[];
|
||||
const flatList: GoalWithDepth[] = [];
|
||||
|
||||
const isGoal = (item: Record<string, unknown>) => {
|
||||
return item["plan"] !== undefined;
|
||||
};
|
||||
|
||||
// Recursive helper function
|
||||
const traverse = (goals: Record<string, unknown>[], depth: number) => {
|
||||
goals.forEach((goal) => {
|
||||
// 1. Add the current goal to the list
|
||||
flatList.push({ ...goal, level: depth });
|
||||
|
||||
// 2. Check for children
|
||||
const plan = goal["plan"] as Record<string, unknown> | undefined;
|
||||
|
||||
if (plan && Array.isArray(plan["steps"])) {
|
||||
const steps = plan["steps"] as Record<string, unknown>[];
|
||||
|
||||
// 3. FILTER: Only recurse on steps that are actually goals
|
||||
// If we just passed 'steps', we might accidentally add Actions/Speeches to the goal list
|
||||
const childGoals = steps.filter(isGoal);
|
||||
|
||||
if (childGoals.length > 0) {
|
||||
traverse(childGoals, depth + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Start traversal
|
||||
traverse(rootGoals, 0);
|
||||
|
||||
return flatList;
|
||||
},
|
||||
/**
|
||||
* gets the triggers for the provided phase
|
||||
*/
|
||||
|
||||
@@ -51,6 +51,7 @@ describe('MonitoringPage', () => {
|
||||
const mockGetPhaseNames = jest.fn();
|
||||
const mockGetNorms = jest.fn();
|
||||
const mockGetGoals = jest.fn();
|
||||
const mockGetGoalsWithDepth = jest.fn();
|
||||
const mockGetTriggers = jest.fn();
|
||||
const mockSetProgramState = jest.fn();
|
||||
|
||||
@@ -65,6 +66,7 @@ describe('MonitoringPage', () => {
|
||||
getNormsInPhase: mockGetNorms,
|
||||
getGoalsInPhase: mockGetGoals,
|
||||
getTriggersInPhase: mockGetTriggers,
|
||||
getGoalsWithDepth: mockGetGoalsWithDepth,
|
||||
setProgramState: mockSetProgramState,
|
||||
};
|
||||
return selector(state);
|
||||
@@ -81,7 +83,11 @@ describe('MonitoringPage', () => {
|
||||
// Default mock return values
|
||||
mockGetPhaseIds.mockReturnValue(['phase-1', 'phase-2']);
|
||||
mockGetPhaseNames.mockReturnValue(['Intro', 'Main']);
|
||||
mockGetGoals.mockReturnValue([{ id: 'g1', name: 'Goal 1' }, { id: 'g2', name: 'Goal 2' }]);
|
||||
mockGetGoals.mockReturnValue([{ id: 'g1', name: 'Goal 1'}, { id: 'g2', name: 'Goal 2'}]);
|
||||
mockGetGoalsWithDepth.mockReturnValue([
|
||||
{ id: 'g1', name: 'Goal 1', level: 0 },
|
||||
{ id: 'g2', name: 'Goal 2', level: 0 }
|
||||
]);
|
||||
mockGetTriggers.mockReturnValue([{ id: 't1', name: 'Trigger 1' }]);
|
||||
mockGetNorms.mockReturnValue([
|
||||
{ id: 'n1', norm: 'Norm 1', condition: null },
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { renderHook, act, cleanup } from '@testing-library/react';
|
||||
import {
|
||||
sendAPICall,
|
||||
nextPhase,
|
||||
resetPhase,
|
||||
nextPhase,
|
||||
pauseExperiment,
|
||||
playExperiment,
|
||||
useExperimentLogger,
|
||||
@@ -116,14 +115,6 @@ describe('MonitoringPageAPI', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('resetPhase sends correct params', async () => {
|
||||
await resetPhase();
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ body: JSON.stringify({ type: 'reset_phase', context: '' }) })
|
||||
);
|
||||
});
|
||||
|
||||
test('pauseExperiment sends correct params', async () => {
|
||||
await pauseExperiment();
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
|
||||
@@ -34,10 +34,17 @@ describe("UndoRedo Middleware", () => {
|
||||
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(); });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -105,6 +105,8 @@ describe("SaveLoadPanel - combined tests", () => {
|
||||
});
|
||||
|
||||
test("onLoad with invalid JSON does not update store", async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const file = new File(["not json"], "bad.json", { type: "application/json" });
|
||||
file.text = jest.fn(() => Promise.resolve(`{"bad json`));
|
||||
|
||||
@@ -112,20 +114,19 @@ describe("SaveLoadPanel - combined tests", () => {
|
||||
|
||||
render(<SaveLoadPanel />);
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
// Give some input
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.alert).toHaveBeenCalledTimes(1);
|
||||
|
||||
const nodesAfter = useFlowStore.getState().nodes;
|
||||
expect(nodesAfter).toHaveLength(0);
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
// Clean up the spy
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("onLoad resolves to null when no file is chosen (user cancels) and does not update store", async () => {
|
||||
|
||||
@@ -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>
|
||||
): 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(<WarningsSidebar />);
|
||||
expect(screen.getByText('Warnings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all warning descriptions', () => {
|
||||
render(<WarningsSidebar />);
|
||||
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(<WarningsSidebar />);
|
||||
expect(screen.getByText('global:')).toBeInTheDocument();
|
||||
expect(screen.getByText('other:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no warnings exist', () => {
|
||||
getStateSpy.mockReturnValueOnce({
|
||||
getWarnings: () => [],
|
||||
} as any);
|
||||
|
||||
render(<WarningsSidebar />);
|
||||
expect(screen.getByText('No warnings!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters by severity', () => {
|
||||
render(<WarningsSidebar />);
|
||||
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(<WarningsSidebar />);
|
||||
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(<WarningsSidebar />);
|
||||
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(<WarningsSidebar />);
|
||||
fireEvent.click(screen.getByText('Node warning'));
|
||||
|
||||
expect(setCenter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
import { describe, it, beforeEach } from '@jest/globals';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../.././/./../../test-utils/test-utils';
|
||||
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
||||
import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx';
|
||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||
import type { Node } from '@xyflow/react';
|
||||
@@ -150,7 +150,7 @@ describe('BasicBeliefNode', () => {
|
||||
|
||||
expect(screen.getByDisplayValue('Emotion recognised:')).toBeInTheDocument();
|
||||
// For emotion type, we should check that the select has the correct value selected
|
||||
const selectElement = screen.getByDisplayValue('Happy');
|
||||
const selectElement = screen.getByDisplayValue('happy');
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
expect((selectElement as HTMLSelectElement).value).toBe('happy');
|
||||
});
|
||||
@@ -185,14 +185,14 @@ describe('BasicBeliefNode', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const selectElement = screen.getByDisplayValue('Happy');
|
||||
const selectElement = screen.getByDisplayValue('happy');
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
|
||||
// Check that all emotion options are present
|
||||
expect(screen.getByText('Happy')).toBeInTheDocument();
|
||||
expect(screen.getByText('Angry')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sad')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cheerful')).toBeInTheDocument();
|
||||
expect(screen.getByText('happy')).toBeInTheDocument();
|
||||
expect(screen.getByText('angry')).toBeInTheDocument();
|
||||
expect(screen.getByText('sad')).toBeInTheDocument();
|
||||
expect(screen.getByText('surprise')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render without wrapping quotes for object type', () => {
|
||||
@@ -382,7 +382,7 @@ describe('BasicBeliefNode', () => {
|
||||
data: {
|
||||
label: 'Belief',
|
||||
droppable: true,
|
||||
belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' },
|
||||
belief: { type: 'emotion', id: 'em1', value: 'sad', label: 'Emotion recognised:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
@@ -409,13 +409,13 @@ describe('BasicBeliefNode', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByDisplayValue('Happy');
|
||||
await user.selectOptions(select, 'sad');
|
||||
const select = screen.getByDisplayValue('sad');
|
||||
await user.selectOptions(select, 'happy');
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
|
||||
expect(updatedNode?.data.belief.value).toBe('sad');
|
||||
expect(updatedNode?.data.belief.value).toBe('happy');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -511,13 +511,11 @@ describe('BasicBeliefNode', () => {
|
||||
expect(updatedNode?.data.belief.type).toBe('emotion');
|
||||
// The component doesn't reset the value when changing types
|
||||
// So it keeps the old value even though it doesn't make sense for emotion type
|
||||
expect(updatedNode?.data.belief.value).toBe('Happy');
|
||||
expect(updatedNode?.data.belief.value).toBe('sad');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ... rest of the tests remain the same, just fixing the Integration with Store section ...
|
||||
|
||||
describe('Integration with Store', () => {
|
||||
it('should properly update the store when changing belief value', async () => {
|
||||
const mockNode: Node<BasicBeliefNodeData> = {
|
||||
|
||||
@@ -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<CompositeWarningKey>()],
|
||||
['WARNING', new Set<CompositeWarningKey>()],
|
||||
['ERROR', new Set<CompositeWarningKey>()],
|
||||
]) 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<CompositeWarningKey>()],
|
||||
['WARNING', new Set<CompositeWarningKey>()],
|
||||
['ERROR', new Set<CompositeWarningKey>()],
|
||||
]) as SeverityIndex,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -115,6 +115,89 @@ describe('useProgramStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGoalsWithDepth', () => {
|
||||
const complexProgram: ReducedProgram = {
|
||||
phases: [
|
||||
{
|
||||
id: 'phase-nested',
|
||||
goals: [
|
||||
// Level 0: Root Goal 1
|
||||
{
|
||||
id: 'root-1',
|
||||
name: 'Root Goal 1',
|
||||
plan: {
|
||||
steps: [
|
||||
// This is an ACTION (no plan), should be ignored
|
||||
{ id: 'action-1', type: 'speech' },
|
||||
|
||||
// Level 1: Child Goal
|
||||
{
|
||||
id: 'child-1',
|
||||
name: 'Child Goal',
|
||||
plan: {
|
||||
steps: [
|
||||
// Level 2: Grandchild Goal
|
||||
{
|
||||
id: 'grandchild-1',
|
||||
name: 'Grandchild',
|
||||
plan: { steps: [] } // Empty plan is still a plan
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
// Level 0: Root Goal 2 (Sibling)
|
||||
{
|
||||
id: 'root-2',
|
||||
name: 'Root Goal 2',
|
||||
plan: { steps: [] }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
it('should flatten nested goals and assign correct depth levels', () => {
|
||||
useProgramStore.getState().setProgramState(complexProgram);
|
||||
|
||||
const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested');
|
||||
|
||||
// logic: Root 1 -> Child 1 -> Grandchild 1 -> Root 2
|
||||
expect(goals).toHaveLength(4);
|
||||
|
||||
// Check Root 1
|
||||
expect(goals[0]).toEqual(expect.objectContaining({ id: 'root-1', level: 0 }));
|
||||
|
||||
// Check Child 1
|
||||
expect(goals[1]).toEqual(expect.objectContaining({ id: 'child-1', level: 1 }));
|
||||
|
||||
// Check Grandchild 1
|
||||
expect(goals[2]).toEqual(expect.objectContaining({ id: 'grandchild-1', level: 2 }));
|
||||
|
||||
// Check Root 2
|
||||
expect(goals[3]).toEqual(expect.objectContaining({ id: 'root-2', level: 0 }));
|
||||
});
|
||||
|
||||
it('should ignore steps that are not goals (missing "plan" property)', () => {
|
||||
useProgramStore.getState().setProgramState(complexProgram);
|
||||
const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested');
|
||||
|
||||
// The 'action-1' object should NOT be in the list
|
||||
const action = goals.find(g => g.id === 'action-1');
|
||||
expect(action).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws if phase does not exist', () => {
|
||||
useProgramStore.getState().setProgramState(complexProgram);
|
||||
|
||||
expect(() =>
|
||||
useProgramStore.getState().getGoalsWithDepth('missing-phase')
|
||||
).toThrow('phase with id:"missing-phase" not found');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the names of all phases in the program', () => {
|
||||
// Define a program specifically with names for this test
|
||||
const programWithNames: ReducedProgram = {
|
||||
|
||||
Reference in New Issue
Block a user