Compare commits

...

48 Commits

Author SHA1 Message Date
JGerla
a85dbeaca6 refactor: adapted to fit the split made in monitoringPage
ref: N25B-450
2026-01-27 10:47:00 +01:00
JGerla
53568476d5 feat: added warnings to inferredBeliefNodes
ref: N25B-450
2026-01-23 18:30:26 +01:00
JGerla
58bd57818e Merge branch 'dev' into feat/editor-user-feedback
# Conflicts:
#	src/pages/VisProgPage/VisProg.tsx
#	src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx
#	src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx
#	src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx
#	src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx
2026-01-23 18:23:46 +01:00
JGerla
ee79660276 feat: added documentation comments
ref: N25B-450
2026-01-23 18:18:08 +01:00
JGerla
85b84c2281 feat: added visibility toggle with autoHide option
ref: N25B-450
2026-01-23 17:10:48 +01:00
Twirre
f9e0eb95f8 Merge branch 'feat/add-inferred-belief-node' into 'dev'
feat: added an inferred belief node to the editor

See merge request ics/sp/2025/n25b/pepperplus-ui!42
2026-01-23 12:57:35 +00:00
Gerla, J. (Justin)
47c5e94b8f feat: added an inferred belief node to the editor 2026-01-23 12:57:34 +00:00
JGerla
820884f8aa fix: updated styles to work with darkMode
ref: N25B-450
2026-01-23 11:45:08 +01:00
Pim Hutting
b17d1e7618 Merge branch 'fix/correct-capitalization' into 'dev'
chore: fix the capitalization of 3 characters to make sure they match. :)

See merge request ics/sp/2025/n25b/pepperplus-ui!46
2026-01-23 10:42:36 +00:00
JGerla
641d794cf0 fix: updated styles to work with darkMode
ref: N25B-450
2026-01-23 11:42:06 +01:00
Björn Otgaar
ec211ccbc3 chore: fix the capitalization of 3 characters to make sure they match. :) 2026-01-23 11:25:30 +01:00
JGerla
7757a04694 fix: fixed warning for missing plan in goal
ref: N25B-450
2026-01-22 20:34:48 +01:00
JGerla
2a6ead352d feat: added warning for missing plan in goal
ref: N25B-450
2026-01-22 20:34:20 +01:00
JGerla
274ffb0238 fix: fixed bug with warning rendering in the sidebar
ref: N25B-450
2026-01-22 18:19:36 +01:00
JGerla
a00fd02634 fix: fixed runProgram button being disabled if program is invalid
ref: N25B-450
2026-01-22 17:55:57 +01:00
JGerla
f6b692e420 test: removed test, as it can be tested manually with better accuracy and the test was causing issues instead of verifying functionality
ref: N25B-450
2026-01-22 16:28:36 +01:00
JGerla
2cbd905f0b style: updated comment
ref: N25B-450
2026-01-22 16:18:07 +01:00
JGerla
84d9cbb19d fix: fixed centering for jumptonode from warningSidebar
ref: N25B-450
2026-01-22 16:14:46 +01:00
JGerla
e5b438c17e fix: fixed tests
ref: N25B-450
2026-01-22 14:21:03 +01:00
JGerla
64dcdc49b3 fix: added missing key attribute to the warningListItem
ref: N25B-450
2026-01-22 13:56:35 +01:00
JGerla
9c64455a19 test: added tests for the warningSidebar
ref: N25B-450
2026-01-22 13:54:24 +01:00
JGerla
9f359de953 test: added tests for editorwarnings
ref: N25B-450
2026-01-22 13:31:02 +01:00
JGerla
9d2f5127c1 fix: fixed incorrect warningkey creation in unregisterWarningsForId
ref: N25B-450
2026-01-22 13:09:44 +01:00
JGerla
bb053fda21 feat: added title based tooltips and updated handle styling to reflect in and output handles
ref: N25B-450
2026-01-22 13:04:56 +01:00
JGerla
f92467b409 Merge branch 'dev' into feat/editor-user-feedback 2026-01-22 12:19:20 +01:00
JGerla
c9c7f55aa0 feat: removed usage of structuredClone inside the editorWarningSystem
ref: N25B-450
2026-01-22 12:02:20 +01:00
JGerla
d6d74d4c6b feat: made warnings undo redo safe and added warnings to phase nodes
ref: N25B-450
2026-01-22 11:26:48 +01:00
JGerla
e86c06c3e5 fix: fixed deleting behaviour
ref: N25B-450
2026-01-22 10:26:21 +01:00
JGerla
363054afda feat: updated visuals
ref: N25B-450
2026-01-20 14:00:01 +01:00
JGerla
327d1de621 feat: Added visual separation between global and node or handle specific warnings
ref: N25B-450
2026-01-20 12:54:47 +01:00
JGerla
3f6d95683d feat: Added visual separation between global and node or handle specific warnings
ref: N25B-450
2026-01-20 12:50:29 +01:00
Gerla, J. (Justin)
6f4471ce6f Merge branch 'demo' into 'dev'
feat: merged demo into dev

