Merge remote-tracking branch 'origin/dev' into feat/save-load-nodes
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -24,4 +24,7 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
# Coverage report
|
# Coverage report
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
|
# Documentation pages (can be generated)
|
||||||
|
docs
|
||||||
|
|||||||
@@ -41,3 +41,10 @@ branch name != <type>/description-of-branch ,
|
|||||||
commit name != <type>: description of the commit.
|
commit name != <type>: description of the commit.
|
||||||
<ref>: N25B-Num's
|
<ref>: N25B-Num's
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Generate documentation webpages with the command:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npx typedoc --entryPointStrategy Expand src
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,23 +1,38 @@
|
|||||||
import js from '@eslint/js'
|
import js from "@eslint/js"
|
||||||
import globals from 'globals'
|
import globals from "globals"
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from "eslint-plugin-react-hooks"
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from "eslint-plugin-react-refresh"
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from "typescript-eslint"
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from "eslint/config"
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(["dist"]),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ["**/*.{ts,tsx}"],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
reactHooks.configs['recommended-latest'],
|
reactHooks.configs["recommended-latest"],
|
||||||
reactRefresh.configs.vite,
|
reactRefresh.configs.vite,
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["test/**/*.{ts,tsx}"],
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
248
package-lock.json
generated
248
package-lock.json
generated
@@ -32,6 +32,7 @@
|
|||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"ts-jest": "^29.4.5",
|
"ts-jest": "^29.4.5",
|
||||||
|
"typedoc": "^0.28.14",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.44.0",
|
"typescript-eslint": "^8.44.0",
|
||||||
"vite": "^7.1.7"
|
"vite": "^7.1.7"
|
||||||
@@ -1348,6 +1349,20 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@gerrit0/mini-shiki": {
|
||||||
|
"version": "3.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.15.0.tgz",
|
||||||
|
"integrity": "sha512-L5IHdZIDa4bG4yJaOzfasOH/o22MCesY0mx+n6VATbaiCtMeR59pdRqYk4bEiQkIHfxsHPNgdi7VJlZb2FhdMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@shikijs/engine-oniguruma": "^3.15.0",
|
||||||
|
"@shikijs/langs": "^3.15.0",
|
||||||
|
"@shikijs/themes": "^3.15.0",
|
||||||
|
"@shikijs/types": "^3.15.0",
|
||||||
|
"@shikijs/vscode-textmate": "^10.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -1460,9 +1475,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
||||||
"version": "3.14.1",
|
"version": "3.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2351,6 +2366,55 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@shikijs/engine-oniguruma": {
|
||||||
|
"version": "3.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.15.0.tgz",
|
||||||
|
"integrity": "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@shikijs/types": "3.15.0",
|
||||||
|
"@shikijs/vscode-textmate": "^10.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@shikijs/langs": {
|
||||||
|
"version": "3.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.15.0.tgz",
|
||||||
|
"integrity": "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@shikijs/types": "3.15.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@shikijs/themes": {
|
||||||
|
"version": "3.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.15.0.tgz",
|
||||||
|
"integrity": "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@shikijs/types": "3.15.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@shikijs/types": {
|
||||||
|
"version": "3.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.15.0.tgz",
|
||||||
|
"integrity": "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@shikijs/vscode-textmate": "^10.0.2",
|
||||||
|
"@types/hast": "^3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@shikijs/vscode-textmate": {
|
||||||
|
"version": "10.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
|
||||||
|
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@sinclair/typebox": {
|
"node_modules/@sinclair/typebox": {
|
||||||
"version": "0.34.41",
|
"version": "0.34.41",
|
||||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
|
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
|
||||||
@@ -2637,6 +2701,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/hast": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/unist": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/istanbul-lib-coverage": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||||
@@ -2738,6 +2812,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/unist": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.33",
|
"version": "17.0.33",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
||||||
@@ -3324,12 +3405,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xyflow/react": {
|
"node_modules/@xyflow/react": {
|
||||||
"version": "12.8.6",
|
"version": "12.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz",
|
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.1.tgz",
|
||||||
"integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==",
|
"integrity": "sha512-JRPCT5p7NnPdVSIh15AFvUSSm+8GUyz2I6iuBEC1LG2lKgig/L48AM/ImMHCc3ZUCg+AgTOJDaX2fcRyPA9BTA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xyflow/system": "0.0.70",
|
"@xyflow/system": "0.0.72",
|
||||||
"classcat": "^5.0.3",
|
"classcat": "^5.0.3",
|
||||||
"zustand": "^4.4.0"
|
"zustand": "^4.4.0"
|
||||||
},
|
},
|
||||||
@@ -3367,9 +3448,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xyflow/system": {
|
"node_modules/@xyflow/system": {
|
||||||
"version": "0.0.70",
|
"version": "0.0.72",
|
||||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz",
|
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz",
|
||||||
"integrity": "sha512-PpC//u9zxdjj0tfTSmZrg3+sRbTz6kop/Amky44U2Dl51sxzDTIUfXMwETOYpmr2dqICWXBIJwXL2a9QWtX2XA==",
|
"integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-drag": "^3.0.7",
|
"@types/d3-drag": "^3.0.7",
|
||||||
@@ -5908,9 +5989,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6055,6 +6136,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uc.micro": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -6095,6 +6186,13 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lunr": {
|
||||||
|
"version": "2.3.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
|
||||||
|
"integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
@@ -6152,6 +6250,44 @@
|
|||||||
"tmpl": "1.0.5"
|
"tmpl": "1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/markdown-it": {
|
||||||
|
"version": "14.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||||
|
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^2.0.1",
|
||||||
|
"entities": "^4.4.0",
|
||||||
|
"linkify-it": "^5.0.0",
|
||||||
|
"mdurl": "^2.0.0",
|
||||||
|
"punycode.js": "^2.3.1",
|
||||||
|
"uc.micro": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"markdown-it": "bin/markdown-it.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/markdown-it/node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/merge-stream": {
|
"node_modules/merge-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
@@ -6704,6 +6840,16 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/punycode.js": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pure-rand": {
|
"node_modules/pure-rand": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
|
||||||
@@ -7602,6 +7748,56 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typedoc": {
|
||||||
|
"version": "0.28.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.14.tgz",
|
||||||
|
"integrity": "sha512-ftJYPvpVfQvFzpkoSfHLkJybdA/geDJ8BGQt/ZnkkhnBYoYW6lBgPQXu6vqLxO4X75dA55hX8Af847H5KXlEFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@gerrit0/mini-shiki": "^3.12.0",
|
||||||
|
"lunr": "^2.3.9",
|
||||||
|
"markdown-it": "^14.1.0",
|
||||||
|
"minimatch": "^9.0.5",
|
||||||
|
"yaml": "^2.8.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"typedoc": "bin/typedoc"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18",
|
||||||
|
"pnpm": ">= 10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typedoc/node_modules/brace-expansion": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typedoc/node_modules/minimatch": {
|
||||||
|
"version": "9.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||||
|
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.8.3",
|
"version": "5.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
@@ -7640,6 +7836,13 @@
|
|||||||
"typescript": ">=4.8.4 <6.0.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uc.micro": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/uglify-js": {
|
"node_modules/uglify-js": {
|
||||||
"version": "3.19.3",
|
"version": "3.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||||
@@ -7738,9 +7941,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.5.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
@@ -8142,6 +8345,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"ts-jest": "^29.4.5",
|
"ts-jest": "^29.4.5",
|
||||||
|
"typedoc": "^0.28.14",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.44.0",
|
"typescript-eslint": "^8.44.0",
|
||||||
"vite": "^7.1.7"
|
"vite": "^7.1.7"
|
||||||
|
|||||||
90
src/App.css
90
src/App.css
@@ -82,6 +82,10 @@ button.movePage:hover{
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -96,6 +100,7 @@ header {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
background-color: var(--accent-color);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
z-index: 1; /* Otherwise any translated elements render above the blur?? */
|
z-index: 1; /* Otherwise any translated elements render above the blur?? */
|
||||||
}
|
}
|
||||||
@@ -104,6 +109,10 @@ main {
|
|||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.flex-row {
|
.flex-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -121,6 +130,14 @@ main {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.min-height-0 {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-y {
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
.align-center {
|
.align-center {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -141,6 +158,10 @@ main {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.margin-0 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.padding-sm {
|
.padding-sm {
|
||||||
padding: .25rem;
|
padding: .25rem;
|
||||||
}
|
}
|
||||||
@@ -150,7 +171,19 @@ main {
|
|||||||
.padding-lg {
|
.padding-lg {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
.padding-b-sm {
|
||||||
|
padding-bottom: .25rem;
|
||||||
|
}
|
||||||
|
.padding-b-md {
|
||||||
|
padding-bottom: .5rem;
|
||||||
|
}
|
||||||
|
.padding-b-lg {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-sm, .round-md, .round-lg {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
.round-sm {
|
.round-sm {
|
||||||
border-radius: .25rem;
|
border-radius: .25rem;
|
||||||
}
|
}
|
||||||
@@ -159,4 +192,59 @@ main {
|
|||||||
}
|
}
|
||||||
.round-lg {
|
.round-lg {
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-sm {
|
||||||
|
border: 1px solid canvastext;
|
||||||
|
}
|
||||||
|
.border-md {
|
||||||
|
border: 2px solid canvastext;
|
||||||
|
}
|
||||||
|
.border-lg {
|
||||||
|
border: 3px solid canvastext;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-small {
|
||||||
|
font-size: .75rem;
|
||||||
|
}
|
||||||
|
.font-medium {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.font-large {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.user-select-all {
|
||||||
|
-webkit-user-select: all;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
.user-select-none {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
button.no-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
30
src/App.tsx
30
src/App.tsx
@@ -3,24 +3,34 @@ import './App.css'
|
|||||||
import TemplatePage from './pages/TemplatePage/Template.tsx'
|
import TemplatePage from './pages/TemplatePage/Template.tsx'
|
||||||
import Home from './pages/Home/Home.tsx'
|
import Home from './pages/Home/Home.tsx'
|
||||||
import Robot from './pages/Robot/Robot.tsx';
|
import Robot from './pages/Robot/Robot.tsx';
|
||||||
|
import ConnectedRobots from './pages/ConnectedRobots/ConnectedRobots.tsx'
|
||||||
import VisProg from "./pages/VisProgPage/VisProg.tsx";
|
import VisProg from "./pages/VisProgPage/VisProg.tsx";
|
||||||
|
import {useState} from "react";
|
||||||
|
import Logging from "./components/Logging/Logging.tsx";
|
||||||
|
|
||||||
function App(){
|
function App(){
|
||||||
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<header>
|
<header>
|
||||||
<Link to={"/"}>Home</Link>
|
<Link to={"/"}>Home</Link>
|
||||||
|
<button onClick={() => setShowLogs(!showLogs)}>Toggle Logging</button>
|
||||||
</header>
|
</header>
|
||||||
<main className={"flex-col align-center"}>
|
<div className={"flex-row justify-center flex-1 min-height-0"}>
|
||||||
<Routes>
|
<main className={"flex-col align-center flex-1 scroll-y"}>
|
||||||
<Route path="/" element={<Home />} />
|
<Routes>
|
||||||
<Route path="/template" element={<TemplatePage />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/editor" element={<VisProg />} />
|
<Route path="/template" element={<TemplatePage />} />
|
||||||
<Route path="/robot" element={<Robot />} />
|
<Route path="/editor" element={<VisProg />} />
|
||||||
|
<Route path="/robot" element={<Robot />} />
|
||||||
|
<Route path="/ConnectedRobots" element={<ConnectedRobots />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
{showLogs && <Logging />}
|
||||||
)
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
34
src/components/Logging/Filters.module.css
Normal file
34
src/components/Logging/Filters.module.css
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
.filter-root {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .25rem;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: canvas;
|
||||||
|
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
|
||||||
|
width: 300px;
|
||||||
|
|
||||||
|
*:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
*:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button.deletable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/components/Logging/Filters.tsx
Normal file
200
src/components/Logging/Filters.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
|
||||||
|
import type {LogFilterPredicate} from "./useLogs.ts";
|
||||||
|
|
||||||
|
import styles from "./Filters.module.css";
|
||||||
|
|
||||||
|
type Setter<T> = (value: T | ((prev: T) => T)) => void;
|
||||||
|
|
||||||
|
const optionMapping = new Map([
|
||||||
|
["ALL", 0],
|
||||||
|
["DEBUG", 10],
|
||||||
|
["INFO", 20],
|
||||||
|
["WARNING", 30],
|
||||||
|
["ERROR", 40],
|
||||||
|
["CRITICAL", 50],
|
||||||
|
["NONE", 999_999_999_999], // It is technically possible to have a higher level, but this is fine
|
||||||
|
]);
|
||||||
|
|
||||||
|
function LevelPredicateElement({
|
||||||
|
name,
|
||||||
|
level,
|
||||||
|
setLevel,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
level: string;
|
||||||
|
setLevel: (level: string) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}) {
|
||||||
|
const normalizedName = name.split(".").pop() || name;
|
||||||
|
|
||||||
|
return <div className={"flex-row gap-sm align-center"}>
|
||||||
|
<label
|
||||||
|
htmlFor={`log_level_${name}`}
|
||||||
|
className={"font-small"}
|
||||||
|
>
|
||||||
|
{onDelete
|
||||||
|
? <button
|
||||||
|
className={`no-button ${styles.deletable}`}
|
||||||
|
onClick={onDelete}
|
||||||
|
>{normalizedName}:</button>
|
||||||
|
: normalizedName + ':'
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`log_level_${name}`}
|
||||||
|
value={level}
|
||||||
|
onChange={(e) => setLevel(e.target.value)}
|
||||||
|
>
|
||||||
|
{Array.from(optionMapping.keys()).map((key) => (
|
||||||
|
<option key={key} value={key}>{key}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLOBAL_LOG_LEVEL_PREDICATE_KEY = "global_log_level";
|
||||||
|
|
||||||
|
function GlobalLevelFilter({
|
||||||
|
filterPredicates,
|
||||||
|
setFilterPredicates,
|
||||||
|
}: {
|
||||||
|
filterPredicates: Map<string, LogFilterPredicate>;
|
||||||
|
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
|
||||||
|
}) {
|
||||||
|
const selected = filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value || "ALL";
|
||||||
|
const setSelected = (selected: string | null) => {
|
||||||
|
if (!selected || !optionMapping.has(selected)) return;
|
||||||
|
|
||||||
|
setFilterPredicates((curr) => {
|
||||||
|
const next = new Map(curr);
|
||||||
|
next.set(GLOBAL_LOG_LEVEL_PREDICATE_KEY, {
|
||||||
|
predicate: (record) => record.levelno >= optionMapping.get(selected)!,
|
||||||
|
priority: 0,
|
||||||
|
value: selected,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filterPredicates.has(GLOBAL_LOG_LEVEL_PREDICATE_KEY)) return;
|
||||||
|
setSelected("INFO");
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // Run only once when the component mounts, not when anything changes
|
||||||
|
|
||||||
|
return <LevelPredicateElement
|
||||||
|
name={"Global"}
|
||||||
|
level={selected}
|
||||||
|
setLevel={setSelected}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX = "agent_log_level_";
|
||||||
|
|
||||||
|
function AgentLevelFilters({
|
||||||
|
filterPredicates,
|
||||||
|
setFilterPredicates,
|
||||||
|
agentNames,
|
||||||
|
}: {
|
||||||
|
filterPredicates: Map<string, LogFilterPredicate>;
|
||||||
|
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
|
||||||
|
agentNames: Set<string>;
|
||||||
|
}) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// Click outside to close
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDocClick = (e: MouseEvent) => {
|
||||||
|
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
setOpen(false);
|
||||||
|
e.preventDefault(); // Don't exit fullscreen mode
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDocClick);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDocClick);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const agentPredicates = [...filterPredicates.keys()].filter((key) =>
|
||||||
|
key.startsWith(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or change the predicate for an agent. If the level is not given, the global level is used.
|
||||||
|
* @param agentName The name of the agent.
|
||||||
|
* @param level The level to filter by. If not given, the global level is used.
|
||||||
|
*/
|
||||||
|
const setAgentPredicate = (agentName: string, level?: string ) => {
|
||||||
|
level = level ?? filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value ?? "ALL";
|
||||||
|
setFilterPredicates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName, {
|
||||||
|
predicate: (record) => record.name === agentName
|
||||||
|
? record.levelno >= optionMapping.get(level!)!
|
||||||
|
: null,
|
||||||
|
priority: 1,
|
||||||
|
value: {agentName, level},
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteAgentPredicate = (agentName: string) => {
|
||||||
|
setFilterPredicates((curr) => {
|
||||||
|
const fullName = AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName;
|
||||||
|
if (!curr.has(fullName)) return curr; // Return unchanged, no re-render
|
||||||
|
const next = new Map(curr);
|
||||||
|
next.delete(fullName);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{agentPredicates.map((key) => {
|
||||||
|
const {agentName, level} = filterPredicates.get(key)!.value;
|
||||||
|
|
||||||
|
return <LevelPredicateElement
|
||||||
|
key={key}
|
||||||
|
name={agentName}
|
||||||
|
level={level}
|
||||||
|
setLevel={(level) => setAgentPredicate(agentName, level)}
|
||||||
|
onDelete={() => deleteAgentPredicate(agentName)}
|
||||||
|
/>;
|
||||||
|
})}
|
||||||
|
<div className={"flex-row gap-sm align-center"}>
|
||||||
|
<label htmlFor={"add_agent"} className={"font-small"}>Add:</label>
|
||||||
|
<select
|
||||||
|
id={"add_agent"}
|
||||||
|
value={""}
|
||||||
|
onChange={(e) => !!e.target.value && setAgentPredicate(e.target.value)}
|
||||||
|
>
|
||||||
|
{["", ...agentNames.keys()].map((key) => (
|
||||||
|
<option key={key} value={key}>{key.split(".").pop()}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Filters({
|
||||||
|
filterPredicates,
|
||||||
|
setFilterPredicates,
|
||||||
|
agentNames,
|
||||||
|
}: {
|
||||||
|
filterPredicates: Map<string, LogFilterPredicate>;
|
||||||
|
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
|
||||||
|
agentNames: Set<string>;
|
||||||
|
}) {
|
||||||
|
return <div className={"flex-1 flex-row flex-wrap gap-md align-center"}>
|
||||||
|
<GlobalLevelFilter filterPredicates={filterPredicates} setFilterPredicates={setFilterPredicates} />
|
||||||
|
<AgentLevelFilters filterPredicates={filterPredicates} setFilterPredicates={setFilterPredicates} agentNames={agentNames} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
39
src/components/Logging/Logging.module.css
Normal file
39
src/components/Logging/Logging.module.css
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
.logging-container {
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
width: max(30dvw, 500px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
box-shadow: 0 0 1rem black;
|
||||||
|
padding: 1rem 1rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-numbers {
|
||||||
|
list-style-type: none;
|
||||||
|
counter-reset: none;
|
||||||
|
padding-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container {
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
|
||||||
|
.accented-0, .accented-10 {
|
||||||
|
background-color: color-mix(in oklab, canvas, rgb(159, 159, 159) 35%)
|
||||||
|
}
|
||||||
|
.accented-20 {
|
||||||
|
background-color: color-mix(in oklab, canvas, green 35%)
|
||||||
|
}
|
||||||
|
.accented-30 {
|
||||||
|
background-color: color-mix(in oklab, canvas, yellow 35%)
|
||||||
|
}
|
||||||
|
.accented-40, .accented-50 {
|
||||||
|
background-color: color-mix(in oklab, canvas, red 35%)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-button {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
129
src/components/Logging/Logging.tsx
Normal file
129
src/components/Logging/Logging.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import {create} from "zustand";
|
||||||
|
|
||||||
|
import formatDuration from "../../utils/formatDuration.ts";
|
||||||
|
import {type LogFilterPredicate, type LogRecord, useLogs} from "./useLogs.ts";
|
||||||
|
import Filters from "./Filters.tsx";
|
||||||
|
import {type Cell, useCell} from "../../utils/cellStore.ts";
|
||||||
|
|
||||||
|
import styles from "./Logging.module.css";
|
||||||
|
|
||||||
|
type LoggingSettings = {
|
||||||
|
showRelativeTime: boolean;
|
||||||
|
setShowRelativeTime: (showRelativeTime: boolean) => void;
|
||||||
|
scrollToBottom: boolean;
|
||||||
|
setScrollToBottom: (scrollToBottom: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useLoggingSettings = create<LoggingSettings>((set) => ({
|
||||||
|
showRelativeTime: false,
|
||||||
|
setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }),
|
||||||
|
scrollToBottom: true,
|
||||||
|
setScrollToBottom: (scrollToBottom: boolean) => set({ scrollToBottom }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function LogMessage({
|
||||||
|
recordCell,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
recordCell: Cell<LogRecord>,
|
||||||
|
onUpdate?: () => void,
|
||||||
|
}) {
|
||||||
|
const { showRelativeTime, setShowRelativeTime } = useLoggingSettings();
|
||||||
|
const record = useCell(recordCell);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes the log level number to a multiple of 10, for which there are CSS styles.
|
||||||
|
*/
|
||||||
|
const normalizedLevelNo = (() => {
|
||||||
|
// By default, the highest level is 50 (CRITICAL). Custom levels can be higher, but we don't have more critical color.
|
||||||
|
if (record.levelno >= 50) return 50;
|
||||||
|
|
||||||
|
return Math.round(record.levelno / 10) * 10;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const normalizedName = record.name.split(".").pop() || record.name;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onUpdate) onUpdate();
|
||||||
|
}, [record, onUpdate]);
|
||||||
|
|
||||||
|
return <div className={`${styles.logContainer} round-md border-lg flex-row gap-md`}>
|
||||||
|
<div className={`${styles[`accented${normalizedLevelNo}`]} flex-col padding-sm justify-between`}>
|
||||||
|
<span className={"mono bold"}>{record.levelname}</span>
|
||||||
|
<span className={"mono clickable font-small"}
|
||||||
|
onClick={() => setShowRelativeTime(!showRelativeTime)}
|
||||||
|
>{showRelativeTime
|
||||||
|
? formatDuration(record.relativeCreated)
|
||||||
|
: new Date(record.created * 1000).toLocaleTimeString()
|
||||||
|
}</span>
|
||||||
|
</div>
|
||||||
|
<div className={"flex-col flex-1 padding-sm"}>
|
||||||
|
<span className={"mono"}>{normalizedName}</span>
|
||||||
|
<span>{record.message}</span>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogMessages({ recordCells }: { recordCells: Cell<LogRecord>[] }) {
|
||||||
|
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lastElementRef = useRef<HTMLLIElement>(null)
|
||||||
|
const { scrollToBottom, setScrollToBottom } = useLoggingSettings();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollableRef.current) return;
|
||||||
|
const currentScrollableRef = scrollableRef.current;
|
||||||
|
|
||||||
|
const handleScroll = () => setScrollToBottom(false);
|
||||||
|
|
||||||
|
currentScrollableRef.addEventListener("wheel", handleScroll);
|
||||||
|
currentScrollableRef.addEventListener("touchmove", handleScroll);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
currentScrollableRef.removeEventListener("wheel", handleScroll);
|
||||||
|
currentScrollableRef.removeEventListener("touchmove", handleScroll);
|
||||||
|
}
|
||||||
|
}, [scrollableRef, setScrollToBottom]);
|
||||||
|
|
||||||
|
function scrollLastElementIntoView(force = false) {
|
||||||
|
if ((!scrollToBottom && !force) || !lastElementRef.current) return;
|
||||||
|
lastElementRef.current.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div ref={scrollableRef} className={"min-height-0 scroll-y padding-b-md"}>
|
||||||
|
<ol className={`${styles.noNumbers} margin-0 flex-col gap-md`}>
|
||||||
|
{recordCells.map((recordCell, i) => (
|
||||||
|
<li key={`${i}_${recordCell.get().firstRelativeCreated}`}>
|
||||||
|
<LogMessage recordCell={recordCell} onUpdate={scrollLastElementIntoView} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li ref={lastElementRef}></li>
|
||||||
|
</ol>
|
||||||
|
{!scrollToBottom && <button
|
||||||
|
className={styles.floatingButton}
|
||||||
|
onClick={() => {
|
||||||
|
setScrollToBottom(true);
|
||||||
|
scrollLastElementIntoView(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Scroll to bottom
|
||||||
|
</button>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Logging() {
|
||||||
|
const [filterPredicates, setFilterPredicates] = useState(new Map<string, LogFilterPredicate>());
|
||||||
|
const { filteredLogs, distinctNames } = useLogs(filterPredicates)
|
||||||
|
|
||||||
|
return <div className={`flex-col gap-lg min-height-0 ${styles.loggingContainer}`}>
|
||||||
|
<div className={"flex-row gap-lg justify-between align-center"}>
|
||||||
|
<h2 className={"margin-0"}>Logs</h2>
|
||||||
|
<Filters
|
||||||
|
filterPredicates={filterPredicates}
|
||||||
|
setFilterPredicates={setFilterPredicates}
|
||||||
|
agentNames={distinctNames}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<LogMessages recordCells={filteredLogs} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
146
src/components/Logging/useLogs.ts
Normal file
146
src/components/Logging/useLogs.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import {useCallback, useEffect, useRef, useState} from "react";
|
||||||
|
|
||||||
|
import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../utils/priorityFiltering.ts";
|
||||||
|
import {cell, type Cell} from "../../utils/cellStore.ts";
|
||||||
|
|
||||||
|
export type LogRecord = {
|
||||||
|
name: string;
|
||||||
|
message: string;
|
||||||
|
levelname: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string;
|
||||||
|
levelno: number;
|
||||||
|
created: number;
|
||||||
|
relativeCreated: number;
|
||||||
|
reference?: string;
|
||||||
|
firstCreated: number;
|
||||||
|
firstRelativeCreated: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type LogFilterPredicate = PriorityFilterPredicate<LogRecord> & { value: any };
|
||||||
|
|
||||||
|
export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
|
||||||
|
const [distinctNames, setDistinctNames] = useState<Set<string>>(new Set());
|
||||||
|
const [filtered, setFiltered] = useState<Cell<LogRecord>[]>([]);
|
||||||
|
|
||||||
|
const sseRef = useRef<EventSource | null>(null);
|
||||||
|
const filtersRef = useRef(filterPredicates);
|
||||||
|
const logsRef = useRef<LogRecord[]>([]);
|
||||||
|
|
||||||
|
/** Map to store the first message for each reference, instance can be updated to change contents. */
|
||||||
|
const firstByRefRef = useRef<Map<string, Cell<LogRecord>>>(new Map());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the filter predicates to a log record.
|
||||||
|
* @param log The log record to apply the filters to.
|
||||||
|
* @returns `true` if the record passes.
|
||||||
|
*/
|
||||||
|
const applyFilters = useCallback((log: LogRecord) =>
|
||||||
|
applyPriorityPredicates(log, [...filtersRef.current.values()]), []);
|
||||||
|
|
||||||
|
/** Recomputes the entire filtered list. Use when filter predicates change. */
|
||||||
|
const recomputeFiltered = useCallback(() => {
|
||||||
|
const newFiltered: Cell<LogRecord>[] = [];
|
||||||
|
firstByRefRef.current = new Map();
|
||||||
|
|
||||||
|
for (const message of logsRef.current) {
|
||||||
|
const messageCell = cell<LogRecord>({
|
||||||
|
...message,
|
||||||
|
firstCreated: message.created,
|
||||||
|
firstRelativeCreated: message.relativeCreated,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (message.reference) {
|
||||||
|
const first = firstByRefRef.current.get(message.reference);
|
||||||
|
if (first) {
|
||||||
|
// Update the first's contents
|
||||||
|
first.set((prev) => ({
|
||||||
|
...message,
|
||||||
|
firstCreated: prev.firstCreated ?? prev.created,
|
||||||
|
firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Don't add it to the list again
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
// Add the first message with this reference to the registry
|
||||||
|
firstByRefRef.current.set(message.reference, messageCell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applyFilters(message)) {
|
||||||
|
newFiltered.push(messageCell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiltered(newFiltered);
|
||||||
|
}, [applyFilters, setFiltered]);
|
||||||
|
|
||||||
|
// Reapply filters to all logs, only when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
filtersRef.current = filterPredicates;
|
||||||
|
recomputeFiltered();
|
||||||
|
}, [filterPredicates, recomputeFiltered]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a new log message. Updates the filtered list and to the full history.
|
||||||
|
* @param message The new log message.
|
||||||
|
*/
|
||||||
|
const handleNewMessage = useCallback((message: LogRecord) => {
|
||||||
|
// Add to the full history for re-filtering on filter changes
|
||||||
|
logsRef.current.push(message);
|
||||||
|
|
||||||
|
setDistinctNames((prev) => {
|
||||||
|
if (prev.has(message.name)) return prev;
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.add(message.name);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageCell = cell<LogRecord>({
|
||||||
|
...message,
|
||||||
|
firstCreated: message.created,
|
||||||
|
firstRelativeCreated: message.relativeCreated,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (message.reference) {
|
||||||
|
const first = firstByRefRef.current.get(message.reference);
|
||||||
|
if (first) {
|
||||||
|
// Update the first's contents
|
||||||
|
first.set((prev) => ({
|
||||||
|
...message,
|
||||||
|
firstCreated: prev.firstCreated ?? prev.created,
|
||||||
|
firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Don't add it to the list again
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// Add the first message with this reference to the registry
|
||||||
|
firstByRefRef.current.set(message.reference, messageCell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applyFilters(message)) {
|
||||||
|
setFiltered((curr) => [...curr, messageCell]);
|
||||||
|
}
|
||||||
|
}, [applyFilters, setFiltered]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sseRef.current) return;
|
||||||
|
|
||||||
|
const es = new EventSource("http://localhost:8000/logs/stream");
|
||||||
|
sseRef.current = es;
|
||||||
|
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
const data: LogRecord = JSON.parse(event.data);
|
||||||
|
handleNewMessage(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
es.close();
|
||||||
|
sseRef.current = null;
|
||||||
|
};
|
||||||
|
}, [handleNewMessage]);
|
||||||
|
|
||||||
|
return {filteredLogs: filtered, distinctNames};
|
||||||
|
}
|
||||||
14
src/components/ScrollIntoView.tsx
Normal file
14
src/components/ScrollIntoView.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import {useEffect, useRef} from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An element that always scrolls into view when it is rendered. When added to a list, the entire list will scroll to show this element.
|
||||||
|
*/
|
||||||
|
export default function ScrollIntoView() {
|
||||||
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (elementRef.current) elementRef.current.scrollIntoView({ behavior: "smooth" });
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div ref={elementRef} />;
|
||||||
|
}
|
||||||
27
src/components/TextField.module.css
Normal file
27
src/components/TextField.module.css
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
.text-field {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 5pt;
|
||||||
|
padding: 4px 8px;
|
||||||
|
outline: none;
|
||||||
|
background-color: canvas;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field.invalid {
|
||||||
|
border-color: red;
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field:focus:not(.invalid) {
|
||||||
|
border-color: color-mix(in srgb, canvas, #777 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field:read-only {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: color-mix(in srgb, canvas, #777 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field:read-only:hover:not(.invalid) {
|
||||||
|
border-color: color-mix(in srgb, canvas, #777 10%);
|
||||||
|
}
|
||||||
101
src/components/TextField.tsx
Normal file
101
src/components/TextField.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import {useState} from "react";
|
||||||
|
import styles from "./TextField.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text input element in our own style that calls `setValue` at every keystroke.
|
||||||
|
*
|
||||||
|
* @param {Object} props - The component props.
|
||||||
|
* @param {string} props.value - The value of the text input.
|
||||||
|
* @param {(value: string) => void} props.setValue - A function that sets the value of the text input.
|
||||||
|
* @param {string} [props.placeholder] - The placeholder text for the text input.
|
||||||
|
* @param {string} [props.className] - Additional CSS classes for the text input.
|
||||||
|
* @param {string} [props.id] - The ID of the text input.
|
||||||
|
* @param {string} [props.ariaLabel] - The ARIA label for the text input.
|
||||||
|
*/
|
||||||
|
export function RealtimeTextField({
|
||||||
|
value = "",
|
||||||
|
setValue,
|
||||||
|
onCommit,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
ariaLabel,
|
||||||
|
invalid = false,
|
||||||
|
} : {
|
||||||
|
value: string,
|
||||||
|
setValue: (value: string) => void,
|
||||||
|
onCommit: () => void,
|
||||||
|
placeholder?: string,
|
||||||
|
className?: string,
|
||||||
|
id?: string,
|
||||||
|
ariaLabel?: string,
|
||||||
|
invalid?: boolean,
|
||||||
|
}) {
|
||||||
|
const [readOnly, setReadOnly] = useState(true);
|
||||||
|
|
||||||
|
const updateData = () => {
|
||||||
|
setReadOnly(true);
|
||||||
|
onCommit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateOnEnter = (event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); };
|
||||||
|
|
||||||
|
return <input
|
||||||
|
type={"text"}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onFocus={() => setReadOnly(false)}
|
||||||
|
onBlur={updateData}
|
||||||
|
onKeyDown={updateOnEnter}
|
||||||
|
readOnly={readOnly}
|
||||||
|
id={id}
|
||||||
|
// ReactFlow uses the "drag" / "nodrag" classes to enable / disable dragging of nodes
|
||||||
|
className={`${readOnly ? "drag" : "nodrag"} ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text input element in our own style that calls `setValue` once the user presses the enter key or clicks outside the input.
|
||||||
|
*
|
||||||
|
* @param {Object} props - The component props.
|
||||||
|
* @param {string} props.value - The value of the text input.
|
||||||
|
* @param {(value: string) => void} props.setValue - A function that sets the value of the text input.
|
||||||
|
* @param {string} [props.placeholder] - The placeholder text for the text input.
|
||||||
|
* @param {string} [props.className] - Additional CSS classes for the text input.
|
||||||
|
* @param {string} [props.id] - The ID of the text input.
|
||||||
|
* @param {string} [props.ariaLabel] - The ARIA label for the text input.
|
||||||
|
*/
|
||||||
|
export function TextField({
|
||||||
|
value = "",
|
||||||
|
setValue,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
ariaLabel,
|
||||||
|
invalid = false,
|
||||||
|
} : {
|
||||||
|
value: string,
|
||||||
|
setValue: (value: string) => void,
|
||||||
|
placeholder?: string,
|
||||||
|
className?: string,
|
||||||
|
id?: string,
|
||||||
|
ariaLabel?: string,
|
||||||
|
invalid?: boolean,
|
||||||
|
}) {
|
||||||
|
const [inputValue, setInputValue] = useState(value);
|
||||||
|
|
||||||
|
const onCommit = () => setValue(inputValue);
|
||||||
|
|
||||||
|
return <RealtimeTextField
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={inputValue}
|
||||||
|
setValue={setInputValue}
|
||||||
|
onCommit={onCommit}
|
||||||
|
id={id}
|
||||||
|
className={className}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
invalid={invalid}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
@@ -7,13 +7,15 @@
|
|||||||
color: rgba(255, 255, 255, 0.87);
|
color: rgba(255, 255, 255, 0.87);
|
||||||
background-color: #242424;
|
background-color: #242424;
|
||||||
|
|
||||||
|
--accent-color: #008080;
|
||||||
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body, #root {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
@@ -25,11 +27,7 @@ html, body {
|
|||||||
|
|
||||||
a {
|
a {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #646cff;
|
color: canvastext;
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -49,7 +47,7 @@ button {
|
|||||||
transition: border-color 0.25s;
|
transition: border-color 0.25s;
|
||||||
}
|
}
|
||||||
button:hover {
|
button:hover {
|
||||||
border-color: #646cff;
|
border-color: var(--accent-color);
|
||||||
}
|
}
|
||||||
button:focus,
|
button:focus,
|
||||||
button:focus-visible {
|
button:focus-visible {
|
||||||
@@ -60,9 +58,8 @@ button:focus-visible {
|
|||||||
:root {
|
:root {
|
||||||
color: #213547;
|
color: #213547;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
}
|
|
||||||
a:hover {
|
--accent-color: #00AAAA;
|
||||||
color: #747bff;
|
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
background-color: #f9f9f9;
|
background-color: #f9f9f9;
|
||||||
|
|||||||
43
src/pages/ConnectedRobots/ConnectedRobots.tsx
Normal file
43
src/pages/ConnectedRobots/ConnectedRobots.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export default function ConnectedRobots() {
|
||||||
|
|
||||||
|
const [connected, setConnected] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// We're excepting a stream of data like that looks like this: `data = False` or `data = True`
|
||||||
|
const eventSource = new EventSource("http://localhost:8000/robot/ping_stream");
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
|
||||||
|
// Receive message and parse
|
||||||
|
console.log("received message:", event.data);
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
// Set connected to value.
|
||||||
|
try {
|
||||||
|
setConnected(data)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
console.log("couldnt extract connected from incoming ping data")
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
console.log("Ping message not in correct format:", event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => eventSource.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Is robot currently connected?</h1>
|
||||||
|
<div>
|
||||||
|
<h2>Robot is currently: {connected == null ? "checking..." : (connected ? "connected! 🟢" : "not connected... 🔴")} </h2>
|
||||||
|
<h3>
|
||||||
|
{connected == null ? "If checking continues, make sure CB is properly loaded with robot at least once." : ""}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ function Home() {
|
|||||||
<Link to={"/robot"}>Robot Interaction →</Link>
|
<Link to={"/robot"}>Robot Interaction →</Link>
|
||||||
<Link to={"/editor"}>Editor →</Link>
|
<Link to={"/editor"}>Editor →</Link>
|
||||||
<Link to={"/template"}>Template →</Link>
|
<Link to={"/template"}>Template →</Link>
|
||||||
|
<Link to={"/ConnectedRobots"}>Connected Robots →</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,47 +1,12 @@
|
|||||||
/* editor UI */
|
/* editor UI */
|
||||||
|
|
||||||
.outer-editor-container {
|
|
||||||
margin-inline: auto;
|
|
||||||
display: flex;
|
|
||||||
justify-self: center;
|
|
||||||
padding: 10px;
|
|
||||||
align-items: center;
|
|
||||||
width: 80vw;
|
|
||||||
height: 80vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inner-editor-container {
|
.inner-editor-container {
|
||||||
outline-style: solid;
|
box-sizing: border-box;
|
||||||
border-radius: 10pt;
|
margin: 1rem;
|
||||||
width: 90%;
|
width: calc(100% - 2rem);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.node-text-input {
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 5pt;
|
|
||||||
padding: 4px 8px;
|
|
||||||
outline: none;
|
|
||||||
background-color: white;
|
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
|
||||||
cursor: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-text-input:focus {
|
|
||||||
border-color: gainsboro;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-text-input:read-only {
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: whitesmoke;
|
|
||||||
}
|
|
||||||
|
|
||||||
.node-text-input:read-only:hover {
|
|
||||||
border-color: gainsboro;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dnd-panel {
|
.dnd-panel {
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
margin-inline-end: auto;
|
margin-inline-end: auto;
|
||||||
@@ -85,10 +50,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.node-norm {
|
.node-norm {
|
||||||
outline: forestgreen solid 2pt;
|
outline: rgb(0, 149, 25) solid 2pt;
|
||||||
filter: drop-shadow(0 0 0.25rem forestgreen);
|
filter: drop-shadow(0 0 0.25rem forestgreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-goal {
|
||||||
|
outline: yellow solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-trigger {
|
||||||
|
outline: teal solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem teal);
|
||||||
|
}
|
||||||
|
|
||||||
.node-phase {
|
.node-phase {
|
||||||
outline: dodgerblue solid 2pt;
|
outline: dodgerblue solid 2pt;
|
||||||
filter: drop-shadow(0 0 0.25rem dodgerblue);
|
filter: drop-shadow(0 0 0.25rem dodgerblue);
|
||||||
@@ -120,6 +95,22 @@
|
|||||||
filter: drop-shadow(0 0 0.25rem forestgreen);
|
filter: drop-shadow(0 0 0.25rem forestgreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.draggable-node-goal {
|
||||||
|
padding: 3px 10px;
|
||||||
|
background-color: canvas;
|
||||||
|
border-radius: 5pt;
|
||||||
|
outline: yellow solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-node-trigger {
|
||||||
|
padding: 3px 10px;
|
||||||
|
background-color: canvas;
|
||||||
|
border-radius: 5pt;
|
||||||
|
outline: teal solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem teal);
|
||||||
|
}
|
||||||
|
|
||||||
.draggable-node-phase {
|
.draggable-node-phase {
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
background-color: canvas;
|
background-color: canvas;
|
||||||
|
|||||||
@@ -8,31 +8,15 @@ import {
|
|||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import {useShallow} from 'zustand/react/shallow';
|
import {useShallow} from 'zustand/react/shallow';
|
||||||
|
|
||||||
import {
|
|
||||||
StartNodeComponent,
|
|
||||||
EndNodeComponent,
|
|
||||||
PhaseNodeComponent,
|
|
||||||
NormNodeComponent
|
|
||||||
} from './visualProgrammingUI/components/NodeDefinitions.tsx';
|
|
||||||
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
||||||
import graphReducer from "./visualProgrammingUI/GraphReducer.ts";
|
|
||||||
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
||||||
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
||||||
import styles from './VisProg.module.css'
|
import styles from './VisProg.module.css'
|
||||||
|
import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
|
||||||
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
|
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
|
||||||
|
|
||||||
// --| config starting params for flow |--
|
// --| config starting params for flow |--
|
||||||
|
|
||||||
/**
|
|
||||||
* contains the types of all nodes that are available in the editor
|
|
||||||
*/
|
|
||||||
const NODE_TYPES = {
|
|
||||||
start: StartNodeComponent,
|
|
||||||
end: EndNodeComponent,
|
|
||||||
phase: PhaseNodeComponent,
|
|
||||||
norm: NormNodeComponent
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* defines how the default edge looks inside the editor
|
* defines how the default edge looks inside the editor
|
||||||
@@ -81,38 +65,37 @@ const VisProgUI = () => {
|
|||||||
} = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore
|
} = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.outerEditorContainer}>
|
<div className={`${styles.innerEditorContainer} round-lg border-lg`}>
|
||||||
<div className={styles.innerEditorContainer}>
|
<ReactFlow
|
||||||
<ReactFlow
|
nodes={nodes}
|
||||||
nodes={nodes}
|
edges={edges}
|
||||||
edges={edges}
|
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
||||||
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
nodeTypes={NodeTypes}
|
||||||
nodeTypes={NODE_TYPES}
|
onNodesChange={onNodesChange}
|
||||||
onNodesChange={onNodesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onReconnect={onReconnect}
|
||||||
onReconnect={onReconnect}
|
onReconnectStart={onReconnectStart}
|
||||||
onReconnectStart={onReconnectStart}
|
onReconnectEnd={onReconnectEnd}
|
||||||
onReconnectEnd={onReconnectEnd}
|
onConnect={onConnect}
|
||||||
onConnect={onConnect}
|
snapToGrid
|
||||||
snapToGrid
|
fitView
|
||||||
fitView
|
proOptions={{hideAttribution: true}}
|
||||||
proOptions={{hideAttribution: true}}
|
>
|
||||||
>
|
<Panel position="top-center" className={styles.dndPanel}>
|
||||||
<Panel position="top-center" className={styles.dndPanel}>
|
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
|
||||||
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
|
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel position = "bottom-left" className={styles.saveLoadPanel}>
|
<Panel position = "bottom-left" className={styles.saveLoadPanel}>
|
||||||
<SaveLoadPanel></SaveLoadPanel>
|
<SaveLoadPanel></SaveLoadPanel>
|
||||||
</Panel>
|
</Panel>
|
||||||
<Controls/>
|
<Controls/>
|
||||||
<Background/>
|
<Background/>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Places the VisProgUI component inside a ReactFlowProvider
|
* Places the VisProgUI component inside a ReactFlowProvider
|
||||||
*
|
*
|
||||||
@@ -132,6 +115,20 @@ function VisualProgrammingUI() {
|
|||||||
function runProgram() {
|
function runProgram() {
|
||||||
const program = graphReducer();
|
const program = graphReducer();
|
||||||
console.log(program);
|
console.log(program);
|
||||||
|
console.log(JSON.stringify(program, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces the graph into its phases' information and recursively calls their reducing function
|
||||||
|
*/
|
||||||
|
function graphReducer() {
|
||||||
|
const { nodes } = useFlowStore.getState();
|
||||||
|
return nodes
|
||||||
|
.filter((n) => n.type == 'phase')
|
||||||
|
.map((n) => {
|
||||||
|
const reducer = NodeReduces['phase'];
|
||||||
|
return reducer(n, nodes)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,4 +145,4 @@ function VisProgPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default VisProgPage
|
export default VisProgPage
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
import {
|
|
||||||
type Edge,
|
|
||||||
getIncomers,
|
|
||||||
getOutgoers
|
|
||||||
} from '@xyflow/react';
|
|
||||||
import useFlowStore from "./VisProgStores.tsx";
|
|
||||||
import type {
|
|
||||||
BehaviorProgram,
|
|
||||||
GoalData,
|
|
||||||
GoalReducer,
|
|
||||||
GraphPreprocessor,
|
|
||||||
NormData,
|
|
||||||
NormReducer,
|
|
||||||
OrderedPhases,
|
|
||||||
Phase,
|
|
||||||
PhaseReducer,
|
|
||||||
PreparedGraph,
|
|
||||||
PreparedPhase
|
|
||||||
} from "./GraphReducerTypes.ts";
|
|
||||||
import type {
|
|
||||||
AppNode,
|
|
||||||
GoalNode,
|
|
||||||
NormNode,
|
|
||||||
PhaseNode
|
|
||||||
} from "./VisProgTypes.tsx";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reduces the current graph inside the visual programming editor into a BehaviorProgram
|
|
||||||
*
|
|
||||||
* @param {GraphPreprocessor} graphPreprocessor
|
|
||||||
* @param {PhaseReducer} phaseReducer
|
|
||||||
* @param {NormReducer} normReducer
|
|
||||||
* @param {GoalReducer} goalReducer
|
|
||||||
* @returns {BehaviorProgram}
|
|
||||||
*/
|
|
||||||
export default function graphReducer(
|
|
||||||
graphPreprocessor: GraphPreprocessor = defaultGraphPreprocessor,
|
|
||||||
phaseReducer: PhaseReducer = defaultPhaseReducer,
|
|
||||||
normReducer: NormReducer = defaultNormReducer,
|
|
||||||
goalReducer: GoalReducer = defaultGoalReducer
|
|
||||||
) : BehaviorProgram {
|
|
||||||
const nodes: AppNode[] = useFlowStore.getState().nodes;
|
|
||||||
const edges: Edge[] = useFlowStore.getState().edges;
|
|
||||||
const preparedGraph: PreparedGraph = graphPreprocessor(nodes, edges);
|
|
||||||
|
|
||||||
return preparedGraph.map((preparedPhase: PreparedPhase) : Phase =>
|
|
||||||
phaseReducer(
|
|
||||||
preparedPhase,
|
|
||||||
normReducer,
|
|
||||||
goalReducer
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* reduces a single preparedPhase to a Phase object
|
|
||||||
* the Phase object describes a single phase in a BehaviorProgram
|
|
||||||
*
|
|
||||||
* @param {PreparedPhase} phase
|
|
||||||
* @param {NormReducer} normReducer
|
|
||||||
* @param {GoalReducer} goalReducer
|
|
||||||
* @returns {Phase}
|
|
||||||
*/
|
|
||||||
export function defaultPhaseReducer(
|
|
||||||
phase: PreparedPhase,
|
|
||||||
normReducer: NormReducer = defaultNormReducer,
|
|
||||||
goalReducer: GoalReducer = defaultGoalReducer
|
|
||||||
) : Phase {
|
|
||||||
return {
|
|
||||||
id: phase.phaseNode.id,
|
|
||||||
name: phase.phaseNode.data.label,
|
|
||||||
nextPhaseId: phase.nextPhaseId,
|
|
||||||
phaseData: {
|
|
||||||
norms: phase.connectedNorms.map(normReducer),
|
|
||||||
goals: phase.connectedGoals.map(goalReducer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* the default implementation of the goalNode reducer function
|
|
||||||
*
|
|
||||||
* @param {GoalNode} node
|
|
||||||
* @returns {GoalData}
|
|
||||||
*/
|
|
||||||
function defaultGoalReducer(node: GoalNode) : GoalData {
|
|
||||||
return {
|
|
||||||
id: node.id,
|
|
||||||
name: node.data.label,
|
|
||||||
value: node.data.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* the default implementation of the normNode reducer function
|
|
||||||
*
|
|
||||||
* @param {NormNode} node
|
|
||||||
* @returns {NormData}
|
|
||||||
*/
|
|
||||||
function defaultNormReducer(node: NormNode) :NormData {
|
|
||||||
return {
|
|
||||||
id: node.id,
|
|
||||||
name: node.data.label,
|
|
||||||
value: node.data.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graph preprocessing functions:
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preprocesses the provide state of the behavior editor graph, preparing it for further processing in
|
|
||||||
* the graphReducer function
|
|
||||||
*
|
|
||||||
* @param {AppNode[]} nodes
|
|
||||||
* @param {Edge[]} edges
|
|
||||||
* @returns {PreparedGraph}
|
|
||||||
*/
|
|
||||||
export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : PreparedGraph {
|
|
||||||
const norms : NormNode[] = nodes.filter((node) => node.type === 'norm') as NormNode[];
|
|
||||||
const goals : GoalNode[] = nodes.filter((node) => node.type === 'goal') as GoalNode[];
|
|
||||||
const orderedPhases : OrderedPhases = orderPhases(nodes, edges);
|
|
||||||
|
|
||||||
return orderedPhases.phaseNodes.map((phase: PhaseNode) : PreparedPhase => {
|
|
||||||
const nextPhase = orderedPhases.connections.get(phase.id);
|
|
||||||
return {
|
|
||||||
phaseNode: phase,
|
|
||||||
nextPhaseId: nextPhase as string,
|
|
||||||
connectedNorms: getIncomers({id: phase.id}, norms,edges),
|
|
||||||
connectedGoals: getIncomers({id: phase.id}, goals,edges)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* orderPhases takes the state of the graph created by the editor and turns it into an OrderedPhases object.
|
|
||||||
*
|
|
||||||
* @param {AppNode[]} nodes
|
|
||||||
* @param {Edge[]} edges
|
|
||||||
* @returns {OrderedPhases}
|
|
||||||
*/
|
|
||||||
export function orderPhases(nodes: AppNode[],edges: Edge[]) : OrderedPhases {
|
|
||||||
// find the first Phase node
|
|
||||||
const phaseNodes : PhaseNode[] = nodes.filter((node) => node.type === 'phase') as PhaseNode[];
|
|
||||||
const startNodeIndex = nodes.findIndex((node : AppNode):boolean => {return (node.type === 'start');});
|
|
||||||
const firstPhaseNode = getOutgoers({ id: nodes[startNodeIndex].id },phaseNodes,edges);
|
|
||||||
|
|
||||||
// recursively adds the phase nodes to a list in the order they are connected in the graph
|
|
||||||
const nextPhase = (
|
|
||||||
currentIndex: number,
|
|
||||||
{ phaseNodes: phases, connections: connections} : OrderedPhases
|
|
||||||
) : OrderedPhases => {
|
|
||||||
// get the current phase and the next phases;
|
|
||||||
const currentPhase = phases[currentIndex];
|
|
||||||
const nextPhaseNodes = getOutgoers(currentPhase,phaseNodes,edges);
|
|
||||||
const nextNodes = getOutgoers(currentPhase,nodes, edges);
|
|
||||||
|
|
||||||
// handles adding of the next phase to the chain, and error handle if an invalid state is received
|
|
||||||
if (nextPhaseNodes.length === 1 && nextNodes.length === 1) {
|
|
||||||
connections.set(currentPhase.id, nextPhaseNodes[0].id);
|
|
||||||
return nextPhase(phases.push(nextPhaseNodes[0] as PhaseNode) - 1, {phaseNodes: phases, connections: connections});
|
|
||||||
} else {
|
|
||||||
// handle erroneous states
|
|
||||||
if (nextNodes.length === 0){
|
|
||||||
throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" doesn't have any outgoing connections`);
|
|
||||||
} else {
|
|
||||||
if (nextNodes.length > 1) {
|
|
||||||
throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" connects to too many targets`);
|
|
||||||
} else {
|
|
||||||
if (nextNodes[0].type === "end"){
|
|
||||||
connections.set(currentPhase.id, "end");
|
|
||||||
// returns the final output of the function
|
|
||||||
return { phaseNodes: phases, connections: connections};
|
|
||||||
} else {
|
|
||||||
throw new Error(`| INVALID PROGRAM | the node "${nextNodes[0].id}" that "${currentPhase.id}" connects to is not a phase or end node`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// initializes the Map describing the connections between phase nodes
|
|
||||||
// we need this Map to make sure we preserve this information,
|
|
||||||
// so we don't need to do checks on the entire set of edges in further stages of the reduction algorithm
|
|
||||||
const connections : Map<string, string> = new Map();
|
|
||||||
|
|
||||||
// returns an empty list if no phase nodes are present, otherwise returns an ordered list of phaseNodes
|
|
||||||
if (firstPhaseNode.length > 0) {
|
|
||||||
return nextPhase(0, {phaseNodes: [firstPhaseNode[0] as PhaseNode], connections: connections})
|
|
||||||
} else { return {phaseNodes: [], connections: connections} }
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import type {Edge} from "@xyflow/react";
|
|
||||||
import type {AppNode, GoalNode, NormNode, PhaseNode} from "./VisProgTypes.tsx";
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* defines how a norm is represented in the simplified behavior program
|
|
||||||
*/
|
|
||||||
export type NormData = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* defines how a goal is represented in the simplified behavior program
|
|
||||||
*/
|
|
||||||
export type GoalData = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* definition of a PhaseData object, it contains all phaseData that is relevant
|
|
||||||
* for further processing and execution of a phase.
|
|
||||||
*/
|
|
||||||
export type PhaseData = {
|
|
||||||
norms: NormData[];
|
|
||||||
goals: GoalData[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Describes a single phase within the simplified representation of a behavior program,
|
|
||||||
*
|
|
||||||
* Contains:
|
|
||||||
* - the id of the described phase,
|
|
||||||
* - the name of the described phase,
|
|
||||||
* - the id of the next phase in the user defined behavior program
|
|
||||||
* - the data property of the described phase node
|
|
||||||
*
|
|
||||||
* @NOTE at the moment the type definitions do not support branching programs,
|
|
||||||
* if branching of phases is to be supported in the future, the type definition for Phase has to be updated
|
|
||||||
*/
|
|
||||||
export type Phase = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
nextPhaseId: string;
|
|
||||||
phaseData: PhaseData;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Describes a simplified behavior program as a list of Phase objects
|
|
||||||
*/
|
|
||||||
export type BehaviorProgram = Phase[];
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type NormReducer = (node: NormNode) => NormData;
|
|
||||||
export type GoalReducer = (node: GoalNode) => GoalData;
|
|
||||||
export type PhaseReducer = (
|
|
||||||
preparedPhase: PreparedPhase,
|
|
||||||
normReducer: NormReducer,
|
|
||||||
goalReducer: GoalReducer
|
|
||||||
) => Phase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* contains:
|
|
||||||
*
|
|
||||||
* - list of phases, sorted based on position in chain between the start and end node
|
|
||||||
* - a dictionary containing all outgoing connections,
|
|
||||||
* to other phase or end nodes, for each phase node uses the id of the source node as key
|
|
||||||
* and the id of the target node as value
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export type OrderedPhases = {
|
|
||||||
phaseNodes: PhaseNode[];
|
|
||||||
connections: Map<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single prepared phase,
|
|
||||||
* contains:
|
|
||||||
* - the described phaseNode,
|
|
||||||
* - the id of the next phaseNode or "end" for the end node
|
|
||||||
* - a list of the normNodes that are connected to the described phase
|
|
||||||
* - a list of the goalNodes that are connected to the described phase
|
|
||||||
*/
|
|
||||||
export type PreparedPhase = {
|
|
||||||
phaseNode: PhaseNode;
|
|
||||||
nextPhaseId: string;
|
|
||||||
connectedNorms: NormNode[];
|
|
||||||
connectedGoals: GoalNode[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* a list of PreparedPhase objects,
|
|
||||||
* describes the preprocessed state of a program,
|
|
||||||
* before the contents of the node
|
|
||||||
*/
|
|
||||||
export type PreparedGraph = PreparedPhase[];
|
|
||||||
|
|
||||||
export type GraphPreprocessor = (nodes: AppNode[], edges: Edge[]) => PreparedGraph;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
82
src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts
Normal file
82
src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import StartNode, { StartConnects, StartReduce } from "./nodes/StartNode";
|
||||||
|
import EndNode, { EndConnects, EndReduce } from "./nodes/EndNode";
|
||||||
|
import PhaseNode, { PhaseConnects, PhaseReduce } from "./nodes/PhaseNode";
|
||||||
|
import NormNode, { NormConnects, NormReduce } from "./nodes/NormNode";
|
||||||
|
import { EndNodeDefaults } from "./nodes/EndNode.default";
|
||||||
|
import { StartNodeDefaults } from "./nodes/StartNode.default";
|
||||||
|
import { PhaseNodeDefaults } from "./nodes/PhaseNode.default";
|
||||||
|
import { NormNodeDefaults } from "./nodes/NormNode.default";
|
||||||
|
import GoalNode, { GoalConnects, GoalReduce } from "./nodes/GoalNode";
|
||||||
|
import { GoalNodeDefaults } from "./nodes/GoalNode.default";
|
||||||
|
import TriggerNode, { TriggerConnects, TriggerReduce } from "./nodes/TriggerNode";
|
||||||
|
import { TriggerNodeDefaults } from "./nodes/TriggerNode.default";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The types of the nodes we have registered.
|
||||||
|
*/
|
||||||
|
export const NodeTypes = {
|
||||||
|
start: StartNode,
|
||||||
|
end: EndNode,
|
||||||
|
phase: PhaseNode,
|
||||||
|
norm: NormNode,
|
||||||
|
goal: GoalNode,
|
||||||
|
trigger: TriggerNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default functions of the nodes we have registered.
|
||||||
|
* These are defined in the <node>.default.ts files.
|
||||||
|
*/
|
||||||
|
export const NodeDefaults = {
|
||||||
|
start: StartNodeDefaults,
|
||||||
|
end: EndNodeDefaults,
|
||||||
|
phase: PhaseNodeDefaults,
|
||||||
|
norm: NormNodeDefaults,
|
||||||
|
goal: GoalNodeDefaults,
|
||||||
|
trigger: TriggerNodeDefaults,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reduce functions of the nodes we have registered.
|
||||||
|
*/
|
||||||
|
export const NodeReduces = {
|
||||||
|
start: StartReduce,
|
||||||
|
end: EndReduce,
|
||||||
|
phase: PhaseReduce,
|
||||||
|
norm: NormReduce,
|
||||||
|
goal: GoalReduce,
|
||||||
|
trigger: TriggerReduce,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The connection functionality of the nodes we have registered.
|
||||||
|
*/
|
||||||
|
export const NodeConnects = {
|
||||||
|
start: StartConnects,
|
||||||
|
end: EndConnects,
|
||||||
|
phase: PhaseConnects,
|
||||||
|
norm: NormConnects,
|
||||||
|
goal: GoalConnects,
|
||||||
|
trigger: TriggerConnects,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Functions that define whether a node should be deleted, currently constant only for start and end.
|
||||||
|
* Any node types that aren't mentioned are 'true', and can be deleted by default.
|
||||||
|
*/
|
||||||
|
export const NodeDeletes = {
|
||||||
|
start: () => false,
|
||||||
|
end: () => false,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines which types are variables in the phase node-
|
||||||
|
* any node that is NOT mentioned here, is automatically seen as a variable of a phase.
|
||||||
|
*/
|
||||||
|
export const NodesInPhase = {
|
||||||
|
start: () => false,
|
||||||
|
end: () => false,
|
||||||
|
phase: () => false,
|
||||||
|
}
|
||||||
@@ -1,142 +1,135 @@
|
|||||||
import {create} from 'zustand';
|
import { create } from 'zustand';
|
||||||
import {
|
import {
|
||||||
applyNodeChanges,
|
applyNodeChanges,
|
||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
addEdge,
|
addEdge,
|
||||||
reconnectEdge, type Edge, type Connection
|
reconnectEdge,
|
||||||
|
type Node,
|
||||||
|
type Edge,
|
||||||
|
type XYPosition,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
|
import type { FlowState } from './VisProgTypes';
|
||||||
|
import { NodeDefaults, NodeConnects, NodeDeletes } from './NodeRegistry';
|
||||||
|
|
||||||
import {type AppNode, type FlowState} from './VisProgTypes.tsx';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* contains the nodes that are created when the editor is loaded,
|
* Create a node given the correct data
|
||||||
* should contain at least a start and an end node
|
* @param type the type of the node to create
|
||||||
|
* @param id the id of the node to create
|
||||||
|
* @param position the position of the node to create
|
||||||
|
* @param data the data in the node to create
|
||||||
|
* @param deletable if this node should be able to be deleted IN ANY WAY POSSIBLE
|
||||||
|
* @constructor
|
||||||
*/
|
*/
|
||||||
const initialNodes = [
|
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable? : boolean) {
|
||||||
{
|
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
|
||||||
id: 'start',
|
const newData = {
|
||||||
type: 'start',
|
id: id,
|
||||||
position: {x: 0, y: 0},
|
type: type,
|
||||||
data: {label: 'start'}
|
position: position,
|
||||||
},
|
data: data,
|
||||||
{
|
deletable: deletable,
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
}
|
||||||
|
return {...defaultData, ...newData}
|
||||||
|
}
|
||||||
|
|
||||||
|
//* Initial nodes, created by using createNode. */
|
||||||
|
const initialNodes : Node[] = [
|
||||||
|
createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false),
|
||||||
|
createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false),
|
||||||
|
createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children : []}),
|
||||||
|
createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}),
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
// * Initial edges * /
|
||||||
* contains the initial edges that are created when the editor is loaded
|
const initialEdges: Edge[] = [
|
||||||
*/
|
{ id: 'start-phase-1', source: 'start', target: 'phase-1' },
|
||||||
const initialEdges = [
|
{ id: 'phase-1-end', source: 'phase-1', target: 'end' },
|
||||||
{
|
|
||||||
id: 'start-phase-1',
|
|
||||||
source: 'start',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1-end',
|
|
||||||
source: 'phase-1',
|
|
||||||
target: 'end',
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The useFlowStore hook contains the implementation for editor functionality and state
|
* How we have defined the functions for our FlowState.
|
||||||
* we can use this inside our editor component to access the current state
|
* We have the normal functionality of a default FlowState with some exceptions to account for extra functionality.
|
||||||
* and use any implemented functionality
|
* The biggest changes are in onConnect and onDelete, which we have given extra functionality based on the nodes defined functions.
|
||||||
*/
|
*/
|
||||||
const useFlowStore = create<FlowState>((set, get) => ({
|
const useFlowStore = create<FlowState>((set, get) => ({
|
||||||
nodes: initialNodes,
|
nodes: initialNodes,
|
||||||
edges: initialEdges,
|
edges: initialEdges,
|
||||||
edgeReconnectSuccessful: true,
|
edgeReconnectSuccessful: true,
|
||||||
onNodesChange: (changes) => {
|
|
||||||
set({
|
|
||||||
nodes: applyNodeChanges(changes, get().nodes)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onEdgesChange: (changes) => {
|
|
||||||
set({
|
|
||||||
edges: applyEdgeChanges(changes, get().edges)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// handles connection of newly created edges
|
|
||||||
onConnect: (connection) => {
|
|
||||||
set({
|
|
||||||
edges: addEdge(connection, get().edges)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// handles attempted reconnections of a previously disconnected edge
|
|
||||||
onReconnect: (oldEdge: Edge, newConnection: Connection) => {
|
|
||||||
get().edgeReconnectSuccessful = true;
|
|
||||||
set({
|
|
||||||
edges: reconnectEdge(oldEdge, newConnection, get().edges)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// Handles initiation of reconnection of edges that are manually disconnected from a node
|
|
||||||
onReconnectStart: () => {
|
|
||||||
set({
|
|
||||||
edgeReconnectSuccessful: false
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// Drops the edge from the set of edges, removing it from the flow, if no successful reconnection occurred
|
|
||||||
onReconnectEnd: (_: unknown, edge: { id: string; }) => {
|
|
||||||
if (!get().edgeReconnectSuccessful) {
|
|
||||||
set({
|
|
||||||
edges: get().edges.filter((e) => e.id !== edge.id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
set({
|
|
||||||
edgeReconnectSuccessful: true
|
|
||||||
});
|
|
||||||
},
|
|
||||||
deleteNode: (nodeId: string) => {
|
|
||||||
set({
|
|
||||||
nodes: get().nodes.filter((n) => n.id !== nodeId),
|
|
||||||
edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
setNodes: (nodes) => {
|
|
||||||
set({nodes});
|
|
||||||
},
|
|
||||||
setEdges: (edges) => {
|
|
||||||
set({edges});
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* handles updating the data component of a node,
|
|
||||||
* if the provided data object contains entries that aren't present in the updated node's data component
|
|
||||||
* those entries are added to the data component,
|
|
||||||
* entries that do exist within the node's data component,
|
|
||||||
* are simply updated to contain the new value
|
|
||||||
*
|
|
||||||
* the data object
|
|
||||||
* @param {string} nodeId
|
|
||||||
* @param {object} data
|
|
||||||
*/
|
|
||||||
updateNodeData: (nodeId: string, data) => {
|
|
||||||
set({
|
|
||||||
nodes: get().nodes.map((node) : AppNode => {
|
|
||||||
if (node.id === nodeId) {
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
data: {
|
|
||||||
...node.data,
|
|
||||||
...data
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else { return node; }
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default useFlowStore;
|
onNodesChange: (changes) =>
|
||||||
|
set({nodes: applyNodeChanges(changes, get().nodes)}),
|
||||||
|
onEdgesChange: (changes) => set({ edges: applyEdgeChanges(changes, get().edges) }),
|
||||||
|
|
||||||
|
onConnect: (connection) => {
|
||||||
|
const edges = addEdge(connection, get().edges);
|
||||||
|
const nodes = get().nodes;
|
||||||
|
// connection has: { source, sourceHandle, target, targetHandle }
|
||||||
|
// Let's find the source and target ID's.
|
||||||
|
const sourceNode = nodes.find((n) => n.id == connection.source);
|
||||||
|
const targetNode = nodes.find((n) => n.id == connection.target);
|
||||||
|
|
||||||
|
// In case the nodes weren't found, return basic functionality.
|
||||||
|
if (sourceNode == undefined || targetNode == undefined || sourceNode.type == undefined || targetNode.type == undefined) {
|
||||||
|
set({ nodes, edges });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should find out how their data changes by calling their respective functions.
|
||||||
|
const sourceConnectFunction = NodeConnects[sourceNode.type as keyof typeof NodeConnects]
|
||||||
|
const targetConnectFunction = NodeConnects[targetNode.type as keyof typeof NodeConnects]
|
||||||
|
|
||||||
|
// We're going to have to update their data based on how they want to update it.
|
||||||
|
sourceConnectFunction(sourceNode, targetNode, true)
|
||||||
|
targetConnectFunction(targetNode, sourceNode, false)
|
||||||
|
set({ nodes, edges });
|
||||||
|
},
|
||||||
|
|
||||||
|
onReconnect: (oldEdge, newConnection) => {
|
||||||
|
get().edgeReconnectSuccessful = true;
|
||||||
|
set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) });
|
||||||
|
},
|
||||||
|
|
||||||
|
onReconnectStart: () => set({ edgeReconnectSuccessful: false }),
|
||||||
|
onReconnectEnd: (_evt, edge) => {
|
||||||
|
if (!get().edgeReconnectSuccessful) {
|
||||||
|
set({ edges: get().edges.filter((e) => e.id !== edge.id) });
|
||||||
|
}
|
||||||
|
set({ edgeReconnectSuccessful: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteNode: (nodeId) => {
|
||||||
|
// Let's find our node to check if they have a special deletion function
|
||||||
|
const ourNode = get().nodes.find((n)=>n.id==nodeId);
|
||||||
|
const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1]
|
||||||
|
|
||||||
|
// If there's no function, OR, our function tells us we can delete it, let's do so...
|
||||||
|
if (ourFunction == undefined || ourFunction()) {
|
||||||
|
set({
|
||||||
|
nodes: get().nodes.filter((n) => n.id !== nodeId),
|
||||||
|
edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
|
||||||
|
})}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
setNodes: (nodes) => set({ nodes }),
|
||||||
|
setEdges: (edges) => set({ edges }),
|
||||||
|
|
||||||
|
updateNodeData: (nodeId, data) => {
|
||||||
|
set({
|
||||||
|
nodes: get().nodes.map((node) => {
|
||||||
|
if (node.id === nodeId) {
|
||||||
|
node = { ...node, data: { ...node.data, ...data }};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
addNode: (node: Node) => {
|
||||||
|
set({ nodes: [...get().nodes, node] });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useFlowStore;
|
||||||
|
|||||||
@@ -1,47 +1,24 @@
|
|||||||
import {
|
// VisProgTypes.ts
|
||||||
type Edge,
|
import type { Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node } from '@xyflow/react';
|
||||||
type Node,
|
import type { NodeTypes } from './NodeRegistry';
|
||||||
type OnNodesChange,
|
|
||||||
type OnEdgesChange,
|
|
||||||
type OnConnect,
|
|
||||||
type OnReconnect,
|
|
||||||
} from '@xyflow/react';
|
|
||||||
|
|
||||||
|
export type AppNode = typeof NodeTypes
|
||||||
|
|
||||||
type defaultNodeData = {
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StartNode = Node<defaultNodeData, 'start'>;
|
|
||||||
export type EndNode = Node<defaultNodeData, 'end'>;
|
|
||||||
export type GoalNode = Node<defaultNodeData & { value: string; }, 'goal'>;
|
|
||||||
export type NormNode = Node<defaultNodeData & { value: string; }, 'norm'>;
|
|
||||||
export type PhaseNode = Node<defaultNodeData & { number: number; }, 'phase'>;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* a type meant to house different node types, currently not used
|
|
||||||
* but will allow us to more clearly define nodeTypes when we implement
|
|
||||||
* computation of the Graph inside the ReactFlow editor
|
|
||||||
*/
|
|
||||||
export type AppNode = Node | StartNode | EndNode | NormNode | GoalNode | PhaseNode;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The type for the Zustand store object used to manage the state of the ReactFlow editor
|
|
||||||
*/
|
|
||||||
export type FlowState = {
|
export type FlowState = {
|
||||||
nodes: AppNode[];
|
nodes: Node[];
|
||||||
edges: Edge[];
|
edges: Edge[];
|
||||||
edgeReconnectSuccessful: boolean;
|
edgeReconnectSuccessful: boolean;
|
||||||
|
|
||||||
onNodesChange: OnNodesChange;
|
onNodesChange: OnNodesChange;
|
||||||
onEdgesChange: OnEdgesChange;
|
onEdgesChange: OnEdgesChange;
|
||||||
onConnect: OnConnect;
|
onConnect: OnConnect;
|
||||||
onReconnect: OnReconnect;
|
onReconnect: OnReconnect;
|
||||||
onReconnectStart: () => void;
|
onReconnectStart: () => void;
|
||||||
onReconnectEnd: (_: unknown, edge: { id: string }) => void;
|
onReconnectEnd: (_: unknown, edge: { id: string }) => void;
|
||||||
|
|
||||||
deleteNode: (nodeId: string) => void;
|
deleteNode: (nodeId: string) => void;
|
||||||
setNodes: (nodes: AppNode[]) => void;
|
setNodes: (nodes: Node[]) => void;
|
||||||
setEdges: (edges: Edge[]) => void;
|
setEdges: (edges: Edge[]) => void;
|
||||||
updateNodeData: (nodeId: string, data: object) => void;
|
updateNodeData: (nodeId: string, data: object) => void;
|
||||||
|
addNode: (node: Node) => void;
|
||||||
};
|
};
|
||||||
@@ -1,19 +1,9 @@
|
|||||||
import {useDraggable} from '@neodrag/react';
|
import { useDraggable } from '@neodrag/react';
|
||||||
import {
|
import { useReactFlow, type XYPosition } from '@xyflow/react';
|
||||||
useReactFlow,
|
import { type ReactNode, useCallback, useRef, useState } from 'react';
|
||||||
type XYPosition
|
import useFlowStore from '../VisProgStores';
|
||||||
} from '@xyflow/react';
|
import styles from '../../VisProg.module.css';
|
||||||
import {
|
import { NodeDefaults, type NodeTypes } from '../NodeRegistry'
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useRef,
|
|
||||||
useState
|
|
||||||
} from 'react';
|
|
||||||
import useFlowStore from "../VisProgStores.tsx";
|
|
||||||
import styles from "../../VisProg.module.css"
|
|
||||||
import type {AppNode, PhaseNode, NormNode} from "../VisProgTypes.tsx";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DraggableNodeProps dictates the type properties of a DraggableNode
|
* DraggableNodeProps dictates the type properties of a DraggableNode
|
||||||
@@ -21,41 +11,28 @@ import type {AppNode, PhaseNode, NormNode} from "../VisProgTypes.tsx";
|
|||||||
interface DraggableNodeProps {
|
interface DraggableNodeProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
nodeType: string;
|
nodeType: keyof typeof NodeTypes;
|
||||||
onDrop: (nodeType: string, position: XYPosition) => void;
|
onDrop: (nodeType: keyof typeof NodeTypes, position: XYPosition) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Definition of a node inside the drag and drop toolbar,
|
* Definition of a node inside the drag and drop toolbar.
|
||||||
* these nodes require an onDrop function to be supplied
|
* These nodes require an onDrop function that dictates
|
||||||
* that dictates how the node is created in the graph.
|
* how the node is created in the graph.
|
||||||
*
|
|
||||||
* @param className
|
|
||||||
* @param children
|
|
||||||
* @param nodeType
|
|
||||||
* @param onDrop
|
|
||||||
* @constructor
|
|
||||||
*/
|
*/
|
||||||
function DraggableNode({className, children, nodeType, onDrop}: DraggableNodeProps) {
|
function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeProps) {
|
||||||
const draggableRef = useRef<HTMLDivElement>(null);
|
const draggableRef = useRef<HTMLDivElement>(null);
|
||||||
const [position, setPosition] = useState<XYPosition>({x: 0, y: 0});
|
const [position, setPosition] = useState<XYPosition>({ x: 0, y: 0 });
|
||||||
|
|
||||||
// @ts-expect-error comes from a package and doesn't appear to play nicely with strict typescript typing
|
// @ts-expect-error from the neodrag package — safe to ignore
|
||||||
useDraggable(draggableRef, {
|
useDraggable(draggableRef, {
|
||||||
position: position,
|
position,
|
||||||
onDrag: ({offsetX, offsetY}) => {
|
onDrag: ({ offsetX, offsetY }) => {
|
||||||
// Calculate position relative to the viewport
|
setPosition({ x: offsetX, y: offsetY });
|
||||||
setPosition({
|
|
||||||
x: offsetX,
|
|
||||||
y: offsetY,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onDragEnd: ({event}) => {
|
onDragEnd: ({ event }) => {
|
||||||
setPosition({x: 0, y: 0});
|
setPosition({ x: 0, y: 0 });
|
||||||
onDrop(nodeType, {
|
onDrop(nodeType, { x: event.clientX, y: event.clientY });
|
||||||
x: event.clientX,
|
|
||||||
y: event.clientY,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,71 +43,48 @@ function DraggableNode({className, children, nodeType, onDrop}: DraggableNodePro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* addNode — adds a new node to the flow using the unified class-based system.
|
||||||
|
* Keeps numbering logic for phase/norm nodes.
|
||||||
|
*/
|
||||||
|
function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) {
|
||||||
|
const { nodes, setNodes } = useFlowStore.getState();
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// Find out if there's any default data about our ndoe
|
||||||
export function addNode(nodeType: string, position: XYPosition) {
|
const defaultData = NodeDefaults[nodeType] ?? {}
|
||||||
const {setNodes} = useFlowStore.getState();
|
|
||||||
const nds : AppNode[] = useFlowStore.getState().nodes;
|
// Currently, we find out what the Id is by checking the last node and adding one
|
||||||
const newNode = () => {
|
const sameTypeNodes = nodes.filter((node) => node.type === nodeType);
|
||||||
switch (nodeType) {
|
const nextNumber =
|
||||||
case "phase":
|
sameTypeNodes.length > 0
|
||||||
{
|
? (() => {
|
||||||
const phaseNodes= nds.filter((node) => node.type === 'phase');
|
const lastNode = sameTypeNodes[sameTypeNodes.length - 1];
|
||||||
let phaseNumber;
|
const parts = lastNode.id.split('-');
|
||||||
if (phaseNodes.length > 0) {
|
const lastNum = Number(parts[1]);
|
||||||
const finalPhaseId : number = +(phaseNodes[phaseNodes.length - 1].id.split('-')[1]);
|
return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1;
|
||||||
phaseNumber = finalPhaseId + 1;
|
})()
|
||||||
} else {
|
: 1;
|
||||||
phaseNumber = 1;
|
const id = `${nodeType}-${nextNumber}`;
|
||||||
}
|
|
||||||
const phaseNode : PhaseNode = {
|
// Create new node
|
||||||
id: `phase-${phaseNumber}`,
|
const newNode = {
|
||||||
type: nodeType,
|
id: id,
|
||||||
position,
|
type: nodeType,
|
||||||
data: {label: 'new', number: phaseNumber},
|
position,
|
||||||
}
|
data: {...defaultData}
|
||||||
return phaseNode;
|
|
||||||
}
|
|
||||||
case "norm":
|
|
||||||
{
|
|
||||||
const normNodes= nds.filter((node) => node.type === 'norm');
|
|
||||||
let normNumber
|
|
||||||
if (normNodes.length > 0) {
|
|
||||||
const finalNormId : number = +(normNodes[normNodes.length - 1].id.split('-')[1]);
|
|
||||||
normNumber = finalNormId + 1;
|
|
||||||
} else {
|
|
||||||
normNumber = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normNode : NormNode = {
|
|
||||||
id: `norm-${normNumber}`,
|
|
||||||
type: nodeType,
|
|
||||||
position,
|
|
||||||
data: {label: `new norm node`, value: "Pepper should be formal"},
|
|
||||||
}
|
|
||||||
return normNode;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
throw new Error(`Node ${nodeType} not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
setNodes([...nodes, newNode]);
|
||||||
setNodes(nds.concat(newNode()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* the DndToolbar defines how the drag and drop toolbar component works
|
* DndToolbar defines how the drag and drop toolbar component works
|
||||||
* and includes the default onDrop behavior through handleNodeDrop
|
* and includes the default onDrop behavior.
|
||||||
* @constructor
|
|
||||||
*/
|
*/
|
||||||
export function DndToolbar() {
|
export function DndToolbar() {
|
||||||
const {screenToFlowPosition} = useReactFlow();
|
const { screenToFlowPosition } = useReactFlow();
|
||||||
/**
|
|
||||||
* handleNodeDrop implements the default onDrop behavior
|
|
||||||
*/
|
|
||||||
const handleNodeDrop = useCallback(
|
const handleNodeDrop = useCallback(
|
||||||
(nodeType: string, screenPosition: XYPosition) => {
|
(nodeType: keyof typeof NodeTypes, screenPosition: XYPosition) => {
|
||||||
const flow = document.querySelector('.react-flow');
|
const flow = document.querySelector('.react-flow');
|
||||||
const flowRect = flow?.getBoundingClientRect();
|
const flowRect = flow?.getBoundingClientRect();
|
||||||
const isInFlow =
|
const isInFlow =
|
||||||
@@ -140,7 +94,6 @@ export function DndToolbar() {
|
|||||||
screenPosition.y >= flowRect.top &&
|
screenPosition.y >= flowRect.top &&
|
||||||
screenPosition.y <= flowRect.bottom;
|
screenPosition.y <= flowRect.bottom;
|
||||||
|
|
||||||
// Create a new node and add it to the flow
|
|
||||||
if (isInFlow) {
|
if (isInFlow) {
|
||||||
const position = screenToFlowPosition(screenPosition);
|
const position = screenToFlowPosition(screenPosition);
|
||||||
addNode(nodeType, position);
|
addNode(nodeType, position);
|
||||||
@@ -149,24 +102,32 @@ export function DndToolbar() {
|
|||||||
[screenToFlowPosition],
|
[screenToFlowPosition],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// Map over our default settings to see which of them have their droppable data set to true
|
||||||
|
const droppableNodes = Object.entries(NodeDefaults)
|
||||||
|
.filter(([, data]) => data.droppable)
|
||||||
|
.map(([type, data]) => ({
|
||||||
|
type: type as DraggableNodeProps['nodeType'],
|
||||||
|
data
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={`flex-col gap-lg padding-md ${styles.innerDndPanel}`}>
|
||||||
|
<div className="description">
|
||||||
<div className={`flex-col gap-lg padding-md ${styles.innerDndPanel}`}>
|
You can drag these nodes to the pane to create new nodes.
|
||||||
<div className="description">
|
</div>
|
||||||
You can drag these nodes to the pane to create new nodes.
|
<div className={`flex-row gap-lg ${styles.dndNodeContainer}`}>
|
||||||
</div>
|
{/* Maps over all the nodes that are droppable, and puts them in the panel */}
|
||||||
<div className={`flex-row gap-lg ${styles.dndNodeContainer}`}>
|
{droppableNodes.map(({type, data}) => (
|
||||||
<DraggableNode className={styles.draggableNodePhase} nodeType="phase" onDrop={handleNodeDrop}>
|
<DraggableNode
|
||||||
phase Node
|
className={styles[`draggable-node-${type}`]} // Our current style signature for nodes
|
||||||
</DraggableNode>
|
nodeType={type}
|
||||||
<DraggableNode className={styles.draggableNodeNorm} nodeType="norm" onDrop={handleNodeDrop}>
|
onDrop={handleNodeDrop}
|
||||||
norm Node
|
>
|
||||||
</DraggableNode>
|
{data.label}
|
||||||
</div>
|
</DraggableNode>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NodeToolbar } from '@xyflow/react';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import useFlowStore from "../VisProgStores.tsx";
|
||||||
|
|
||||||
|
//Toolbar definitions
|
||||||
|
type ToolbarProps = {
|
||||||
|
nodeId: string;
|
||||||
|
allowDelete: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node Toolbar definition:
|
||||||
|
* handles: node deleting functionality
|
||||||
|
* can be added to any custom node component as a React component
|
||||||
|
*
|
||||||
|
* @param {string} nodeId
|
||||||
|
* @param {boolean} allowDelete
|
||||||
|
* @returns {React.JSX.Element}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
|
||||||
|
const {deleteNode} = useFlowStore();
|
||||||
|
|
||||||
|
const deleteParentNode = ()=> {
|
||||||
|
deleteNode(nodeId);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<NodeToolbar>
|
||||||
|
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
|
||||||
|
</NodeToolbar>);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
import {
|
|
||||||
Handle,
|
|
||||||
type NodeProps,
|
|
||||||
NodeToolbar,
|
|
||||||
Position
|
|
||||||
} from '@xyflow/react';
|
|
||||||
import '@xyflow/react/dist/style.css';
|
|
||||||
import styles from '../../VisProg.module.css';
|
|
||||||
import useFlowStore from "../VisProgStores.tsx";
|
|
||||||
import type {
|
|
||||||
StartNode,
|
|
||||||
EndNode,
|
|
||||||
PhaseNode,
|
|
||||||
NormNode
|
|
||||||
} from "../VisProgTypes.tsx";
|
|
||||||
|
|
||||||
//Toolbar definitions
|
|
||||||
|
|
||||||
type ToolbarProps = {
|
|
||||||
nodeId: string;
|
|
||||||
allowDelete: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Node Toolbar definition:
|
|
||||||
* handles: node deleting functionality
|
|
||||||
* can be added to any custom node component as a React component
|
|
||||||
*
|
|
||||||
* @param {string} nodeId
|
|
||||||
* @param {boolean} allowDelete
|
|
||||||
* @returns {React.JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
|
|
||||||
const {deleteNode} = useFlowStore();
|
|
||||||
|
|
||||||
const deleteParentNode = ()=> {
|
|
||||||
deleteNode(nodeId);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<NodeToolbar>
|
|
||||||
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
|
|
||||||
</NodeToolbar>);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Renaming component
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a component that can be used to edit a node's label entry inside its Data
|
|
||||||
* can be added to any custom node that has a label inside its Data
|
|
||||||
*
|
|
||||||
* @param {string} nodeLabel
|
|
||||||
* @param {string} nodeId
|
|
||||||
* @returns {React.JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
export function EditableName({nodeLabel = "new node", nodeId} : { nodeLabel : string, nodeId: string}) {
|
|
||||||
const {updateNodeData} = useFlowStore();
|
|
||||||
|
|
||||||
const updateData = (event: React.FocusEvent<HTMLInputElement>) => {
|
|
||||||
const input = event.target.value;
|
|
||||||
updateNodeData(nodeId, {label: input});
|
|
||||||
event.currentTarget.setAttribute("readOnly", "true");
|
|
||||||
window.getSelection()?.empty();
|
|
||||||
event.currentTarget.classList.replace("nodrag", "drag"); // enable dragging of the node with cursor on the input box
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateOnEnter = (event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); };
|
|
||||||
|
|
||||||
const enableEditing = (event: React.MouseEvent<HTMLInputElement>) => {
|
|
||||||
if(event.currentTarget.hasAttribute("readOnly")) {
|
|
||||||
event.currentTarget.removeAttribute("readOnly"); // enable editing
|
|
||||||
event.currentTarget.select(); // select the text input
|
|
||||||
window.getSelection()?.collapseToEnd(); // move the caret to the end of the current value
|
|
||||||
event.currentTarget.classList.replace("drag", "nodrag"); // disable dragging using input box
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.NodeTextBar }>
|
|
||||||
<label>name: </label>
|
|
||||||
<input
|
|
||||||
className={`drag ${styles.nodeTextInput}`} // prevents dragging the component when user has focused the text input
|
|
||||||
type={"text"}
|
|
||||||
defaultValue={nodeLabel}
|
|
||||||
onKeyDown={updateOnEnter}
|
|
||||||
onBlur={updateData}
|
|
||||||
onClick={enableEditing}
|
|
||||||
maxLength={25}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Definitions of Nodes
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start Node definition:
|
|
||||||
*
|
|
||||||
* @param {string} id
|
|
||||||
* @param {defaultNodeData} data
|
|
||||||
* @returns {React.JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
export const StartNodeComponent = ({id, data}: NodeProps<StartNode>) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Toolbar nodeId={id} allowDelete={false}/>
|
|
||||||
<div className={`${styles.defaultNode} ${styles.nodeStart}`}>
|
|
||||||
<div> data test {data.label} </div>
|
|
||||||
<Handle type="source" position={Position.Right} id="start"/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End node definition:
|
|
||||||
*
|
|
||||||
* @param {string} id
|
|
||||||
* @param {defaultNodeData} data
|
|
||||||
* @returns {React.JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
export const EndNodeComponent = ({id, data}: NodeProps<EndNode>) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Toolbar nodeId={id} allowDelete={false}/>
|
|
||||||
<div className={`${styles.defaultNode} ${styles.nodeEnd}`}>
|
|
||||||
<div> {data.label} </div>
|
|
||||||
<Handle type="target" position={Position.Left} id="end"/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Phase node definition:
|
|
||||||
*
|
|
||||||
* @param {string} id
|
|
||||||
* @param {defaultNodeData & {number: number}} data
|
|
||||||
* @returns {React.JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
export const PhaseNodeComponent = ({id, data}: NodeProps<PhaseNode>) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Toolbar nodeId={id} allowDelete={true}/>
|
|
||||||
<div className={`${styles.defaultNode} ${styles.nodePhase}`}>
|
|
||||||
<EditableName nodeLabel={data.label} nodeId={id}/>
|
|
||||||
<Handle type="target" position={Position.Left} id="target"/>
|
|
||||||
<Handle type="target" position={Position.Bottom} id="norms"/>
|
|
||||||
<Handle type="source" position={Position.Right} id="source"/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Norm node definition:
|
|
||||||
*
|
|
||||||
* @param {string} id
|
|
||||||
* @param {defaultNodeData & {value: string}} data
|
|
||||||
* @returns {React.JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
export const NormNodeComponent = ({id, data}: NodeProps<NormNode>) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Toolbar nodeId={id} allowDelete={true}/>
|
|
||||||
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
|
|
||||||
<EditableName nodeLabel={data.label} nodeId={id}/>
|
|
||||||
<Handle type="source" position={Position.Right} id="NormSource"/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { EndNodeData } from "./EndNode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default data for this node.
|
||||||
|
*/
|
||||||
|
export const EndNodeDefaults: EndNodeData = {
|
||||||
|
label: "End Node",
|
||||||
|
droppable: false,
|
||||||
|
hasReduce: true
|
||||||
|
};
|
||||||
67
src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx
Normal file
67
src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
Handle,
|
||||||
|
type NodeProps,
|
||||||
|
Position,
|
||||||
|
type Node,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
|
import styles from '../../VisProg.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The typing of this node's data
|
||||||
|
*/
|
||||||
|
export type EndNodeData = {
|
||||||
|
label: string;
|
||||||
|
droppable: boolean;
|
||||||
|
hasReduce: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EndNode = Node<EndNodeData>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default function to render an end node given its properties
|
||||||
|
* @param props the node's properties
|
||||||
|
* @returns React.JSX.Element
|
||||||
|
*/
|
||||||
|
export default function EndNode(props: NodeProps<EndNode>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toolbar nodeId={props.id} allowDelete={false}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeEnd}`}>
|
||||||
|
<div className={"flex-row gap-sm"}>
|
||||||
|
End
|
||||||
|
</div>
|
||||||
|
<Handle type="target" position={Position.Left} id="target"/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Functionality for reducing this node into its more compact json program
|
||||||
|
* @param node the node to reduce
|
||||||
|
* @param nodes all nodes present
|
||||||
|
* @returns Dictionary, {id: node.id}
|
||||||
|
*/
|
||||||
|
export function EndReduce(node: Node, nodes: Node[]) {
|
||||||
|
// Replace this for nodes functionality
|
||||||
|
if (nodes.length <= -1) {
|
||||||
|
console.warn("Impossible nodes length in EndReduce")
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: node.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Any connection functionality that should get called when a connection is made to this node type (end)
|
||||||
|
* @param thisNode the node of which the functionality gets called
|
||||||
|
* @param otherNode the other node which has connected
|
||||||
|
* @param isThisSource whether this node is the one that is the source of the connection
|
||||||
|
*/
|
||||||
|
export function EndConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
|
||||||
|
// Replace this for connection logic
|
||||||
|
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
|
||||||
|
console.warn("Impossible node connection called in EndConnects")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { GoalNodeData } from "./GoalNode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default data for this node
|
||||||
|
*/
|
||||||
|
export const GoalNodeDefaults: GoalNodeData = {
|
||||||
|
label: "Goal Node",
|
||||||
|
droppable: true,
|
||||||
|
description: "The robot will strive towards this goal",
|
||||||
|
achieved: false,
|
||||||
|
hasReduce: true,
|
||||||
|
};
|
||||||
101
src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx
Normal file
101
src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
Handle,
|
||||||
|
type NodeProps,
|
||||||
|
Position,
|
||||||
|
type Node,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
|
import styles from '../../VisProg.module.css';
|
||||||
|
import { TextField } from '../../../../components/TextField';
|
||||||
|
import useFlowStore from '../VisProgStores';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default data dot a phase node
|
||||||
|
* @param label: the label of this phase
|
||||||
|
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
||||||
|
* @param desciption: description of the goal
|
||||||
|
* @param hasReduce: whether this node has reducing functionality (true by default)
|
||||||
|
*/
|
||||||
|
export type GoalNodeData = {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
droppable: boolean;
|
||||||
|
achieved: boolean;
|
||||||
|
hasReduce: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GoalNode = Node<GoalNodeData>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how a Goal node should be rendered
|
||||||
|
* @param props NodeProps, like id, label, children
|
||||||
|
* @returns React.JSX.Element
|
||||||
|
*/
|
||||||
|
export default function GoalNode(props: NodeProps<GoalNode>) {
|
||||||
|
const data = props.data
|
||||||
|
const {updateNodeData} = useFlowStore();
|
||||||
|
|
||||||
|
const text_input_id = `goal_${props.id}_text_input`;
|
||||||
|
const checkbox_id = `goal_${props.id}_checkbox`;
|
||||||
|
|
||||||
|
const setDescription = (value: string) => {
|
||||||
|
updateNodeData(props.id, {...data, description: value});
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAchieved = (value: boolean) => {
|
||||||
|
updateNodeData(props.id, {...data, achieved: value});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeGoal} flex-col gap-sm`}>
|
||||||
|
<div className={"flex-row gap-md"}>
|
||||||
|
<label htmlFor={text_input_id}>Goal:</label>
|
||||||
|
<TextField
|
||||||
|
id={text_input_id}
|
||||||
|
value={data.description}
|
||||||
|
setValue={(val) => setDescription(val)}
|
||||||
|
placeholder={"To ..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={"flex-row gap-md align-center"}>
|
||||||
|
<label htmlFor={checkbox_id}>Achieved:</label>
|
||||||
|
<input
|
||||||
|
id={checkbox_id}
|
||||||
|
type={"checkbox"}
|
||||||
|
value={data.achieved ? "checked" : ""}
|
||||||
|
onChange={(e) => setAchieved(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Right} id="GoalSource"/>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces each Goal, including its children down into its relevant data.
|
||||||
|
* @param node: The Node Properties of this node.
|
||||||
|
* @param nodes: all the nodes in the graph
|
||||||
|
*/
|
||||||
|
export function GoalReduce(node: Node, nodes: Node[]) {
|
||||||
|
// Replace this for nodes functionality
|
||||||
|
if (nodes.length <= -1) {
|
||||||
|
console.warn("Impossible nodes length in GoalReduce")
|
||||||
|
}
|
||||||
|
const data = node.data as GoalNodeData;
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
label: data.label,
|
||||||
|
description: data.description,
|
||||||
|
achieved: data.achieved,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GoalConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
|
||||||
|
// Replace this for connection logic
|
||||||
|
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
|
||||||
|
console.warn("Impossible node connection called in EndConnects")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { NormNodeData } from "./NormNode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default data for this node
|
||||||
|
*/
|
||||||
|
export const NormNodeDefaults: NormNodeData = {
|
||||||
|
label: "Norm Node",
|
||||||
|
droppable: true,
|
||||||
|
norm: "",
|
||||||
|
hasReduce: true,
|
||||||
|
};
|
||||||
84
src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx
Normal file
84
src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
Handle,
|
||||||
|
type NodeProps,
|
||||||
|
Position,
|
||||||
|
type Node,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
|
import styles from '../../VisProg.module.css';
|
||||||
|
import { TextField } from '../../../../components/TextField';
|
||||||
|
import useFlowStore from '../VisProgStores';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default data dot a phase node
|
||||||
|
* @param label: the label of this phase
|
||||||
|
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
||||||
|
* @param norm: list of strings of norms for this node
|
||||||
|
* @param hasReduce: whether this node has reducing functionality (true by default)
|
||||||
|
*/
|
||||||
|
export type NormNodeData = {
|
||||||
|
label: string;
|
||||||
|
droppable: boolean;
|
||||||
|
norm: string;
|
||||||
|
hasReduce: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NormNode = Node<NormNodeData>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how a Norm node should be rendered
|
||||||
|
* @param props NodeProps, like id, label, children
|
||||||
|
* @returns React.JSX.Element
|
||||||
|
*/
|
||||||
|
export default function NormNode(props: NodeProps<NormNode>) {
|
||||||
|
const data = props.data;
|
||||||
|
const {updateNodeData} = useFlowStore();
|
||||||
|
|
||||||
|
const text_input_id = `norm_${props.id}_text_input`;
|
||||||
|
|
||||||
|
const setValue = (value: string) => {
|
||||||
|
updateNodeData(props.id, {norm: value});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
|
||||||
|
<div className={"flex-row gap-sm"}>
|
||||||
|
<label htmlFor={text_input_id}>Norm :</label>
|
||||||
|
<TextField
|
||||||
|
id={text_input_id}
|
||||||
|
value={data.norm}
|
||||||
|
setValue={(val) => setValue(val)}
|
||||||
|
placeholder={"Pepper should ..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Right} id="norms"/>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces each Norm, including its children down into its relevant data.
|
||||||
|
* @param node: The Node Properties of this node.
|
||||||
|
* @param nodes: all the nodes in the graph
|
||||||
|
*/
|
||||||
|
export function NormReduce(node: Node, nodes: Node[]) {
|
||||||
|
// Replace this for nodes functionality
|
||||||
|
if (nodes.length <= -1) {
|
||||||
|
console.warn("Impossible nodes length in NormReduce")
|
||||||
|
}
|
||||||
|
const data = node.data as NormNodeData;
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
label: data.label,
|
||||||
|
norm: data.norm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NormConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
|
||||||
|
// Replace this for connection logic
|
||||||
|
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
|
||||||
|
console.warn("Impossible node connection called in EndConnects")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { PhaseNodeData } from "./PhaseNode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default data for this node
|
||||||
|
*/
|
||||||
|
export const PhaseNodeDefaults: PhaseNodeData = {
|
||||||
|
label: "Phase Node",
|
||||||
|
droppable: true,
|
||||||
|
children: [],
|
||||||
|
hasReduce: true,
|
||||||
|
};
|
||||||
116
src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx
Normal file
116
src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
Handle,
|
||||||
|
type NodeProps,
|
||||||
|
Position,
|
||||||
|
type Node,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
|
import styles from '../../VisProg.module.css';
|
||||||
|
import { NodeReduces, NodesInPhase, NodeTypes } from '../NodeRegistry';
|
||||||
|
import useFlowStore from '../VisProgStores';
|
||||||
|
import { TextField } from '../../../../components/TextField';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default data dot a phase node
|
||||||
|
* @param label: the label of this phase
|
||||||
|
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
||||||
|
* @param children: ID's of children of this node
|
||||||
|
* @param hasReduce: whether this node has reducing functionality (true by default)
|
||||||
|
*/
|
||||||
|
export type PhaseNodeData = {
|
||||||
|
label: string;
|
||||||
|
droppable: boolean;
|
||||||
|
children: string[];
|
||||||
|
hasReduce: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PhaseNode = Node<PhaseNodeData>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how a phase node should be rendered
|
||||||
|
* @param props NodeProps, like id, label, children
|
||||||
|
* @returns React.JSX.Element
|
||||||
|
*/
|
||||||
|
export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
||||||
|
const data = props.data;
|
||||||
|
const {updateNodeData} = useFlowStore();
|
||||||
|
const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value});
|
||||||
|
const label_input_id = `phase_${props.id}_label_input`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodePhase}`}>
|
||||||
|
<div className={"flex-row gap-sm"}>
|
||||||
|
<label htmlFor={label_input_id}>Name:</label>
|
||||||
|
<TextField
|
||||||
|
id={label_input_id}
|
||||||
|
value={data.label}
|
||||||
|
setValue={updateLabel}
|
||||||
|
placeholder={"Phase ..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Handle type="target" position={Position.Left} id="target"/>
|
||||||
|
<Handle type="target" position={Position.Bottom} id="norms"/>
|
||||||
|
<Handle type="source" position={Position.Right} id="source"/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces each phase, including its children down into its relevant data.
|
||||||
|
* @param node the node which is being reduced
|
||||||
|
* @param nodes all the nodes currently in the flow.
|
||||||
|
* @returns A collection of all reduced nodes in this phase, starting with this phases' reduced data.
|
||||||
|
*/
|
||||||
|
export function PhaseReduce(node: Node, nodes: Node[]) {
|
||||||
|
const thisnode = node as PhaseNode;
|
||||||
|
const data = thisnode.data as PhaseNodeData;
|
||||||
|
|
||||||
|
// node typings that are not in phase
|
||||||
|
const nodesNotInPhase: string[] = Object.entries(NodesInPhase)
|
||||||
|
.filter(([, f]) => !f())
|
||||||
|
.map(([t]) => t);
|
||||||
|
|
||||||
|
// node typings that then are in phase
|
||||||
|
const nodesInPhase: string[] = Object.entries(NodeTypes)
|
||||||
|
.filter(([t]) => !nodesNotInPhase.includes(t))
|
||||||
|
.map(([t]) => t);
|
||||||
|
|
||||||
|
// children nodes
|
||||||
|
const childrenNodes = nodes.filter((node) => data.children.includes(node.id));
|
||||||
|
|
||||||
|
// Build the result object
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
id: thisnode.id,
|
||||||
|
label: data.label,
|
||||||
|
};
|
||||||
|
|
||||||
|
nodesInPhase.forEach((type) => {
|
||||||
|
const typedChildren = childrenNodes.filter((child) => child.type == type);
|
||||||
|
const reducer = NodeReduces[type as keyof typeof NodeReduces];
|
||||||
|
if (!reducer) {
|
||||||
|
console.warn(`No reducer found for node type ${type}`);
|
||||||
|
result[type + "s"] = [];
|
||||||
|
} else {
|
||||||
|
result[type + "s"] = typedChildren.map((child) => reducer(child, nodes));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type (phase)
|
||||||
|
* @param thisNode the node of this node type which function is called
|
||||||
|
* @param otherNode the other node which was part of the connection
|
||||||
|
* @param isThisSource whether this instance of the node was the source in the connection, true = yes.
|
||||||
|
*/
|
||||||
|
export function PhaseConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
|
||||||
|
console.log("Connect functionality called.")
|
||||||
|
const node = thisNode as PhaseNode
|
||||||
|
const data = node.data as PhaseNodeData
|
||||||
|
if (!isThisSource)
|
||||||
|
data.children.push(otherNode.id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { StartNodeData } from "./StartNode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default data for this node.
|
||||||
|
*/
|
||||||
|
export const StartNodeDefaults: StartNodeData = {
|
||||||
|
label: "Start Node",
|
||||||
|
droppable: false,
|
||||||
|
hasReduce: true
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
Handle,
|
||||||
|
type NodeProps,
|
||||||
|
Position,
|
||||||
|
type Node,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
|
import styles from '../../VisProg.module.css';
|
||||||
|
|
||||||
|
|
||||||
|
export type StartNodeData = {
|
||||||
|
label: string;
|
||||||
|
droppable: boolean;
|
||||||
|
hasReduce: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type StartNode = Node<StartNodeData>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how a Norm node should be rendered
|
||||||
|
* @param props NodeProps, like id, label, children
|
||||||
|
* @returns React.JSX.Element
|
||||||
|
*/
|
||||||
|
export default function StartNode(props: NodeProps<StartNode>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toolbar nodeId={props.id} allowDelete={false}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeStart}`}>
|
||||||
|
<div className={"flex-row gap-sm"}>
|
||||||
|
Start
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Right} id="source"/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reduce function for this node type.
|
||||||
|
* @param node this node
|
||||||
|
* @param nodes all the nodes in the graph
|
||||||
|
* @returns a reduced structure of this node
|
||||||
|
*/
|
||||||
|
export function StartReduce(node: Node, nodes: Node[]) {
|
||||||
|
// Replace this for nodes functionality
|
||||||
|
if (nodes.length <= -1) {
|
||||||
|
console.warn("Impossible nodes length in StartReduce")
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: node.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type (start)
|
||||||
|
* @param thisNode the node of this node type which function is called
|
||||||
|
* @param otherNode the other node which was part of the connection
|
||||||
|
* @param isThisSource whether this instance of the node was the source in the connection, true = yes.
|
||||||
|
*/
|
||||||
|
export function StartConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
|
||||||
|
// Replace this for connection logic
|
||||||
|
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
|
||||||
|
console.warn("Impossible node connection called in EndConnects")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { TriggerNodeData } from "./TriggerNode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default data for this node
|
||||||
|
*/
|
||||||
|
export const TriggerNodeDefaults: TriggerNodeData = {
|
||||||
|
label: "Trigger Node",
|
||||||
|
droppable: true,
|
||||||
|
triggers: [],
|
||||||
|
triggerType: "keywords",
|
||||||
|
hasReduce: true,
|
||||||
|
};
|
||||||
187
src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx
Normal file
187
src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import {
|
||||||
|
Handle,
|
||||||
|
type NodeProps,
|
||||||
|
Position,
|
||||||
|
type Connection,
|
||||||
|
type Edge,
|
||||||
|
type Node,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
|
import styles from '../../VisProg.module.css';
|
||||||
|
import useFlowStore from '../VisProgStores';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { RealtimeTextField, TextField } from '../../../../components/TextField';
|
||||||
|
import duplicateIndices from '../../../../utils/duplicateIndices';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default data dot a Trigger node
|
||||||
|
* @param label: the label of this Trigger
|
||||||
|
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
||||||
|
* @param children: ID's of children of this node
|
||||||
|
*/
|
||||||
|
export type TriggerNodeData = {
|
||||||
|
label: string;
|
||||||
|
droppable: boolean;
|
||||||
|
triggerType: "keywords" | string;
|
||||||
|
triggers: Keyword[] | never;
|
||||||
|
hasReduce: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type TriggerNode = Node<TriggerNodeData>
|
||||||
|
|
||||||
|
|
||||||
|
export function TriggerNodeCanConnect(connection: Connection | Edge): boolean {
|
||||||
|
return (connection != undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how a Trigger node should be rendered
|
||||||
|
* @param props NodeProps, like id, label, children
|
||||||
|
* @returns React.JSX.Element
|
||||||
|
*/
|
||||||
|
export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
||||||
|
const data = props.data;
|
||||||
|
const {updateNodeData} = useFlowStore();
|
||||||
|
|
||||||
|
const setKeywords = (keywords: Keyword[]) => {
|
||||||
|
updateNodeData(props.id, {...data, triggers: keywords});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
|
||||||
|
{data.triggerType === "emotion" && (
|
||||||
|
<div className={"flex-row gap-md"}>Emotion?</div>
|
||||||
|
)}
|
||||||
|
{data.triggerType === "keywords" && (
|
||||||
|
<Keywords
|
||||||
|
keywords={data.triggers}
|
||||||
|
setKeywords={setKeywords}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Handle type="source" position={Position.Right} id="TriggerSource"/>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces each Trigger, including its children down into its relevant data.
|
||||||
|
* @param node: The Node Properties of this node.
|
||||||
|
* @param nodes: all the nodes in the graph.
|
||||||
|
*/
|
||||||
|
export function TriggerReduce(node: Node, nodes: Node[]) {
|
||||||
|
// Replace this for nodes functionality
|
||||||
|
if (nodes.length <= -1) {
|
||||||
|
console.warn("Impossible nodes length in TriggerReduce")
|
||||||
|
}
|
||||||
|
const data = node.data as TriggerNodeData;
|
||||||
|
return {
|
||||||
|
label: data.label,
|
||||||
|
list: data.triggers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type (trigger)
|
||||||
|
* @param thisNode the node of this node type which function is called
|
||||||
|
* @param otherNode the other node which was part of the connection
|
||||||
|
* @param isThisSource whether this instance of the node was the source in the connection, true = yes.
|
||||||
|
*/
|
||||||
|
export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
|
||||||
|
// Replace this for connection logic
|
||||||
|
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
|
||||||
|
console.warn("Impossible node connection called in EndConnects")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definitions for the possible triggers, being keywords and emotions
|
||||||
|
type Keyword = { id: string, keyword: string };
|
||||||
|
|
||||||
|
export type EmotionTriggerNodeProps = {
|
||||||
|
type: "emotion";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
export type KeywordTriggerNodeProps = {
|
||||||
|
type: "keywords";
|
||||||
|
value: Keyword[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JSX element that is responsible for updating the field and showing the text
|
||||||
|
* @param param0 the function that updates the field
|
||||||
|
* @returns React.JSX.Element that handles adding keywords
|
||||||
|
*/
|
||||||
|
function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
|
||||||
|
const text_input_id = "keyword_adder_input";
|
||||||
|
|
||||||
|
return <div className={"flex-row gap-md"}>
|
||||||
|
<label htmlFor={text_input_id}>New Keyword:</label>
|
||||||
|
<RealtimeTextField
|
||||||
|
id={text_input_id}
|
||||||
|
value={input}
|
||||||
|
setValue={setInput}
|
||||||
|
onCommit={() => {
|
||||||
|
if (!input) return;
|
||||||
|
addKeyword(input);
|
||||||
|
setInput("");
|
||||||
|
}}
|
||||||
|
placeholder={"..."}
|
||||||
|
className={"flex-1"}
|
||||||
|
/>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Keywords({
|
||||||
|
keywords,
|
||||||
|
setKeywords,
|
||||||
|
}: {
|
||||||
|
keywords: Keyword[];
|
||||||
|
setKeywords: (keywords: Keyword[]) => void;
|
||||||
|
}) {
|
||||||
|
type Interpolatable = string | number | boolean | bigint | null | undefined;
|
||||||
|
|
||||||
|
const inputElementId = (id: Interpolatable) => `keyword_${id}_input`;
|
||||||
|
|
||||||
|
/** Indices of duplicates in the keyword array. */
|
||||||
|
const [duplicates, setDuplicates] = useState<number[]>([]);
|
||||||
|
|
||||||
|
function replace(id: string, value: string) {
|
||||||
|
value = value.trim();
|
||||||
|
const newKeywords = value === ""
|
||||||
|
? keywords.filter((kw) => kw.id != id)
|
||||||
|
: keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw);
|
||||||
|
setKeywords(newKeywords);
|
||||||
|
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(value: string) {
|
||||||
|
value = value.trim();
|
||||||
|
if (value === "") return;
|
||||||
|
const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}];
|
||||||
|
setKeywords(newKeywords);
|
||||||
|
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<span>Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken.</span>
|
||||||
|
{[...keywords].map(({id, keyword}, index) => {
|
||||||
|
return <div key={id} className={"flex-row gap-md"}>
|
||||||
|
<label htmlFor={inputElementId(id)}>Keyword:</label>
|
||||||
|
<TextField
|
||||||
|
id={inputElementId(id)}
|
||||||
|
value={keyword}
|
||||||
|
setValue={(val) => replace(id, val)}
|
||||||
|
placeholder={"..."}
|
||||||
|
className={"flex-1"}
|
||||||
|
invalid={duplicates.includes(index)}
|
||||||
|
/>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
<KeywordAdder addKeyword={add} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
29
src/utils/cellStore.ts
Normal file
29
src/utils/cellStore.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import {useSyncExternalStore} from "react";
|
||||||
|
|
||||||
|
type Unsub = () => void;
|
||||||
|
|
||||||
|
export type Cell<T> = {
|
||||||
|
get: () => T;
|
||||||
|
set: (next: T | ((prev: T) => T)) => void;
|
||||||
|
subscribe: (callback: () => void) => Unsub;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function cell<T>(initial: T): Cell<T> {
|
||||||
|
let value = initial;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
return {
|
||||||
|
get: () => value,
|
||||||
|
set: (next) => {
|
||||||
|
value = typeof next === "function" ? (next as (v: T) => T)(value) : next;
|
||||||
|
for (const l of listeners) l();
|
||||||
|
},
|
||||||
|
subscribe: (callback) => {
|
||||||
|
listeners.add(callback);
|
||||||
|
return () => listeners.delete(callback);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCell<T>(c: Cell<T>) {
|
||||||
|
return useSyncExternalStore(c.subscribe, c.get, c.get);
|
||||||
|
}
|
||||||
19
src/utils/duplicateIndices.ts
Normal file
19
src/utils/duplicateIndices.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Find the indices of all elements that occur more than once.
|
||||||
|
*
|
||||||
|
* @param array The array to search for duplicates.
|
||||||
|
* @returns An array of indices where an element occurs more than once, in no particular order.
|
||||||
|
*/
|
||||||
|
export default function duplicateIndices<T>(array: T[]): number[] {
|
||||||
|
const positions = new Map<T, number[]>();
|
||||||
|
|
||||||
|
array.forEach((value, i) => {
|
||||||
|
if (!positions.has(value)) positions.set(value, []);
|
||||||
|
positions.get(value)!.push(i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// flatten all index lists with more than one element
|
||||||
|
return Array.from(positions.values())
|
||||||
|
.filter(idxs => idxs.length > 1)
|
||||||
|
.flat();
|
||||||
|
}
|
||||||
21
src/utils/formatDuration.ts
Normal file
21
src/utils/formatDuration.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Format a time duration like `HH:MM:SS.mmm`.
|
||||||
|
*
|
||||||
|
* @param durationMs time duration in milliseconds.
|
||||||
|
* @return formatted time string.
|
||||||
|
*/
|
||||||
|
export default function formatDuration(durationMs: number): string {
|
||||||
|
const isNegative = durationMs < 0;
|
||||||
|
if (isNegative) durationMs = -durationMs;
|
||||||
|
|
||||||
|
const hours = Math.floor(durationMs / 3600000);
|
||||||
|
const minutes = Math.floor((durationMs % 3600000) / 60000);
|
||||||
|
const seconds = Math.floor((durationMs % 60000) / 1000);
|
||||||
|
const milliseconds = Math.floor(durationMs % 1000);
|
||||||
|
|
||||||
|
return (isNegative ? '-' : '') +
|
||||||
|
`${hours.toString().padStart(2, '0')}:` +
|
||||||
|
`${minutes.toString().padStart(2, '0')}:` +
|
||||||
|
`${seconds.toString().padStart(2, '0')}.` +
|
||||||
|
`${milliseconds.toString().padStart(3, '0')}`;
|
||||||
|
}
|
||||||
24
src/utils/priorityFiltering.ts
Normal file
24
src/utils/priorityFiltering.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export type PriorityFilterPredicate<T> = {
|
||||||
|
priority: number;
|
||||||
|
predicate: (element: T) => boolean | null; // The predicate and its priority are ignored if it returns null.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a list of priority predicates to an element. For all predicates that don't return null, if the ones with the highest level return true, then this function returns true.
|
||||||
|
* @param element The element to apply the predicates to.
|
||||||
|
* @param predicates The list of predicates to apply.
|
||||||
|
*/
|
||||||
|
export function applyPriorityPredicates<T>(element: T, predicates: PriorityFilterPredicate<T>[]): boolean {
|
||||||
|
let highestPriority = -1;
|
||||||
|
let highestKeep = true;
|
||||||
|
for (const predicate of predicates) {
|
||||||
|
if (predicate.priority >= highestPriority) {
|
||||||
|
const predicateKeep = predicate.predicate(element);
|
||||||
|
if (predicateKeep === null) continue; // This predicate doesn't care about the element, so skip it
|
||||||
|
if (predicate.priority > highestPriority) highestKeep = true;
|
||||||
|
highestPriority = predicate.priority;
|
||||||
|
highestKeep = highestKeep && predicateKeep;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return highestKeep;
|
||||||
|
}
|
||||||
328
test/components/Logging/Filters.test.tsx
Normal file
328
test/components/Logging/Filters.test.tsx
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import {render, screen, waitFor, fireEvent} from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type ControlledUseState = typeof React.useState & {
|
||||||
|
__forceNextReturn?: (value: any) => jest.Mock;
|
||||||
|
__resetMockState?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock("react", () => {
|
||||||
|
const actual = jest.requireActual("react");
|
||||||
|
const queue: Array<{value: any; setter: jest.Mock}> = [];
|
||||||
|
const mockUseState = ((initial: any) => {
|
||||||
|
if (queue.length) {
|
||||||
|
const {value, setter} = queue.shift()!;
|
||||||
|
return [value, setter];
|
||||||
|
}
|
||||||
|
return actual.useState(initial);
|
||||||
|
}) as ControlledUseState;
|
||||||
|
|
||||||
|
mockUseState.__forceNextReturn = (value: any) => {
|
||||||
|
const setter = jest.fn();
|
||||||
|
queue.push({value, setter});
|
||||||
|
return setter;
|
||||||
|
};
|
||||||
|
mockUseState.__resetMockState = () => {
|
||||||
|
queue.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
...actual,
|
||||||
|
useState: mockUseState,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
import Filters from "../../../src/components/Logging/Filters.tsx";
|
||||||
|
import type {LogFilterPredicate, LogRecord} from "../../../src/components/Logging/useLogs.ts";
|
||||||
|
|
||||||
|
const GLOBAL = "global_log_level";
|
||||||
|
const AGENT_PREFIX = "agent_log_level_";
|
||||||
|
const optionMapping = new Map([
|
||||||
|
["ALL", 0],
|
||||||
|
["DEBUG", 10],
|
||||||
|
["INFO", 20],
|
||||||
|
["WARNING", 30],
|
||||||
|
["ERROR", 40],
|
||||||
|
["CRITICAL", 50],
|
||||||
|
["NONE", 999_999_999_999],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const controlledUseState = React.useState as ControlledUseState;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
controlledUseState.__resetMockState?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
function getCallArg<T>(mock: jest.Mock, index = 0): T {
|
||||||
|
return mock.mock.calls[index][0] as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleRecord(levelno: number, name = "any.logger"): LogRecord {
|
||||||
|
return {
|
||||||
|
levelname: "UNKNOWN",
|
||||||
|
levelno,
|
||||||
|
name,
|
||||||
|
message: "Whatever",
|
||||||
|
created: 0,
|
||||||
|
relativeCreated: 0,
|
||||||
|
firstCreated: 0,
|
||||||
|
firstRelativeCreated: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("Filters", () => {
|
||||||
|
describe("Global level filter", () => {
|
||||||
|
it("initializes to INFO when missing", async () => {
|
||||||
|
const setFilterPredicates = jest.fn();
|
||||||
|
const filterPredicates = new Map<string, LogFilterPredicate>();
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<Filters
|
||||||
|
filterPredicates={filterPredicates}
|
||||||
|
setFilterPredicates={setFilterPredicates}
|
||||||
|
agentNames={new Set<string>()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Effect sets default to INFO
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(setFilterPredicates).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
|
||||||
|
const newMap = updater(filterPredicates);
|
||||||
|
const global = newMap.get(GLOBAL)!;
|
||||||
|
|
||||||
|
expect(global.value).toBe("INFO");
|
||||||
|
expect(global.priority).toBe(0);
|
||||||
|
// Predicate gate at INFO (>= 20)
|
||||||
|
expect(global.predicate(sampleRecord(10))).toBe(false);
|
||||||
|
expect(global.predicate(sampleRecord(20))).toBe(true);
|
||||||
|
|
||||||
|
// UI shows INFO selected after parent state updates
|
||||||
|
view.rerender(
|
||||||
|
<Filters
|
||||||
|
filterPredicates={newMap}
|
||||||
|
setFilterPredicates={setFilterPredicates}
|
||||||
|
agentNames={new Set<string>()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const globalSelect = screen.getByLabelText("Global:");
|
||||||
|
expect((globalSelect as HTMLSelectElement).value).toBe("INFO");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates predicate when selecting a higher level", async () => {
|
||||||
|
// Start with INFO already present
|
||||||
|
const existing = new Map<string, LogFilterPredicate>([
|
||||||
|
[
|
||||||
|
GLOBAL,
|
||||||
|
{
|
||||||
|
value: "INFO",
|
||||||
|
priority: 0,
|
||||||
|
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setFilterPredicates = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Filters
|
||||||
|
filterPredicates={existing}
|
||||||
|
setFilterPredicates={setFilterPredicates}
|
||||||
|
agentNames={new Set<string>()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("Global:");
|
||||||
|
await user.selectOptions(select, "ERROR");
|
||||||
|
|
||||||
|
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
|
||||||
|
const updated = updater(existing);
|
||||||
|
const global = updated.get(GLOBAL)!;
|
||||||
|
|
||||||
|
expect(global.value).toBe("ERROR");
|
||||||
|
expect(global.priority).toBe(0);
|
||||||
|
expect(global.predicate(sampleRecord(30))).toBe(false);
|
||||||
|
expect(global.predicate(sampleRecord(40))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Agent level filters", () => {
|
||||||
|
it("adds an agent using the current global level when none specified", async () => {
|
||||||
|
// Global set to WARNING
|
||||||
|
const existing = new Map<string, LogFilterPredicate>([
|
||||||
|
[
|
||||||
|
GLOBAL,
|
||||||
|
{
|
||||||
|
value: "WARNING",
|
||||||
|
priority: 0,
|
||||||
|
predicate: (r: any) => r.levelno >= optionMapping.get("WARNING")!
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setFilterPredicates = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Filters
|
||||||
|
filterPredicates={existing}
|
||||||
|
setFilterPredicates={setFilterPredicates}
|
||||||
|
agentNames={new Set<string>(["pepper.speech", "vision.agent"])}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const addSelect = screen.getByLabelText("Add:");
|
||||||
|
await user.selectOptions(addSelect, "pepper.speech");
|
||||||
|
|
||||||
|
// Agent setter is functional: prev => next
|
||||||
|
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
|
||||||
|
const next = updater(existing);
|
||||||
|
|
||||||
|
const key = AGENT_PREFIX + "pepper.speech";
|
||||||
|
const agentPred = next.get(key)!;
|
||||||
|
|
||||||
|
expect(agentPred.priority).toBe(1);
|
||||||
|
expect(agentPred.value).toEqual({agentName: "pepper.speech", level: "WARNING"});
|
||||||
|
// When agentName matches, enforce WARNING (>= 30)
|
||||||
|
expect(agentPred.predicate(sampleRecord(20, "pepper.speech"))).toBe(false);
|
||||||
|
expect(agentPred.predicate(sampleRecord(30, "pepper.speech"))).toBe(true);
|
||||||
|
// Other agents -> null
|
||||||
|
expect(agentPred.predicate(sampleRecord(999, "other"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes an agent's level when its select is updated", async () => {
|
||||||
|
// Prepopulate agent predicate at WARNING
|
||||||
|
const key = AGENT_PREFIX + "pepper.speech";
|
||||||
|
const existing = new Map<string, LogFilterPredicate>([
|
||||||
|
[
|
||||||
|
GLOBAL,
|
||||||
|
{
|
||||||
|
value: "INFO",
|
||||||
|
priority: 0,
|
||||||
|
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
value: {agentName: "pepper.speech", level: "WARNING"},
|
||||||
|
priority: 1,
|
||||||
|
predicate: (r: any) => (r.name === "pepper.speech" ? r.levelno >= optionMapping.get("WARNING")! : null)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setFilterPredicates = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const element = render(
|
||||||
|
<Filters
|
||||||
|
filterPredicates={existing}
|
||||||
|
setFilterPredicates={setFilterPredicates}
|
||||||
|
agentNames={new Set(["pepper.speech"])}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentSelect = element.container.querySelector("select#log_level_pepper\\.speech")!;
|
||||||
|
|
||||||
|
await user.selectOptions(agentSelect, "ERROR");
|
||||||
|
|
||||||
|
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
|
||||||
|
const next = updater(existing);
|
||||||
|
const updated = next.get(key)!;
|
||||||
|
|
||||||
|
expect(updated.value).toEqual({agentName: "pepper.speech", level: "ERROR"});
|
||||||
|
// Threshold moved to ERROR (>= 40)
|
||||||
|
expect(updated.predicate(sampleRecord(30, "pepper.speech"))).toBe(false);
|
||||||
|
expect(updated.predicate(sampleRecord(40, "pepper.speech"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes an agent predicate when clicking its name button", async () => {
|
||||||
|
const key = AGENT_PREFIX + "pepper.speech";
|
||||||
|
const existing = new Map<string, LogFilterPredicate>([
|
||||||
|
[
|
||||||
|
GLOBAL,
|
||||||
|
{
|
||||||
|
value: "INFO",
|
||||||
|
priority: 0,
|
||||||
|
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
value: {agentName: "pepper.speech", level: "INFO"},
|
||||||
|
priority: 1,
|
||||||
|
predicate: (r: any) => (r.name === "pepper.speech" ? r.levelno >= optionMapping.get("INFO")! : null)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setFilterPredicates = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Filters
|
||||||
|
filterPredicates={existing}
|
||||||
|
setFilterPredicates={setFilterPredicates}
|
||||||
|
agentNames={new Set<string>(["pepper.speech"])}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteBtn = screen.getByRole("button", {name: "speech:"});
|
||||||
|
await user.click(deleteBtn);
|
||||||
|
|
||||||
|
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
|
||||||
|
const next = updater(existing);
|
||||||
|
expect(next.has(key)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Filter popup behavior", () => {
|
||||||
|
function renderWithPopupOpen() {
|
||||||
|
const existing = new Map<string, LogFilterPredicate>([
|
||||||
|
[
|
||||||
|
GLOBAL,
|
||||||
|
{
|
||||||
|
value: "INFO",
|
||||||
|
priority: 0,
|
||||||
|
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
const setFilterPredicates = jest.fn();
|
||||||
|
const forceNext = controlledUseState.__forceNextReturn;
|
||||||
|
if (!forceNext) throw new Error("useState mock missing helper");
|
||||||
|
const setOpen = forceNext(true);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Filters
|
||||||
|
filterPredicates={existing}
|
||||||
|
setFilterPredicates={setFilterPredicates}
|
||||||
|
agentNames={new Set(["pepper.vision"])}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { setOpen };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("closes the popup when clicking outside", () => {
|
||||||
|
const { setOpen } = renderWithPopupOpen();
|
||||||
|
fireEvent.mouseDown(document.body);
|
||||||
|
expect(setOpen).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the popup when pressing Escape", () => {
|
||||||
|
const { setOpen } = renderWithPopupOpen();
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(setOpen).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
239
test/components/Logging/Logging.test.tsx
Normal file
239
test/components/Logging/Logging.test.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import {render, screen, fireEvent, act, waitFor} from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import type {Cell} from "../../../src/utils/cellStore.ts";
|
||||||
|
import {cell} from "../../../src/utils/cellStore.ts";
|
||||||
|
import type {LogFilterPredicate, LogRecord} from "../../../src/components/Logging/useLogs.ts";
|
||||||
|
|
||||||
|
const mockFiltersRender = jest.fn();
|
||||||
|
const loggingStoreRef: { current: null | { setState: (state: Partial<LoggingSettingsState>) => void } } = { current: null };
|
||||||
|
|
||||||
|
type LoggingSettingsState = {
|
||||||
|
showRelativeTime: boolean;
|
||||||
|
setShowRelativeTime: (show: boolean) => void;
|
||||||
|
scrollToBottom: boolean;
|
||||||
|
setScrollToBottom: (scroll: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock("zustand", () => {
|
||||||
|
const actual = jest.requireActual("zustand");
|
||||||
|
const actualCreate = actual.create;
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
...actual,
|
||||||
|
create: (...args: any[]) => {
|
||||||
|
const store = actualCreate(...args);
|
||||||
|
const state = store.getState();
|
||||||
|
if ("setShowRelativeTime" in state && "setScrollToBottom" in state) {
|
||||||
|
loggingStoreRef.current = store;
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("../../../src/components/Logging/Filters.tsx", () => {
|
||||||
|
const React = jest.requireActual("react");
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
default: (props: any) => {
|
||||||
|
mockFiltersRender(props);
|
||||||
|
return React.createElement("div", {"data-testid": "filters-mock"}, "filters");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("../../../src/components/Logging/useLogs.ts", () => {
|
||||||
|
const actual = jest.requireActual("../../../src/components/Logging/useLogs.ts");
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
...actual,
|
||||||
|
useLogs: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import {useLogs} from "../../../src/components/Logging/useLogs.ts";
|
||||||
|
const mockUseLogs = useLogs as jest.MockedFunction<typeof useLogs>;
|
||||||
|
|
||||||
|
type LoggingComponent = typeof import("../../../src/components/Logging/Logging.tsx").default;
|
||||||
|
let Logging: LoggingComponent;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (!Element.prototype.scrollIntoView) {
|
||||||
|
Object.defineProperty(Element.prototype, "scrollIntoView", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: function () {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
({default: Logging} = await import("../../../src/components/Logging/Logging.tsx"));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseLogs.mockReset();
|
||||||
|
mockFiltersRender.mockReset();
|
||||||
|
mockUseLogs.mockReturnValue({filteredLogs: [], distinctNames: new Set()});
|
||||||
|
resetLoggingStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetLoggingStore() {
|
||||||
|
loggingStoreRef.current?.setState({
|
||||||
|
showRelativeTime: false,
|
||||||
|
scrollToBottom: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRecord(overrides: Partial<LogRecord> = {}): LogRecord {
|
||||||
|
return {
|
||||||
|
name: "pepper.logger",
|
||||||
|
message: "default",
|
||||||
|
levelname: "INFO",
|
||||||
|
levelno: 20,
|
||||||
|
created: 1,
|
||||||
|
relativeCreated: 1,
|
||||||
|
firstCreated: 1,
|
||||||
|
firstRelativeCreated: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCell(overrides: Partial<LogRecord> = {}): Cell<LogRecord> {
|
||||||
|
return cell(makeRecord(overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Logging component", () => {
|
||||||
|
it("renders log messages and toggles the timestamp between absolute and relative view", async () => {
|
||||||
|
const logCell = makeCell({
|
||||||
|
name: "pepper.trace.logging",
|
||||||
|
message: "Ping",
|
||||||
|
levelname: "WARNING",
|
||||||
|
levelno: 30,
|
||||||
|
created: 1_700_000_000,
|
||||||
|
relativeCreated: 12_345,
|
||||||
|
firstCreated: 1_700_000_000,
|
||||||
|
firstRelativeCreated: 12_345,
|
||||||
|
});
|
||||||
|
|
||||||
|
const names = new Set(["pepper.trace.logging"]);
|
||||||
|
mockUseLogs.mockReturnValue({filteredLogs: [logCell], distinctNames: names});
|
||||||
|
|
||||||
|
jest.spyOn(Date.prototype, "toLocaleTimeString").mockReturnValue("ABS TIME");
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<Logging/>);
|
||||||
|
|
||||||
|
expect(screen.getByText("Logs")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("WARNING")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("logging")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Ping")).toBeInTheDocument();
|
||||||
|
|
||||||
|
let timestamp = screen.queryByText("ABS TIME");
|
||||||
|
if (!timestamp) {
|
||||||
|
// if previous test left the store toggled, click once to show absolute time
|
||||||
|
timestamp = screen.getByText("00:00:12.345");
|
||||||
|
await user.click(timestamp);
|
||||||
|
timestamp = screen.getByText("ABS TIME");
|
||||||
|
}
|
||||||
|
|
||||||
|
await user.click(timestamp);
|
||||||
|
expect(screen.getByText("00:00:12.345")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the scroll-to-bottom button after a manual scroll and scrolls when clicked", async () => {
|
||||||
|
const logs = [
|
||||||
|
makeCell({message: "first", firstRelativeCreated: 1}),
|
||||||
|
makeCell({message: "second", firstRelativeCreated: 2}),
|
||||||
|
];
|
||||||
|
mockUseLogs.mockReturnValue({filteredLogs: logs, distinctNames: new Set()});
|
||||||
|
|
||||||
|
const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const view = render(<Logging/>);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", {name: "Scroll to bottom"})).toBeNull();
|
||||||
|
|
||||||
|
const scrollable = view.container.querySelector(".scroll-y");
|
||||||
|
expect(scrollable).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.wheel(scrollable!);
|
||||||
|
|
||||||
|
const button = await screen.findByRole("button", {name: "Scroll to bottom"});
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
expect(scrollSpy).toHaveBeenCalled();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("button", {name: "Scroll to bottom"})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scrolls the last element into view when a log cell updates", async () => {
|
||||||
|
const logCell = makeCell({message: "Initial", firstRelativeCreated: 42});
|
||||||
|
mockUseLogs.mockReturnValue({filteredLogs: [logCell], distinctNames: new Set()});
|
||||||
|
|
||||||
|
const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {});
|
||||||
|
render(<Logging/>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
scrollSpy.mockClear();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const current = logCell.get();
|
||||||
|
logCell.set({...current, message: "Updated"});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Updated")).toBeInTheDocument();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes filter state to Filters and re-invokes useLogs when predicates change", async () => {
|
||||||
|
const distinct = new Set(["pepper.core"]);
|
||||||
|
mockUseLogs.mockImplementation((_filters: Map<string, LogFilterPredicate>) => ({
|
||||||
|
filteredLogs: [],
|
||||||
|
distinctNames: distinct,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(<Logging/>);
|
||||||
|
|
||||||
|
expect(mockFiltersRender).toHaveBeenCalledTimes(1);
|
||||||
|
const firstProps = mockFiltersRender.mock.calls[0][0];
|
||||||
|
expect(firstProps.agentNames).toBe(distinct);
|
||||||
|
|
||||||
|
const initialMap = firstProps.filterPredicates;
|
||||||
|
expect(initialMap).toBeInstanceOf(Map);
|
||||||
|
expect(initialMap.size).toBe(0);
|
||||||
|
expect(mockUseLogs).toHaveBeenCalledWith(initialMap);
|
||||||
|
|
||||||
|
const updatedPredicate: LogFilterPredicate = {
|
||||||
|
value: "custom",
|
||||||
|
priority: 0,
|
||||||
|
predicate: () => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
firstProps.setFilterPredicates((prev: Map<string, LogFilterPredicate>) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set("custom", updatedPredicate);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUseLogs).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextFilters = mockUseLogs.mock.calls[1][0];
|
||||||
|
expect(nextFilters.get("custom")).toBe(updatedPredicate);
|
||||||
|
|
||||||
|
const secondProps = mockFiltersRender.mock.calls[mockFiltersRender.mock.calls.length - 1][0];
|
||||||
|
expect(secondProps.filterPredicates).toBe(nextFilters);
|
||||||
|
});
|
||||||
|
});
|
||||||
246
test/components/Logging/useLogs.test.tsx
Normal file
246
test/components/Logging/useLogs.test.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { render, screen, act } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import {type LogRecord, useLogs} from "../../../src/components/Logging/useLogs.ts";
|
||||||
|
import {type cell, useCell} from "../../../src/utils/cellStore.ts";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
|
||||||
|
jest.mock("../../../src/utils/priorityFiltering.ts", () => ({
|
||||||
|
applyPriorityPredicates: jest.fn((_log, preds: any[]) =>
|
||||||
|
preds.every(() => true) // default: pass all
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
import {applyPriorityPredicates} from "../../../src/utils/priorityFiltering.ts";
|
||||||
|
|
||||||
|
class MockEventSource {
|
||||||
|
url: string;
|
||||||
|
onmessage: ((event: { data: string }) => void) | null = null;
|
||||||
|
onerror: ((event: unknown) => void) | null = null;
|
||||||
|
close = jest.fn();
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url;
|
||||||
|
// expose the latest instance for tests:
|
||||||
|
(globalThis as any).__es = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
globalThis.EventSource = MockEventSource as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// reset mock so previous instance not reused accidentally
|
||||||
|
(globalThis as any).__es = undefined;
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function LogsProbe({ filters }: { filters: Map<string, any> }) {
|
||||||
|
const { filteredLogs, distinctNames } = useLogs(filters);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="names-count">{distinctNames.size}</div>
|
||||||
|
<ul data-testid="logs">
|
||||||
|
{filteredLogs.map((c, i) => (
|
||||||
|
<LogItem key={i} cell={c} index={i} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogItem({ cell: c, index }: { cell: ReturnType<typeof cell<LogRecord>>; index: number }) {
|
||||||
|
const value = useCell(c);
|
||||||
|
return (
|
||||||
|
<li data-testid={`log-${index}`}>
|
||||||
|
<span data-testid={`log-${index}-name`}>{value.name}</span>
|
||||||
|
<span data-testid={`log-${index}-msg`}>{value.message}</span>
|
||||||
|
<span data-testid={`log-${index}-first`}>{String(value.firstCreated)}</span>
|
||||||
|
<span data-testid={`log-${index}-created`}>{String(value.created)}</span>
|
||||||
|
<span data-testid={`log-${index}-ref`}>{value.reference ?? ""}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(log: LogRecord) {
|
||||||
|
const eventSource = (globalThis as any).__es as MockEventSource;
|
||||||
|
if (!eventSource || !eventSource.onmessage) throw new Error("EventSource not initialized");
|
||||||
|
act(() => {
|
||||||
|
eventSource.onmessage!({ data: JSON.stringify(log) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useLogs (unit)", () => {
|
||||||
|
it("creates EventSource once and closes on unmount", () => {
|
||||||
|
const filters = new Map(); // allow all by default
|
||||||
|
const { unmount } = render(
|
||||||
|
<StrictMode>
|
||||||
|
<LogsProbe filters={filters} />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
const es = (globalThis as any).__es as MockEventSource;
|
||||||
|
expect(es).toBeTruthy();
|
||||||
|
expect(es.url).toBe("http://localhost:8000/logs/stream");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
expect(es.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends filtered logs and collects distinct names", () => {
|
||||||
|
const filters = new Map();
|
||||||
|
render(
|
||||||
|
<StrictMode>
|
||||||
|
<LogsProbe filters={filters} />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("names-count")).toHaveTextContent("0");
|
||||||
|
|
||||||
|
emit({
|
||||||
|
levelname: "DEBUG",
|
||||||
|
levelno: 10,
|
||||||
|
name: "alpha",
|
||||||
|
message: "m1",
|
||||||
|
created: 1,
|
||||||
|
relativeCreated: 1,
|
||||||
|
firstCreated: 1,
|
||||||
|
firstRelativeCreated: 1,
|
||||||
|
});
|
||||||
|
emit({
|
||||||
|
levelname: "DEBUG",
|
||||||
|
levelno: 10,
|
||||||
|
name: "beta",
|
||||||
|
message: "m2",
|
||||||
|
created: 2,
|
||||||
|
relativeCreated: 2,
|
||||||
|
firstCreated: 2,
|
||||||
|
firstRelativeCreated: 2,
|
||||||
|
});
|
||||||
|
emit({
|
||||||
|
levelname: "DEBUG",
|
||||||
|
levelno: 10,
|
||||||
|
name: "alpha",
|
||||||
|
message: "m3",
|
||||||
|
created: 3,
|
||||||
|
relativeCreated: 3,
|
||||||
|
firstCreated: 3,
|
||||||
|
firstRelativeCreated: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3 messages (no reference), 2 distinct names
|
||||||
|
expect(screen.getAllByRole("listitem")).toHaveLength(3);
|
||||||
|
expect(screen.getByTestId("names-count")).toHaveTextContent("2");
|
||||||
|
|
||||||
|
expect(screen.getByTestId("log-0-name")).toHaveTextContent("alpha");
|
||||||
|
expect(screen.getByTestId("log-1-name")).toHaveTextContent("beta");
|
||||||
|
expect(screen.getByTestId("log-2-name")).toHaveTextContent("alpha");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates first message with reference when a second one with that reference comes", () => {
|
||||||
|
const filters = new Map();
|
||||||
|
render(<LogsProbe filters={filters} />);
|
||||||
|
|
||||||
|
// First message with ref r1
|
||||||
|
emit({
|
||||||
|
levelname: "DEBUG",
|
||||||
|
levelno: 10,
|
||||||
|
name: "svc",
|
||||||
|
message: "first",
|
||||||
|
reference: "r1",
|
||||||
|
created: 10,
|
||||||
|
relativeCreated: 10,
|
||||||
|
firstCreated: 10,
|
||||||
|
firstRelativeCreated: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second message with same ref r1, should still be a single item
|
||||||
|
emit({
|
||||||
|
levelname: "DEBUG",
|
||||||
|
levelno: 10,
|
||||||
|
name: "svc",
|
||||||
|
message: "second",
|
||||||
|
reference: "r1",
|
||||||
|
created: 20,
|
||||||
|
relativeCreated: 20,
|
||||||
|
firstCreated: 20,
|
||||||
|
firstRelativeCreated: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = screen.getAllByRole("listitem");
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
|
||||||
|
// Same single item, but message should be "second"
|
||||||
|
expect(screen.getByTestId("log-0-msg")).toHaveTextContent("second");
|
||||||
|
// The "firstCreated" should remain the original (10), while "created" is now 20
|
||||||
|
expect(screen.getByTestId("log-0-first")).toHaveTextContent("10");
|
||||||
|
expect(screen.getByTestId("log-0-created")).toHaveTextContent("20");
|
||||||
|
expect(screen.getByTestId("log-0-ref")).toHaveTextContent("r1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs recomputeFiltered when filters change", () => {
|
||||||
|
const allowAll = new Map<string, any>();
|
||||||
|
const { rerender } = render(<LogsProbe filters={allowAll} />);
|
||||||
|
|
||||||
|
emit({
|
||||||
|
levelname: "DEBUG",
|
||||||
|
levelno: 10,
|
||||||
|
name: "n1",
|
||||||
|
message: "ok",
|
||||||
|
created: 1,
|
||||||
|
relativeCreated: 1,
|
||||||
|
firstCreated: 1,
|
||||||
|
firstRelativeCreated: 1,
|
||||||
|
});
|
||||||
|
emit({
|
||||||
|
levelname: "DEBUG",
|
||||||
|
levelno: 10,
|
||||||
|
name: "n2",
|
||||||
|
message: "ok",
|
||||||
|
created: 2,
|
||||||
|
relativeCreated: 2,
|
||||||
|
firstCreated: 2,
|
||||||
|
firstRelativeCreated: 2,
|
||||||
|
});
|
||||||
|
emit({
|
||||||
|
levelname: "INFO",
|
||||||
|
levelno: 20,
|
||||||
|
name: "n3",
|
||||||
|
message: "ok1",
|
||||||
|
reference: "r1",
|
||||||
|
created: 3,
|
||||||
|
relativeCreated: 3,
|
||||||
|
firstCreated: 3,
|
||||||
|
firstRelativeCreated: 3,
|
||||||
|
});
|
||||||
|
emit({
|
||||||
|
levelname: "INFO",
|
||||||
|
levelno: 20,
|
||||||
|
name: "n3",
|
||||||
|
message: "ok2",
|
||||||
|
reference: "r1",
|
||||||
|
created: 4,
|
||||||
|
relativeCreated: 4,
|
||||||
|
firstCreated: 4,
|
||||||
|
firstRelativeCreated: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getAllByRole("listitem")).toHaveLength(3);
|
||||||
|
|
||||||
|
// Now change filters to block all < INFO
|
||||||
|
(applyPriorityPredicates as jest.Mock).mockImplementation((l) => l.levelno >= 20);
|
||||||
|
const blockDebug = new Map<string, any>([["dummy", { value: true }]]);
|
||||||
|
rerender(<LogsProbe filters={blockDebug} />);
|
||||||
|
|
||||||
|
// Should recompute with shorter list
|
||||||
|
expect(screen.queryAllByRole("listitem")).toHaveLength(1);
|
||||||
|
|
||||||
|
// Switch back to allow-all
|
||||||
|
(applyPriorityPredicates as jest.Mock).mockImplementation((_log, preds: any[]) =>
|
||||||
|
preds.every(() => true)
|
||||||
|
);
|
||||||
|
rerender(<LogsProbe filters={allowAll} />);
|
||||||
|
|
||||||
|
// recompute should restore all three
|
||||||
|
expect(screen.getAllByRole("listitem")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
0
test/eslint.config.js.ts
Normal file
0
test/eslint.config.js.ts
Normal file
104
test/pages/connectedRobots/ConnectedRobots.test.tsx
Normal file
104
test/pages/connectedRobots/ConnectedRobots.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { render, screen, act, cleanup, waitFor } from '@testing-library/react';
|
||||||
|
import ConnectedRobots from '../../../src/pages/ConnectedRobots/ConnectedRobots';
|
||||||
|
|
||||||
|
// Mock event source
|
||||||
|
const mockInstances: MockEventSource[] = [];
|
||||||
|
class MockEventSource {
|
||||||
|
url: string;
|
||||||
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||||
|
closed = false;
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url;
|
||||||
|
mockInstances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessage(data: string) {
|
||||||
|
// Trigger whatever the component listens to
|
||||||
|
this.onmessage?.({ data } as MessageEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mock event source generation with fake function that returns our fake mock source
|
||||||
|
beforeAll(() => {
|
||||||
|
// Cast globalThis to a type exposing EventSource and assign a mocked constructor.
|
||||||
|
(globalThis as unknown as { EventSource?: typeof EventSource }).EventSource =
|
||||||
|
jest.fn((url: string) => new MockEventSource(url)) as unknown as typeof EventSource;
|
||||||
|
});
|
||||||
|
|
||||||
|
// clean after tests
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
mockInstances.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ConnectedRobots', () => {
|
||||||
|
test('renders initial state correctly', () => {
|
||||||
|
render(<ConnectedRobots />);
|
||||||
|
|
||||||
|
// Check initial texts (before connection)
|
||||||
|
expect(screen.getByText('Is robot currently connected?')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Robot is currently:\s*checking/i)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/If checking continues, make sure CB is properly loaded/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates to connected when message data is true', async () => {
|
||||||
|
render(<ConnectedRobots />);
|
||||||
|
const eventSource = mockInstances[0];
|
||||||
|
expect(eventSource).toBeDefined();
|
||||||
|
|
||||||
|
// Check state after getting 'true' message
|
||||||
|
await act(async () => {
|
||||||
|
eventSource.sendMessage('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/connected! 🟢/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates to not connected when message data is false', async () => {
|
||||||
|
render(<ConnectedRobots />);
|
||||||
|
const eventSource = mockInstances[0];
|
||||||
|
|
||||||
|
// Check statew after getting 'false' message
|
||||||
|
await act(async () => {
|
||||||
|
eventSource.sendMessage('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/not connected.*🔴/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles invalid JSON gracefully', async () => {
|
||||||
|
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
render(<ConnectedRobots />);
|
||||||
|
const eventSource = mockInstances[0];
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
eventSource.sendMessage('not-json');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
|
'Ping message not in correct format:',
|
||||||
|
'not-json'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closes EventSource on unmount', () => {
|
||||||
|
render(<ConnectedRobots />);
|
||||||
|
const eventSource = mockInstances[0];
|
||||||
|
const closeSpy = jest.spyOn(eventSource, 'close');
|
||||||
|
cleanup();
|
||||||
|
expect(closeSpy).toHaveBeenCalled();
|
||||||
|
expect(eventSource.closed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,986 +1,5 @@
|
|||||||
import type {Edge} from "@xyflow/react";
|
describe('not yet implemented', () => {
|
||||||
import graphReducer, {
|
test('nothing yet', () => {
|
||||||
defaultGraphPreprocessor, defaultPhaseReducer,
|
expect(true);
|
||||||
orderPhases
|
|
||||||
} from "../../../../src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts";
|
|
||||||
import type {PreparedPhase} from "../../../../src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts";
|
|
||||||
import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
|
||||||
import type {AppNode} from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx";
|
|
||||||
|
|
||||||
// sets of default values for nodes and edges to be used for test cases
|
|
||||||
type FlowState = {
|
|
||||||
name: string;
|
|
||||||
nodes: AppNode[];
|
|
||||||
edges: Edge[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// predefined graphs for testing:
|
|
||||||
const onlyOnePhase : FlowState = {
|
|
||||||
name: "onlyOnePhase",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
type: 'start',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {label: 'start'}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges:[
|
|
||||||
{
|
|
||||||
id: 'start-phase-1',
|
|
||||||
source: 'start',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1-end',
|
|
||||||
source: 'phase-1',
|
|
||||||
target: 'end',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
const onlyThreePhases : FlowState = {
|
|
||||||
name: "onlyThreePhases",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
type: 'start',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {label: 'start'}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 3},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges:[
|
|
||||||
{
|
|
||||||
id: 'start-phase-1',
|
|
||||||
source: 'start',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1-phase-2',
|
|
||||||
source: 'phase-1',
|
|
||||||
target: 'phase-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2-phase-3',
|
|
||||||
source: 'phase-2',
|
|
||||||
target: 'phase-3',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3-end',
|
|
||||||
source: 'phase-3',
|
|
||||||
target: 'end',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
const onlySingleEdgeNorms : FlowState = {
|
|
||||||
name: "onlySingleEdgeNorms",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
type: 'start',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {label: 'start'}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-1',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 3},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges:[
|
|
||||||
{
|
|
||||||
id: 'start-phase-1',
|
|
||||||
source: 'start',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-1-phase-2',
|
|
||||||
source: 'norm-1',
|
|
||||||
target: 'phase-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1-phase-2',
|
|
||||||
source: 'phase-1',
|
|
||||||
target: 'phase-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2-phase-3',
|
|
||||||
source: 'phase-2',
|
|
||||||
target: 'phase-3',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2-phase-3',
|
|
||||||
source: 'norm-2',
|
|
||||||
target: 'phase-3',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3-end',
|
|
||||||
source: 'phase-3',
|
|
||||||
target: 'end',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
const multiEdgeNorms : FlowState = {
|
|
||||||
name: "multiEdgeNorms",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
type: 'start',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {label: 'start'}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-1',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 3},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-3',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges:[
|
|
||||||
{
|
|
||||||
id: 'start-phase-1',
|
|
||||||
source: 'start',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-1-phase-2',
|
|
||||||
source: 'norm-1',
|
|
||||||
target: 'phase-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-1-phase-3',
|
|
||||||
source: 'norm-1',
|
|
||||||
target: 'phase-3',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1-phase-2',
|
|
||||||
source: 'phase-1',
|
|
||||||
target: 'phase-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-3-phase-1',
|
|
||||||
source: 'norm-3',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2-phase-3',
|
|
||||||
source: 'phase-2',
|
|
||||||
target: 'phase-3',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2-phase-3',
|
|
||||||
source: 'norm-2',
|
|
||||||
target: 'phase-3',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2-phase-2',
|
|
||||||
source: 'norm-2',
|
|
||||||
target: 'phase-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3-end',
|
|
||||||
source: 'phase-3',
|
|
||||||
target: 'end',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
const onlyStartEnd : FlowState = {
|
|
||||||
name: "onlyStartEnd",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
type: 'start',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {label: 'start'}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges:[
|
|
||||||
{
|
|
||||||
id: 'start-end',
|
|
||||||
source: 'start',
|
|
||||||
target: 'end',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
// states that contain invalid programs for testing if correct errors are thrown:
|
|
||||||
const phaseConnectsToInvalidNodeType : FlowState = {
|
|
||||||
name: "phaseConnectsToInvalidNodeType",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
type: 'start',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {label: 'start'}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'default-1',
|
|
||||||
type: 'default',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm'},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges:[
|
|
||||||
{
|
|
||||||
id: 'start-phase-1',
|
|
||||||
source: 'start',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1-default-1',
|
|
||||||
source: 'phase-1',
|
|
||||||
target: 'default-1',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
};
|
|
||||||
const phaseHasNoOutgoingConnections : FlowState = {
|
|
||||||
name: "phaseHasNoOutgoingConnections",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
type: 'start',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {label: 'start'}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges:[
|
|
||||||
{
|
|
||||||
id: 'start-phase-1',
|
|
||||||
source: 'start',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
};
|
|
||||||
const phaseHasTooManyOutgoingConnections : FlowState = {
|
|
||||||
name: "phaseHasTooManyOutgoingConnections",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
type: 'start',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {label: 'start'}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges:[
|
|
||||||
{
|
|
||||||
id: 'start-phase-1',
|
|
||||||
source: 'start',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1-phase-2',
|
|
||||||
source: 'phase-1',
|
|
||||||
target: 'phase-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1-end',
|
|
||||||
source: 'phase-1',
|
|
||||||
target: 'end',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2-end',
|
|
||||||
source: 'phase-2',
|
|
||||||
target: 'end',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Graph Reducer Tests', () => {
|
|
||||||
describe('defaultGraphPreprocessor', () => {
|
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
state: onlyOnePhase,
|
|
||||||
expected: [
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
connectedNorms: [],
|
|
||||||
connectedGoals: [],
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlyThreePhases,
|
|
||||||
expected: [
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'phase-2',
|
|
||||||
connectedNorms: [],
|
|
||||||
connectedGoals: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'phase-3',
|
|
||||||
connectedNorms: [],
|
|
||||||
connectedGoals: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-3',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 3},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
connectedNorms: [],
|
|
||||||
connectedGoals: [],
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlySingleEdgeNorms,
|
|
||||||
expected: [
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'phase-2',
|
|
||||||
connectedNorms: [],
|
|
||||||
connectedGoals: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'phase-3',
|
|
||||||
connectedNorms: [{
|
|
||||||
id: 'norm-1',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
}],
|
|
||||||
connectedGoals: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-3',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 3},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
connectedNorms: [{
|
|
||||||
id: 'norm-2',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
}],
|
|
||||||
connectedGoals: [],
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: multiEdgeNorms,
|
|
||||||
expected: [
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'phase-2',
|
|
||||||
connectedNorms: [{
|
|
||||||
id: 'norm-3',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
}],
|
|
||||||
connectedGoals: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'phase-3',
|
|
||||||
connectedNorms: [{
|
|
||||||
id: 'norm-1',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
}],
|
|
||||||
connectedGoals: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-3',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 3},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
connectedNorms: [{
|
|
||||||
id: 'norm-1',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
}],
|
|
||||||
connectedGoals: [],
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlyStartEnd,
|
|
||||||
expected: [],
|
|
||||||
}
|
|
||||||
])(`tests state: $state.name`, ({state, expected}) => {
|
|
||||||
const output = defaultGraphPreprocessor(state.nodes, state.edges);
|
|
||||||
expect(output).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
describe("orderPhases", () => {
|
});
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
state: onlyOnePhase,
|
|
||||||
expected: {
|
|
||||||
phaseNodes: [{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
}],
|
|
||||||
connections: new Map<string,string>([["phase-1","end"]])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlyThreePhases,
|
|
||||||
expected: {
|
|
||||||
phaseNodes: [
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 3},
|
|
||||||
}],
|
|
||||||
connections: new Map<string,string>([
|
|
||||||
["phase-1","phase-2"],
|
|
||||||
["phase-2","phase-3"],
|
|
||||||
["phase-3","end"]
|
|
||||||
])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlySingleEdgeNorms,
|
|
||||||
expected: {
|
|
||||||
phaseNodes: [
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 3},
|
|
||||||
}],
|
|
||||||
connections: new Map<string,string>([
|
|
||||||
["phase-1","phase-2"],
|
|
||||||
["phase-2","phase-3"],
|
|
||||||
["phase-3","end"]
|
|
||||||
])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlyStartEnd,
|
|
||||||
expected: {
|
|
||||||
phaseNodes: [],
|
|
||||||
connections: new Map<string,string>()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])(`tests state: $state.name`, ({state, expected}) => {
|
|
||||||
const output = orderPhases(state.nodes, state.edges);
|
|
||||||
expect(output.phaseNodes).toEqual(expected.phaseNodes);
|
|
||||||
expect(output.connections).toEqual(expected.connections);
|
|
||||||
});
|
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
state: phaseConnectsToInvalidNodeType,
|
|
||||||
expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: phaseHasNoOutgoingConnections,
|
|
||||||
expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: phaseHasTooManyOutgoingConnections,
|
|
||||||
expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets')
|
|
||||||
}
|
|
||||||
])(`tests erroneous state: $state.name`, ({state, expected}) => {
|
|
||||||
const testForError = () => {
|
|
||||||
orderPhases(state.nodes, state.edges);
|
|
||||||
};
|
|
||||||
expect(testForError).toThrow(expected);
|
|
||||||
})
|
|
||||||
})
|
|
||||||
describe("defaultPhaseReducer", () => {
|
|
||||||
test("phaseReducer handles empty norms and goals without failing", () => {
|
|
||||||
const input : PreparedPhase = {
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
connectedNorms: [],
|
|
||||||
connectedGoals: [],
|
|
||||||
}
|
|
||||||
const output = defaultPhaseReducer(input);
|
|
||||||
expect(output).toEqual({
|
|
||||||
id: 'phase-1',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
phaseData: {
|
|
||||||
norms: [],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
test("defaultNormReducer reduces norms correctly", () => {
|
|
||||||
const input : PreparedPhase = {
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
connectedNorms: [{
|
|
||||||
id: 'norm-1',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
}],
|
|
||||||
connectedGoals: [],
|
|
||||||
}
|
|
||||||
const output = defaultPhaseReducer(input);
|
|
||||||
expect(output).toEqual({
|
|
||||||
id: 'phase-1',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
phaseData: {
|
|
||||||
norms: [{
|
|
||||||
id: 'norm-1',
|
|
||||||
name: 'Generic Norm',
|
|
||||||
value: "generic"
|
|
||||||
}],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
test("defaultGoalReducer reduces goals correctly", () => {
|
|
||||||
const input : PreparedPhase = {
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
connectedNorms: [],
|
|
||||||
connectedGoals: [{
|
|
||||||
id: 'goal-1',
|
|
||||||
type: 'goal',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Goal', value: "generic"},
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
const output = defaultPhaseReducer(input);
|
|
||||||
expect(output).toEqual({
|
|
||||||
id: 'phase-1',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
phaseData: {
|
|
||||||
norms: [],
|
|
||||||
goals: [{
|
|
||||||
id: 'goal-1',
|
|
||||||
name: 'Generic Goal',
|
|
||||||
value: "generic"
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
describe("GraphReducer", () => {
|
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
state: onlyOnePhase,
|
|
||||||
expected: [
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
phaseData: {
|
|
||||||
norms: [],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlyThreePhases,
|
|
||||||
expected: [
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'phase-2',
|
|
||||||
phaseData: {
|
|
||||||
norms: [],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'phase-3',
|
|
||||||
phaseData: {
|
|
||||||
norms: [],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
phaseData: {
|
|
||||||
norms: [],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlySingleEdgeNorms,
|
|
||||||
expected: [
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'phase-2',
|
|
||||||
phaseData: {
|
|
||||||
norms: [],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'phase-3',
|
|
||||||
phaseData: {
|
|
||||||
norms: [
|
|
||||||
{
|
|
||||||
id: 'norm-1',
|
|
||||||
name: 'Generic Norm',
|
|
||||||
value: "generic"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
phaseData: {
|
|
||||||
norms: [{
|
|
||||||
id: 'norm-2',
|
|
||||||
name: 'Generic Norm',
|
|
||||||
value: "generic"
|
|
||||||
}],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: multiEdgeNorms,
|
|
||||||
expected: [
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'phase-2',
|
|
||||||
phaseData: {
|
|
||||||
norms: [{
|
|
||||||
id: 'norm-3',
|
|
||||||
name: 'Generic Norm',
|
|
||||||
value: "generic"
|
|
||||||
}],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'phase-3',
|
|
||||||
phaseData: {
|
|
||||||
norms: [
|
|
||||||
{
|
|
||||||
id: 'norm-1',
|
|
||||||
name: 'Generic Norm',
|
|
||||||
value: "generic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2',
|
|
||||||
name: 'Generic Norm',
|
|
||||||
value: "generic"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
phaseData: {
|
|
||||||
norms: [{
|
|
||||||
id: 'norm-1',
|
|
||||||
name: 'Generic Norm',
|
|
||||||
value: "generic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2',
|
|
||||||
name: 'Generic Norm',
|
|
||||||
value: "generic"
|
|
||||||
}],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlyStartEnd,
|
|
||||||
expected: [],
|
|
||||||
}
|
|
||||||
])(`tests state: $state.name`, ({state, expected}) => {
|
|
||||||
useFlowStore.setState({nodes: state.nodes, edges: state.edges});
|
|
||||||
const output = graphReducer(); // uses default reducers
|
|
||||||
expect(output).toEqual(expected);
|
|
||||||
})
|
|
||||||
// we run the test for correct error handling for the entire graph reducer as well,
|
|
||||||
// to make sure no errors occur before we intend to handle the errors ourselves
|
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
state: phaseConnectsToInvalidNodeType,
|
|
||||||
expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: phaseHasNoOutgoingConnections,
|
|
||||||
expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: phaseHasTooManyOutgoingConnections,
|
|
||||||
expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets')
|
|
||||||
}
|
|
||||||
])(`tests erroneous state: $state.name`, ({state, expected}) => {
|
|
||||||
useFlowStore.setState({nodes: state.nodes, edges: state.edges});
|
|
||||||
const testForError = () => {
|
|
||||||
graphReducer();
|
|
||||||
};
|
|
||||||
expect(testForError).toThrow(expected);
|
|
||||||
})
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,33 +1,5 @@
|
|||||||
import { mockReactFlow } from '../../../../setupFlowTests.ts';
|
describe('Not implemented', () => {
|
||||||
import {act} from "@testing-library/react";
|
test('nothing yet', () => {
|
||||||
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
expect(true)
|
||||||
import {addNode} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx";
|
});
|
||||||
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
mockReactFlow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Drag-and-Drop sidebar', () => {
|
|
||||||
test.each(['phase', 'phase'])('new nodes get added correctly', (nodeType: string) => {
|
|
||||||
act(()=> {
|
|
||||||
addNode(nodeType, {x:100, y:100});
|
|
||||||
})
|
|
||||||
const updatedState = useFlowStore.getState();
|
|
||||||
expect(updatedState.nodes.length).toBe(1);
|
|
||||||
expect(updatedState.nodes[0].type).toBe(nodeType);
|
|
||||||
});
|
|
||||||
test.each(['phase', 'norm'])('new nodes get correct Id', (nodeType) => {
|
|
||||||
act(()=> {
|
|
||||||
addNode(nodeType, {x:100, y:100});
|
|
||||||
addNode(nodeType, {x:100, y:100});
|
|
||||||
})
|
|
||||||
const updatedState = useFlowStore.getState();
|
|
||||||
expect(updatedState.nodes.length).toBe(2);
|
|
||||||
expect(updatedState.nodes[0].id).toBe(`${nodeType}-1`);
|
|
||||||
expect(updatedState.nodes[1].id).toBe(`${nodeType}-2`);
|
|
||||||
});
|
|
||||||
test('throws error on unexpected node type', () => {
|
|
||||||
expect(() => addNode('I do not Exist', {x:100, y:100})).toThrow("Node I do not Exist not found");
|
|
||||||
})
|
|
||||||
});
|
|
||||||
156
test/utils/cellStore.test.tsx
Normal file
156
test/utils/cellStore.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import {render, screen, act} from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import {type Cell, cell, useCell} from "../../src/utils/cellStore.ts";
|
||||||
|
|
||||||
|
describe("cell store (unit)", () => {
|
||||||
|
it("returns initial value with get()", () => {
|
||||||
|
const c = cell(123);
|
||||||
|
expect(c.get()).toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates value with set(next)", () => {
|
||||||
|
const c = cell("a");
|
||||||
|
c.set("b");
|
||||||
|
expect(c.get()).toBe("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives previous value in set(updater)", () => {
|
||||||
|
const c = cell(1);
|
||||||
|
c.set((prev) => prev + 2);
|
||||||
|
expect(c.get()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls subscribe callback on set", () => {
|
||||||
|
const c = cell(0);
|
||||||
|
const cb = jest.fn();
|
||||||
|
const unsub = c.subscribe(cb);
|
||||||
|
|
||||||
|
c.set(1);
|
||||||
|
c.set(2);
|
||||||
|
|
||||||
|
expect(cb).toHaveBeenCalledTimes(2);
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops notifications when unsubscribing", () => {
|
||||||
|
const c = cell(0);
|
||||||
|
const cb = jest.fn();
|
||||||
|
const unsub = c.subscribe(cb);
|
||||||
|
|
||||||
|
c.set(1);
|
||||||
|
unsub();
|
||||||
|
c.set(2);
|
||||||
|
|
||||||
|
expect(cb).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates multiple listeners", () => {
|
||||||
|
const c = cell("x");
|
||||||
|
const a = jest.fn();
|
||||||
|
const b = jest.fn();
|
||||||
|
const ua = c.subscribe(a);
|
||||||
|
const ub = c.subscribe(b);
|
||||||
|
|
||||||
|
c.set("y");
|
||||||
|
expect(a).toHaveBeenCalledTimes(1);
|
||||||
|
expect(b).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
ua();
|
||||||
|
ub();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cell store (integration)", () => {
|
||||||
|
function View({c, label}: { c: Cell<any>; label: string }) {
|
||||||
|
const v = useCell(c);
|
||||||
|
// count renders to verify re-render behavior
|
||||||
|
(View as any).__renders = ((View as any).__renders ?? 0) + 1;
|
||||||
|
return <div data-testid={label}>{String(v)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reads initial value and updates on set", () => {
|
||||||
|
const c = cell("hello");
|
||||||
|
|
||||||
|
render(<View c={c} label="value"/>);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("value")).toHaveTextContent("hello");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
c.set("world");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("value")).toHaveTextContent("world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers one re-render with set", () => {
|
||||||
|
const c = cell(1);
|
||||||
|
(View as any).__renders = 0;
|
||||||
|
|
||||||
|
render(<View c={c} label="num"/>);
|
||||||
|
|
||||||
|
const rendersAfterMount = (View as any).__renders;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
c.set((prev: number) => prev + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// exactly one extra render from the update
|
||||||
|
expect((View as any).__renders).toBe(rendersAfterMount + 1);
|
||||||
|
expect(screen.getByTestId("num")).toHaveTextContent("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unsubscribes on unmount (no errors on later sets)", () => {
|
||||||
|
const c = cell("a");
|
||||||
|
|
||||||
|
const {unmount} = render(<View c={c} label="value"/>);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// should not throw even though there was a subscriber
|
||||||
|
expect(() =>
|
||||||
|
act(() => {
|
||||||
|
c.set("b");
|
||||||
|
})
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only re-renders components that use the cell", () => {
|
||||||
|
const a = cell("A");
|
||||||
|
const b = cell("B");
|
||||||
|
|
||||||
|
let rendersA = 0;
|
||||||
|
let rendersB = 0;
|
||||||
|
|
||||||
|
function A() {
|
||||||
|
const v = useCell(a);
|
||||||
|
rendersA++;
|
||||||
|
return <div data-testid="A">{v}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function B() {
|
||||||
|
const v = useCell(b);
|
||||||
|
rendersB++;
|
||||||
|
return <div data-testid="B">{v}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<A/>
|
||||||
|
<B/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rendersAAfterMount = rendersA;
|
||||||
|
const rendersBAfterMount = rendersB;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
a.set("A2"); // only A should update
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("A")).toHaveTextContent("A2");
|
||||||
|
expect(screen.getByTestId("B")).toHaveTextContent("B");
|
||||||
|
|
||||||
|
expect(rendersA).toBe(rendersAAfterMount + 1);
|
||||||
|
expect(rendersB).toBe(rendersBAfterMount); // unchanged
|
||||||
|
});
|
||||||
|
});
|
||||||
22
test/utils/duplicateIndices.test.ts
Normal file
22
test/utils/duplicateIndices.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import duplicateIndices from "../../src/utils/duplicateIndices.ts";
|
||||||
|
|
||||||
|
describe("duplicateIndices (unit)", () => {
|
||||||
|
it("returns an empty array for empty input", () => {
|
||||||
|
expect(duplicateIndices<number>([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array when no duplicates exist", () => {
|
||||||
|
expect(duplicateIndices([1, 2, 3, 4])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all positions for every duplicated value", () => {
|
||||||
|
const result = duplicateIndices(["a", "b", "a", "c", "b", "b"]);
|
||||||
|
expect(result.sort()).toEqual([0, 1, 2, 4, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only treats identical references as duplicate objects", () => {
|
||||||
|
const shared = { v: 1 };
|
||||||
|
const result = duplicateIndices([shared, { v: 1 }, shared, shared]);
|
||||||
|
expect(result.sort()).toEqual([0, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
test/utils/formatDuration.test.ts
Normal file
53
test/utils/formatDuration.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import formatDuration from "../../src/utils/formatDuration.ts";
|
||||||
|
|
||||||
|
describe("formatting durations (unit)", () => {
|
||||||
|
it("does one millisecond", () => {
|
||||||
|
const result = formatDuration(1);
|
||||||
|
expect(result).toBe("00:00:00.001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does one-hundred twenty-three milliseconds", () => {
|
||||||
|
const result = formatDuration(123);
|
||||||
|
expect(result).toBe("00:00:00.123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does one second", () => {
|
||||||
|
const result = formatDuration(1*1000);
|
||||||
|
expect(result).toBe("00:00:01.000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does thirteen seconds", () => {
|
||||||
|
const result = formatDuration(13*1000);
|
||||||
|
expect(result).toBe("00:00:13.000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does one minute", () => {
|
||||||
|
const result = formatDuration(60*1000);
|
||||||
|
expect(result).toBe("00:01:00.000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does thirteen minutes", () => {
|
||||||
|
const result = formatDuration(13*60*1000);
|
||||||
|
expect(result).toBe("00:13:00.000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does one hour", () => {
|
||||||
|
const result = formatDuration(60*60*1000);
|
||||||
|
expect(result).toBe("01:00:00.000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does thirteen hours", () => {
|
||||||
|
const result = formatDuration(13*60*60*1000);
|
||||||
|
expect(result).toBe("13:00:00.000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does negative one millisecond", () => {
|
||||||
|
const result = formatDuration(-1);
|
||||||
|
expect(result).toBe("-00:00:00.001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does large negative durations", () => {
|
||||||
|
const result = formatDuration(-(123*60*60*1000 + 59*60*1000 + 59*1000 + 123));
|
||||||
|
expect(result).toBe("-123:59:59.123");
|
||||||
|
});
|
||||||
|
});
|
||||||
81
test/utils/priorityFiltering.test.ts
Normal file
81
test/utils/priorityFiltering.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../src/utils/priorityFiltering";
|
||||||
|
|
||||||
|
const makePred = <T>(priority: number, fn: (el: T) => boolean | null): PriorityFilterPredicate<T> => ({
|
||||||
|
priority,
|
||||||
|
predicate: jest.fn(fn),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyPriorityPredicates (unit)", () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it("returns true when there are no predicates", () => {
|
||||||
|
expect(applyPriorityPredicates(123, [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behaves like a normal predicate with only one predicate", () => {
|
||||||
|
const even = makePred<number>(1, (n) => n % 2 === 0);
|
||||||
|
expect(applyPriorityPredicates(2, [even])).toBe(true);
|
||||||
|
expect(applyPriorityPredicates(3, [even])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("determines the result only listening to the highest priority predicates", () => {
|
||||||
|
const lowFail = makePred<number>(1, (_) => false);
|
||||||
|
const lowPass = makePred<number>(1, (_) => true);
|
||||||
|
const highPass = makePred<number>(10, (n) => n > 0);
|
||||||
|
const highFail = makePred<number>(10, (n) => n < 0);
|
||||||
|
|
||||||
|
expect(applyPriorityPredicates(5, [lowFail, highPass])).toBe(true);
|
||||||
|
expect(applyPriorityPredicates(5, [lowPass, highFail])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses all predicates at the highest priority", () => {
|
||||||
|
const high1 = makePred<number>(5, (n) => n % 2 === 0);
|
||||||
|
const high2 = makePred<number>(5, (n) => n > 2);
|
||||||
|
expect(applyPriorityPredicates(4, [high1, high2])).toBe(true);
|
||||||
|
expect(applyPriorityPredicates(2, [high1, high2])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is order independent (later higher positive clears earlier lower negative)", () => {
|
||||||
|
const lowFalse = makePred<number>(1, (_) => false);
|
||||||
|
const highTrue = makePred<number>(9, (n) => n === 7);
|
||||||
|
|
||||||
|
// Higher priority appears later → should reset and decide by highest only
|
||||||
|
expect(applyPriorityPredicates(7, [lowFalse, highTrue])).toBe(true);
|
||||||
|
|
||||||
|
// Same set, different order → same result
|
||||||
|
expect(applyPriorityPredicates(7, [highTrue, lowFalse])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles many priorities: only max matters", () => {
|
||||||
|
const p1 = makePred<number>(1, (_) => false);
|
||||||
|
const p3 = makePred<number>(3, (_) => false);
|
||||||
|
const p5 = makePred<number>(5, (n) => n > 0);
|
||||||
|
expect(applyPriorityPredicates(1, [p1, p3, p5])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips predicates that return null", () => {
|
||||||
|
const high = makePred<number>(10, (n) => n === 0 ? true : null);
|
||||||
|
const low = makePred<number>(1, (_) => false);
|
||||||
|
expect(applyPriorityPredicates(0, [high, low])).toBe(true);
|
||||||
|
expect(applyPriorityPredicates(1, [high, low])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("(integration) filter with applyPriorityPredicates", () => {
|
||||||
|
it("filters an array using only highest-priority predicates", () => {
|
||||||
|
const elems = [1, 2, 3, 4, 5];
|
||||||
|
const low = makePred<number>(0, (_) => false);
|
||||||
|
const high1 = makePred<number>(5, (n) => n % 2 === 0);
|
||||||
|
const high2 = makePred<number>(5, (n) => n > 2);
|
||||||
|
const result = elems.filter((e) => applyPriorityPredicates(e, [low, high1, high2]));
|
||||||
|
expect(result).toEqual([4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters an array using only highest-priority predicates", () => {
|
||||||
|
const elems = [1, 2, 3, 4, 5];
|
||||||
|
const low = makePred<number>(0, (_) => false);
|
||||||
|
const high = makePred<number>(5, (n) => n === 3 ? true : null);
|
||||||
|
const result = elems.filter((e) => applyPriorityPredicates(e, [low, high]));
|
||||||
|
expect(result).toEqual([3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user