See merge request ics/sp/2025/n25b/pepperplus-ui!43
2026-01-20 11:10:58 +00:00
JGerla
5d55ebaaa2 feat: Added global warning for incomplete program chain
ref: N25B-450
2026-01-20 11:53:51 +01:00
JGerla
23a02b2b4a Merge branch 'demo' into feat/editor-user-feedback 2026-01-20 11:13:22 +01:00
JGerla
487ee30923 feat: made jumpToNode select the node after focussing the editor
ref: N25B-450
2026-01-20 10:22:08 +01:00
JGerla
5a9b78fdda feat: added jump to node on clicking the warning
ref: N25B-450
2026-01-15 16:24:08 +01:00
JGerla
a6f24b677f feat: added two new warnings
ref: N25B-450
2026-01-15 16:09:24 +01:00
JGerla
022a6708ea feat: added a Warnings sidebar
warnings are now displayed in the sidebar

ref: N25B-450
2026-01-15 16:04:09 +01:00
JGerla
f62f416af3 Merge remote-tracking branch 'origin/feat/editor-user-feedback' into feat/editor-user-feedback
# Conflicts:
#	src/pages/VisProgPage/VisProg.tsx
2026-01-15 14:23:55 +01:00
JGerla
385ec250cc feat: finished basic warning system
nodes can now register warnings to prevent running the program

ref: N25B-450
2026-01-15 14:23:35 +01:00
JGerla
35bf3ad9e5 feat: finished basic warning system
nodes can now register warnings to prevent running the program

ref: N25B-450
2026-01-15 14:22:50 +01:00
JGerla
66daafe1f0 feat: added disabling of runProgram button if program is not valid
ref: N25B-450
2026-01-15 12:21:09 +01:00
JGerla
5d650b36ce feat: implemented the rest of the warning registry
ref: N25B-450
2026-01-15 12:15:32 +01:00
JGerla
e9acab456e feat: implemented getWarningsBySeverity
ref: N25B-450
2026-01-14 16:59:28 +01:00
JGerla
1a8670ba13 feat: implemented delete all warnings for a node
ref: N25B-450
2026-01-14 16:52:43 +01:00
JGerla
f174623a4c feat: implemented basic add and remove functions
ref: N25B-450
2026-01-14 16:46:50 +01:00
JGerla
b3b77b94ad feat: slightly modified structure for better global logic
ref: N25B-450
2026-01-14 16:11:48 +01:00
JGerla
67558a7ac7 feat: added full definition of editor warning infrastructure
Everything is now defined architecturally, and can be implemented properly.

ref: N25B-450
2026-01-14 16:04:44 +01:00
38 changed files with 2857 additions and 141 deletions

515
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -84,7 +84,10 @@
filter: drop-shadow(0 0 0.25rem plum);
}
.node-inferred_belief {
outline: mediumpurple solid 2pt;
filter: drop-shadow(0 0 0.25rem mediumpurple);
}
.draggable-node {
padding: 3px 10px;
@@ -158,6 +161,14 @@
filter: drop-shadow(0 0 0.25rem plum);
}
.draggable-node-inferred_belief {
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
outline: mediumpurple solid 2pt;
filter: drop-shadow(0 0 0.25rem mediumpurple);
}
.planNoIterate {
opacity: 0.5;
font-style: italic;

View File

@@ -4,19 +4,21 @@ import {
Panel,
ReactFlow,
ReactFlowProvider,
MarkerType,
MarkerType, getOutgoers
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {graphReducer, runProgram} from "./VisProgLogic.tsx";
import warningStyles from './visualProgrammingUI/components/WarningSidebar.module.css'
import {type CSSProperties, useEffect, useState} from "react";
import {useShallow} from 'zustand/react/shallow';
import orderPhaseNodeArray from "../../utils/orderPhaseNodes.ts";
import useProgramStore from "../../utils/programStore.ts";
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
import type {PhaseNode} from "./visualProgrammingUI/nodes/PhaseNode.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'
import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
import { NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
// --| config starting params for flow |--
@@ -42,6 +44,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 +70,7 @@ const VisProgUI = () => {
const {
nodes, edges,
onNodesChange,
onNodesDelete,
onEdgesDelete,
onEdgesChange,
onConnect,
@@ -89,15 +93,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 +133,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 +146,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>
);
};
@@ -144,41 +175,23 @@ function VisualProgrammingUI() {
);
}
// currently outputs the prepared program to the console
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.");
const checkPhaseChain = (): boolean => {
const {nodes, edges} = useFlowStore.getState();
// 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."));
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;
}
/**
* Reduces the graph into its phases' information and recursively calls their reducing function
*/
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)
});
}
return checkForCompleteChain('start');
};
/**
* houses the entire page, so also UI elements
@@ -186,10 +199,29 @@ function graphReducer() {
* @constructor
*/
function VisProgPage() {
const [programValidity, setProgramValidity] = useState<boolean>(true);
const {isProgramValid, severityIndex} = useFlowStore();
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 setProgramState = useProgramStore((state) => state.setProgramState);
const processProgram = () => {
const phases = graphReducer(); // reduce graph
setProgramState({ phases }); // <-- save to store
runProgram(); // send to backend if needed
};
return (
<>
<VisualProgrammingUI/>
<button onClick={runProgram}>run program</button>
<button onClick={processProgram} disabled={!programValidity}>Run Program</button>
</>
)
}

View 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);
}

View File

@@ -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

View File

@@ -108,3 +108,15 @@ export function useHandleRules(
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);
}

View File

@@ -43,3 +43,4 @@ export const noSelfConnections : HandleRule =
}

View File

@@ -52,15 +52,24 @@ import TriggerNode, {
TriggerTooltip
} from "./nodes/TriggerNode";
import { TriggerNodeDefaults } from "./nodes/TriggerNode.default";
import InferredBeliefNode, {
InferredBeliefConnectionTarget,
InferredBeliefConnectionSource,
InferredBeliefDisconnectionTarget,
InferredBeliefDisconnectionSource,
InferredBeliefReduce, InferredBeliefTooltip
} from "./nodes/InferredBeliefNode";
import { InferredBeliefNodeDefaults } from "./nodes/InferredBeliefNode.default";
import BasicBeliefNode, {
BasicBeliefConnectionSource,
BasicBeliefConnectionTarget,
BasicBeliefDisconnectionSource,
BasicBeliefDisconnectionTarget,
BasicBeliefReduce,
BasicBeliefReduce
,
BasicBeliefTooltip
} from "./nodes/BasicBeliefNode";
import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default";
} from "./nodes/BasicBeliefNode.tsx";
import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default.ts";
/**
* Registered node types in the visual programming system.
@@ -76,6 +85,7 @@ export const NodeTypes = {
goal: GoalNode,
trigger: TriggerNode,
basic_belief: BasicBeliefNode,
inferred_belief: InferredBeliefNode,
};
/**
@@ -91,6 +101,7 @@ export const NodeDefaults = {
goal: GoalNodeDefaults,
trigger: TriggerNodeDefaults,
basic_belief: BasicBeliefNodeDefaults,
inferred_belief: InferredBeliefNodeDefaults,
};
@@ -108,6 +119,7 @@ export const NodeReduces = {
goal: GoalReduce,
trigger: TriggerReduce,
basic_belief: BasicBeliefReduce,
inferred_belief: InferredBeliefReduce,
}
@@ -126,6 +138,7 @@ export const NodeConnections = {
goal: GoalConnectionTarget,
trigger: TriggerConnectionTarget,
basic_belief: BasicBeliefConnectionTarget,
inferred_belief: InferredBeliefConnectionTarget,
},
Sources: {
start: StartConnectionSource,
@@ -134,7 +147,8 @@ export const NodeConnections = {
norm: NormConnectionSource,
goal: GoalConnectionSource,
trigger: TriggerConnectionSource,
basic_belief: BasicBeliefConnectionSource
basic_belief: BasicBeliefConnectionSource,
inferred_belief: InferredBeliefConnectionSource,
}
}
@@ -153,6 +167,7 @@ export const NodeDisconnections = {
goal: GoalDisconnectionTarget,
trigger: TriggerDisconnectionTarget,
basic_belief: BasicBeliefDisconnectionTarget,
inferred_belief: InferredBeliefDisconnectionTarget,
},
Sources: {
start: StartDisconnectionSource,
@@ -162,6 +177,7 @@ export const NodeDisconnections = {
goal: GoalDisconnectionSource,
trigger: TriggerDisconnectionSource,
basic_belief: BasicBeliefDisconnectionSource,
inferred_belief: InferredBeliefDisconnectionSource,
},
}
@@ -186,6 +202,7 @@ export const NodesInPhase = {
end: () => false,
phase: () => false,
basic_belief: () => false,
inferred_belief: () => false,
}
/**
@@ -199,4 +216,5 @@ export const NodeTooltips = {
goal: GoalTooltip,
trigger: TriggerTooltip,
basic_belief: BasicBeliefTooltip,
inferred_belief: InferredBeliefTooltip,
}

View File

@@ -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,
@@ -49,13 +51,12 @@ function createNode(id: string, type: string, position: XYPosition, data: Record
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()) {
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;

View File

@@ -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
@@ -130,3 +134,6 @@ export type HandleRuleRegistry = {
// cleans up all registered rules of all handles of the provided node
unregisterNodeRules: (nodeId: string) => void
}

View File

@@ -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(),
};
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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}
/>
);
}

View File

@@ -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;
}

View File

@@ -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]);
});
};
}

View File

@@ -1,4 +1,4 @@
import type { BasicBeliefNodeData } from "./BasicBeliefNode";
import type { BasicBeliefNodeData } from "./BasicBeliefNode.tsx";
/**

View File

@@ -3,13 +3,14 @@ import {
Position,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import { Toolbar } from '../components/NodeComponents.tsx';
import styles from '../../VisProg.module.css';
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores';
import { TextField } from '../../../../components/TextField';
import { MultilineTextField } from '../../../../components/MultilineTextField';
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
@@ -31,7 +32,7 @@ export type BasicBeliefNodeData = {
};
// These are all the types a basic belief could be.
type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion
export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion
type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"};
type Semantic = { type: "semantic", id: string, value: string, description: string, label: "Detected with LLM:"};
type DetectedObject = { type: "object", id: string, value: string, label: "Object found:"};
@@ -189,8 +190,9 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
</div>
)}
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
noMatchingLeftRightBelief,
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"}]),
]}/>
]} title="Connect to any number of trigger and/or normNode(-s)"/>
</div>
</>
);

View File

@@ -0,0 +1,63 @@
import {getOutgoers, type Node} from '@xyflow/react';
import {type HandleRule, type RuleResult, ruleResult} from "../HandleRuleLogic.ts";
import useFlowStore from "../VisProgStores.tsx";
import {BasicBeliefReduce} from "./BasicBeliefNode.tsx";
import {type InferredBeliefNodeData, InferredBeliefReduce} from "./InferredBeliefNode.tsx";
export function BeliefGlobalReduce(beliefNode: Node, nodes: Node[]) {
switch (beliefNode.type) {
case 'basic_belief':
return BasicBeliefReduce(beliefNode, nodes);
case 'inferred_belief':
return InferredBeliefReduce(beliefNode, nodes);
}
}
export const noMatchingLeftRightBelief : HandleRule = (connection, _)=> {
const { nodes } = useFlowStore.getState();
const thisNode = nodes.find(node => node.id === connection.target && node.type === 'inferred_belief');
if (!thisNode) return ruleResult.satisfied;
const iBelief = (thisNode.data as InferredBeliefNodeData).inferredBelief;
return (iBelief.left === connection.source || iBelief.right === connection.source)
? ruleResult.notSatisfied("Connecting one belief to both input handles of an inferred belief node is not allowed")
: ruleResult.satisfied;
}
/**
* makes it impossible to connect Inferred belief nodes
* if the connection would create a cyclical connection between inferred beliefs
*/
export const noBeliefCycles: HandleRule = (connection, _): RuleResult => {
const {nodes, edges} = useFlowStore.getState();
const defaultErrorMessage = "Cyclical connection exists between inferred beliefs";
/**
* recursively checks for cyclical connections between InferredBelief nodes
*
* to check for a cycle provide the source of an attempted connection as the targetNode for the cycle check,
* the currentNodeId should be initialised with the id of the targetNode of the attempted connection.
*
* @param {string} targetNodeId - the id of the node we are looking for as the endpoint of a cyclical connection
* @param {string} currentNodeId - the id of the node we are checking for outgoing connections to the provided target node
* @returns {RuleResult}
*/
function checkForCycle(targetNodeId: string, currentNodeId: string): RuleResult {
const outgoingBeliefs = getOutgoers({id: currentNodeId}, nodes, edges)
.filter(node => node.type === 'inferred_belief');
if (outgoingBeliefs.length === 0) return ruleResult.satisfied;
if (outgoingBeliefs.some(node => node.id === targetNodeId)) return ruleResult
.notSatisfied(defaultErrorMessage);
const next = outgoingBeliefs.map(node => checkForCycle(targetNodeId, node.id))
.find(result => !result.isSatisfied);
return next
? next
: ruleResult.satisfied;
}
return connection.source === connection.target
? ruleResult.notSatisfied(defaultErrorMessage)
: checkForCycle(connection.source, connection.target);
};

View File

@@ -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>
</>
);

View File

@@ -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>

View File

@@ -0,0 +1,16 @@
import type { InferredBeliefNodeData } from "./InferredBeliefNode.tsx";
/**
* Default data for this node
*/
export const InferredBeliefNodeDefaults: InferredBeliefNodeData = {
label: "AND/OR",
droppable: true,
inferredBelief: {
left: undefined,
operator: true,
right: undefined
},
hasReduce: true,
};

View File

@@ -0,0 +1,80 @@
.operator-switch {
display: inline-flex;
align-items: center;
gap: 0.5em;
cursor: pointer;
font-family: sans-serif;
/* Change this font-size to scale the whole component */
font-size: 12px;
}
/* hide the default checkbox */
.operator-switch input {
display: none;
}
/* The Track */
.switch-visual {
position: relative;
/* height is now 3x the font size */
height: 3em;
aspect-ratio: 1 / 2;
background-color: ButtonFace;
border-radius: 2em;
transition: 0.2s;
}
/* The Knob */
.switch-visual::after {
content: "";
position: absolute;
top: 0.1em;
left: 0.1em;
width: 1em;
height: 1em;
background: Canvas;
border: 0.175em solid mediumpurple;
border-radius: 50%;
transition: transform 0.2s ease-in-out, border-color 0.2s;
}
/* Labels */
.switch-labels {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 3em; /* Matches the track height */
font-weight: 800;
color: Canvas;
line-height: 1.4;
padding: 0.2em 0;
}
.operator-switch input:checked + .switch-visual::after {
/* Moves the slider down */
transform: translateY(1.4em);
}
/*change the colours to highlight the selected operator*/
.operator-switch input:checked ~ .switch-labels{
:first-child {
transition: ease-in-out color 0.2s;
color: ButtonFace;
}
:last-child {
transition: ease-in-out color 0.2s;
color: mediumpurple;
}
}
.operator-switch input:not(:checked) ~ .switch-labels{
:first-child {
transition: ease-in-out color 0.2s;
color: mediumpurple;
}
:last-child {
transition: ease-in-out color 0.2s;
color: ButtonFace;
}
}

View File

@@ -0,0 +1,200 @@
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";
import useFlowStore from "../VisProgStores.tsx";
import {BeliefGlobalReduce, noBeliefCycles, noMatchingLeftRightBelief} from "./BeliefGlobals.ts";
import switchStyles from './InferredBeliefNode.module.css';
/**
* The default data structure for an InferredBelief node
*/
export type InferredBeliefNodeData = {
label: string;
droppable: boolean;
inferredBelief: InferredBelief;
hasReduce: boolean;
};
/**
* stores a boolean to represent the operator
* and a left and right BeliefNode (can be both an inferred and a basic belief)
* in the form of their corresponding id's
*/
export type InferredBelief = {
left: string | undefined,
operator: boolean,
right: string | undefined,
}
export type InferredBeliefNode = Node<InferredBeliefNodeData>;
/**
* This function is called whenever a connection is made with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the received connection
*/
export function InferredBeliefConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
const data = _thisNode.data as InferredBeliefNodeData;
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId
&& ['basic_belief', 'inferred_belief'].includes(node.type!)))
) {
const connectedEdges = getConnectedEdges([_thisNode], useFlowStore.getState().edges);
switch(connectedEdges.find(edge => edge.source === _sourceNodeId)?.targetHandle){
case 'beliefLeft': data.inferredBelief.left = _sourceNodeId; break;
case 'beliefRight': data.inferredBelief.right = _sourceNodeId; break;
}
}
}
/**
* This function is called whenever a connection is made with this node type as the source
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the created connection
*/
export function InferredBeliefConnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
}
/**
* This function is called whenever a connection is disconnected with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the disconnected connection
*/
export function InferredBeliefDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
const data = _thisNode.data as InferredBeliefNodeData;
if (_sourceNodeId === data.inferredBelief.left) data.inferredBelief.left = undefined;
if (_sourceNodeId === data.inferredBelief.right) data.inferredBelief.right = undefined;
}
/**
* This function is called whenever a connection is disconnected with this node type as the source
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the diconnected connection
*/
export function InferredBeliefDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
}
export const InferredBeliefTooltip = `
Combines two beliefs into a single belief using logical inference,
the node can be toggled between using "AND" and "OR" mode for inference`;
/**
* Defines how an InferredBelief node should be rendered
* @param {NodeProps<InferredBeliefNode>} props - Node properties provided by React Flow, including `id` and `data`.
* @returns The rendered InferredBeliefNode React element. (React.JSX.Element)
*/
export default function InferredBeliefNode(props: NodeProps<InferredBeliefNode>) {
const data = props.data;
const { updateNodeData, registerWarning, unregisterWarning } = useFlowStore();
// start of as an AND operator, true: "AND", false: "OR"
const [enforceAllBeliefs, setEnforceAllBeliefs] = useState(true);
// used to toggle operator
function onToggle() {
const newOperator = !enforceAllBeliefs; // compute the new value
setEnforceAllBeliefs(newOperator);
updateNodeData(props.id, {
...data,
inferredBelief: {
...data.inferredBelief,
operator: enforceAllBeliefs,
}
});
}
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}/>
<div className={`${styles.defaultNode} ${styles.nodeInferredBelief}`}>
{/* The checkbox used to toggle the operator between 'AND' and 'OR' */}
<label className={switchStyles.operatorSwitch}>
<input
type="checkbox"
checked={data.inferredBelief.operator}
onChange={onToggle}
/>
<div className={switchStyles.switchVisual}></div>
<div className={switchStyles.switchLabels}>
<span title={"Belief is fulfilled if either of the supplied beliefs is true"}>OR</span>
<span title={"Belief is fulfilled if all of the supplied beliefs are true"}>AND</span>
</div>
</label>
{/* outgoing connections */}
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
allowOnlyConnectionsFromType(["norm", "trigger"]),
noBeliefCycles,
noMatchingLeftRightBelief
]}/>
{/* incoming connections */}
<SingleConnectionHandle type="target" position={Position.Left} style={{top: '30%'}} id="beliefLeft" rules={[
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"]),
noBeliefCycles,
noMatchingLeftRightBelief
]}/>
<SingleConnectionHandle type="target" position={Position.Left} style={{top: '70%'}} id="beliefRight" rules={[
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"]),
noBeliefCycles,
noMatchingLeftRightBelief
]}/>
</div>
</>
);
};
/**
* Reduces each BasicBelief, including its children down into its core data.
* @param {Node} node - The BasicBelief node to reduce.
* @param {Node[]} nodes - The list of all nodes in the current flow graph.
* @returns A simplified object containing the node label and its list of BasicBeliefs.
*/
export function InferredBeliefReduce(node: Node, nodes: Node[]) {
const data = node.data as InferredBeliefNodeData;
const leftBelief = nodes.find((node) => node.id === data.inferredBelief.left);
const rightBelief = nodes.find((node) => node.id === data.inferredBelief.right);
if (!leftBelief) { throw new Error("No Left belief found")}
if (!rightBelief) { throw new Error("No Right Belief found")}
const result: Record<string, unknown> = {
id: node.id,
left: BeliefGlobalReduce(leftBelief, nodes),
operator: data.inferredBelief.operator ? "AND" : "OR",
right: BeliefGlobalReduce(rightBelief, nodes),
};
return result
}

View File

@@ -9,7 +9,7 @@ import { TextField } from '../../../../components/TextField';
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores';
import { BasicBeliefReduce } from './BasicBeliefNode';
import {BeliefGlobalReduce} from "./BeliefGlobals.ts";
/**
* The default data dot a phase node
@@ -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"])
]}/>
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"])
]} title="Connect to a beliefNode or a set of beliefs combined using the AND/OR node"/>
</div>
</>;
};
@@ -105,11 +105,10 @@ export function NormReduce(node: Node, nodes: Node[]) {
};
if (data.condition) {
const reducer = BasicBeliefReduce; // TODO: also add inferred.
const conditionNode = nodes.find((node) => node.id === data.condition);
// In case something went wrong, and our condition doesn't actually exist;
if (conditionNode == undefined) return result;
result["condition"] = reducer(conditionNode, nodes)
result["condition"] = BeliefGlobalReduce(conditionNode, nodes)
}
return result
}
@@ -126,7 +125,7 @@ export const NormTooltip = `
export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
const data = _thisNode.data as NormNodeData;
// If we got a belief connected, this is the condition for the norm.
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) {
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && ['basic_belief', 'inferred_belief'].includes(node.type!)))) {
data.condition = _sourceNodeId;
}
}

View File

@@ -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>
</>
);

View File

@@ -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>
</>
);

View File

@@ -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";
@@ -10,7 +12,7 @@ import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../H
import useFlowStore from '../VisProgStores';
import {PlanReduce, type Plan } from '../components/Plan';
import PlanEditorDialog from '../components/PlanEditor';
import { BasicBeliefReduce } from './BasicBeliefNode';
import {BeliefGlobalReduce} from "./BeliefGlobals.ts";
import type { GoalNode } from './GoalNode.tsx';
import { defaultPlan } from '../components/Plan.default.ts';
import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
@@ -45,12 +47,77 @@ 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}/>
@@ -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%' }}
rules={[
allowOnlyConnectionsFromType(['basic_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
@@ -102,13 +171,13 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
/**
* Reduces each Trigger, including its children down into its core data.
* @param node - The Trigger node to reduce.
* @param _nodes - The list of all nodes in the current flow graph.
* @param nodes - The list of all nodes in the current flow graph.
* @returns A simplified object containing the node label and its list of triggers.
*/
export function TriggerReduce(node: Node, nodes: Node[]) {
const data = node.data as TriggerNodeData;
const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined
const conditionData = conditionNode ? BasicBeliefReduce(conditionNode, nodes) : ""
const conditionData = conditionNode ? BeliefGlobalReduce(conditionNode, nodes) : ""
return {
id: node.id,
name: node.data.name,
@@ -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' /* TODO: Add the option for an inferred belief */) {
if (['basic_belief', 'inferred_belief'].includes(otherNode.type!)) {
data.condition = _sourceNodeId;
}

View File

@@ -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(); });

View File

@@ -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);
});

View File

@@ -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);
});
});
})

View File

@@ -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();
});
});

View File

@@ -0,0 +1,274 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import {type Connection, getOutgoers, type Node} from '@xyflow/react';
import {ruleResult} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
import {BasicBeliefReduce} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx";
import {
BeliefGlobalReduce, noBeliefCycles,
noMatchingLeftRightBelief
} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts";
import { InferredBeliefReduce } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx";
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
import * as BasicModule from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode';
import * as InferredModule from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx';
import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
describe('BeliefGlobalReduce', () => {
const nodes: Node[] = [];
beforeEach(() => {
jest.clearAllMocks();
});
it('delegates to BasicBeliefReduce for basic_belief nodes', () => {
const spy = jest
.spyOn(BasicModule, 'BasicBeliefReduce')
.mockReturnValue('basic-result' as any);
const node = { id: '1', type: 'basic_belief' } as Node;
const result = BeliefGlobalReduce(node, nodes);
expect(spy).toHaveBeenCalledWith(node, nodes);
expect(result).toBe('basic-result');
});
it('delegates to InferredBeliefReduce for inferred_belief nodes', () => {
const spy = jest
.spyOn(InferredModule, 'InferredBeliefReduce')
.mockReturnValue('inferred-result' as any);
const node = { id: '2', type: 'inferred_belief' } as Node;
const result = BeliefGlobalReduce(node, nodes);
expect(spy).toHaveBeenCalledWith(node, nodes);
expect(result).toBe('inferred-result');
});
it('returns undefined for unknown node types', () => {
const node = { id: '3', type: 'other' } as Node;
const result = BeliefGlobalReduce(node, nodes);
expect(result).toBeUndefined();
expect(BasicBeliefReduce).not.toHaveBeenCalled();
expect(InferredBeliefReduce).not.toHaveBeenCalled();
});
});
describe('noMatchingLeftRightBelief rule', () => {
let getStateSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
jest.clearAllMocks();
getStateSpy = jest.spyOn(FlowStore.default, 'getState');
});
it('is satisfied when target node is not an inferred belief', () => {
getStateSpy.mockReturnValue({
nodes: [{ id: 't1', type: 'basic_belief' }],
} as any);
const result = noMatchingLeftRightBelief(
{ source: 's1', target: 't1' } as Connection,
null as any
);
expect(result).toBe(ruleResult.satisfied);
});
it('is satisfied when inferred belief has no matching left/right', () => {
getStateSpy.mockReturnValue({
nodes: [
{
id: 't1',
type: 'inferred_belief',
data: {
inferredBelief: {
left: 'a',
right: 'b',
},
},
},
],
} as any);
const result = noMatchingLeftRightBelief(
{ source: 'c', target: 't1' } as Connection,
null as any
);
expect(result).toBe(ruleResult.satisfied);
});
it('is NOT satisfied when source matches left input', () => {
getStateSpy.mockReturnValue({
nodes: [
{
id: 't1',
type: 'inferred_belief',
data: {
inferredBelief: {
left: 's1',
right: 's2',
},
},
},
],
} as any);
const result = noMatchingLeftRightBelief(
{ source: 's1', target: 't1' } as Connection,
null as any
);
expect(result.isSatisfied).toBe(false);
if (!(result.isSatisfied)) {
expect(result.message).toContain(
'Connecting one belief to both input handles of an inferred belief node is not allowed'
);
}
});
it('is NOT satisfied when source matches right input', () => {
getStateSpy.mockReturnValue({
nodes: [
{
id: 't1',
type: 'inferred_belief',
data: {
inferredBelief: {
left: 's1',
right: 's2',
},
},
},
],
} as any);
const result = noMatchingLeftRightBelief(
{ source: 's2', target: 't1' } as Connection,
null as any
);
expect(result.isSatisfied).toBe(false);
if (!(result.isSatisfied)) {
expect(result.message).toContain(
'Connecting one belief to both input handles of an inferred belief node is not allowed'
);
}
});
});
jest.mock('@xyflow/react', () => ({
getOutgoers: jest.fn(),
getConnectedEdges: jest.fn(), // include if some tests require it
}));
describe('noBeliefCycles rule', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns notSatisfied when source === target', () => {
const result = noBeliefCycles({ source: 'n1', target: 'n1' } as any, null as any);
expect(result.isSatisfied).toBe(false);
if (!(result.isSatisfied)) {
expect(result.message).toContain('Cyclical connection exists');
}
});
it('returns satisfied when there are no outgoing inferred beliefs', () => {
jest.spyOn(useFlowStore, 'getState').mockReturnValue({
nodes: [{ id: 'n1', type: 'inferred_belief' }],
edges: [],
} as any);
(getOutgoers as jest.Mock).mockReturnValue([]);
const result = noBeliefCycles({ source: 'n1', target: 'n2' } as any, null as any);
expect(result).toBe(ruleResult.satisfied);
});
it('returns notSatisfied for direct cycle', () => {
jest.spyOn(useFlowStore, 'getState').mockReturnValue({
nodes: [
{ id: 'n1', type: 'inferred_belief' },
{ id: 'n2', type: 'inferred_belief' },
],
edges: [{ source: 'n2', target: 'n1' }],
} as any);
// @ts-expect-error is acting up
(getOutgoers as jest.Mock).mockImplementation(({ id }) => {
if (id === 'n2') return [{ id: 'n1', type: 'inferred_belief' }];
return [];
});
const result = noBeliefCycles({ source: 'n1', target: 'n2' } as any, null as any);
expect(result.isSatisfied).toBe(false);
if (!(result.isSatisfied)) {
expect(result.message).toContain('Cyclical connection exists');
}
});
it('returns notSatisfied for indirect cycle', () => {
jest.spyOn(useFlowStore, 'getState').mockReturnValue({
nodes: [
{ id: 'A', type: 'inferred_belief' },
{ id: 'B', type: 'inferred_belief' },
{ id: 'C', type: 'inferred_belief' },
],
edges: [
{ source: 'A', target: 'B' },
{ source: 'B', target: 'C' },
{ source: 'C', target: 'A' },
],
} as any);
// @ts-expect-error is acting up
(getOutgoers as jest.Mock).mockImplementation(({ id }) => {
const mapping: Record<string, any[]> = {
A: [{ id: 'B', type: 'inferred_belief' }],
B: [{ id: 'C', type: 'inferred_belief' }],
C: [{ id: 'A', type: 'inferred_belief' }],
};
return mapping[id] || [];
});
const result = noBeliefCycles({ source: 'A', target: 'B' } as any, null as any);
expect(result.isSatisfied).toBe(false);
if (!(result.isSatisfied)) {
expect(result.message).toContain('Cyclical connection exists');
}
});
it('returns satisfied when no cycle exists in a multi-node graph', () => {
jest.spyOn(useFlowStore, 'getState').mockReturnValue({
nodes: [
{ id: 'A', type: 'inferred_belief' },
{ id: 'B', type: 'inferred_belief' },
{ id: 'C', type: 'inferred_belief' },
],
edges: [
{ source: 'A', target: 'B' },
{ source: 'B', target: 'C' },
],
} as any);
// @ts-expect-error is acting up
(getOutgoers as jest.Mock).mockImplementation(({ id }) => {
const mapping: Record<string, any[]> = {
A: [{ id: 'B', type: 'inferred_belief' }],
B: [{ id: 'C', type: 'inferred_belief' }],
C: [],
};
return mapping[id] || [];
});
const result = noBeliefCycles({ source: 'A', target: 'B' } as any, null as any);
expect(result).toBe(ruleResult.satisfied);
});
});

View File

@@ -3,7 +3,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 BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode';
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';
import '@testing-library/jest-dom';

View File

@@ -0,0 +1,129 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import type {Node, Edge} from '@xyflow/react';
import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
import {
type InferredBelief,
InferredBeliefConnectionTarget,
InferredBeliefDisconnectionTarget,
InferredBeliefReduce,
} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx';
// helper functions
function inferredNode(overrides = {}): Node {
return {
id: 'i1',
type: 'inferred_belief',
position: {x: 0, y: 0},
data: {
inferredBelief: {
left: undefined,
operator: true,
right: undefined,
},
...overrides,
},
} as Node;
}
describe('InferredBelief connection logic', () => {
let getStateSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
jest.clearAllMocks();
getStateSpy = jest.spyOn(FlowStore.default, 'getState');
});
it('sets left belief when connected on beliefLeft handle', () => {
const node = inferredNode();
getStateSpy.mockReturnValue({
nodes: [{ id: 'b1', type: 'basic_belief' }],
edges: [
{
source: 'b1',
target: 'i1',
targetHandle: 'beliefLeft',
} as Edge,
],
} as any);
InferredBeliefConnectionTarget(node, 'b1');
expect((node.data.inferredBelief as InferredBelief).left).toBe('b1');
expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined();
});
it('sets right belief when connected on beliefRight handle', () => {
const node = inferredNode();
getStateSpy.mockReturnValue({
nodes: [{ id: 'b2', type: 'basic_belief' }],
edges: [
{
source: 'b2',
target: 'i1',
targetHandle: 'beliefRight',
} as Edge,
],
} as any);
InferredBeliefConnectionTarget(node, 'b2');
expect((node.data.inferredBelief as InferredBelief).right).toBe('b2');
});
it('ignores connections from unsupported node types', () => {
const node = inferredNode();
getStateSpy.mockReturnValue({
nodes: [{ id: 'x', type: 'norm' }],
edges: [],
} as any);
InferredBeliefConnectionTarget(node, 'x');
expect((node.data.inferredBelief as InferredBelief).left).toBeUndefined();
expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined();
});
it('clears left or right belief on disconnection', () => {
const node = inferredNode({
inferredBelief: { left: 'a', right: 'b', operator: true },
});
InferredBeliefDisconnectionTarget(node, 'a');
expect((node.data.inferredBelief as InferredBelief).left).toBeUndefined();
InferredBeliefDisconnectionTarget(node, 'b');
expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined();
});
});
describe('InferredBeliefReduce', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('throws if left belief is missing', () => {
const node = inferredNode({
inferredBelief: { left: 'l', right: 'r', operator: true },
});
expect(() =>
InferredBeliefReduce(node, [{ id: 'r' } as Node])
).toThrow('No Left belief found');
});
it('throws if right belief is missing', () => {
const node = inferredNode({
inferredBelief: { left: 'l', right: 'r', operator: true },
});
expect(() =>
InferredBeliefReduce(node, [{ id: 'l' } as Node])
).toThrow('No Right Belief found');
});
});

View File

@@ -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,
});
});

View File

@@ -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