From 10522b71c368301a6b8949def72214dfbb6b7b4b Mon Sep 17 00:00:00 2001
From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com>
Date: Wed, 1 Oct 2025 22:56:03 +0200
Subject: [PATCH 01/17] chore: combined some branches, improved style
This demo branch contains code from multiple different branches. DO NOT MERGE this branch because it looks like I'm the author of all these changes.
---
package-lock.json | 241 +++++++++++-
package.json | 2 +
src/App.css | 109 ++++--
src/App.tsx | 20 +-
src/assets/data.ts | 361 ++++++++++++++++++
src/pages/Home/Home.module.css | 6 +
src/pages/Home/Home.tsx | 45 +--
src/pages/Logging/Logging.module.css | 17 +
src/pages/Logging/Logging.tsx | 78 ++++
src/pages/ServerComms/ServerComms.css | 0
src/pages/ServerComms/ServerComms.tsx | 86 +++--
src/pages/VisProgPage/VisProg.tsx | 11 +
src/visualProgrammingUI/VisProgUI.css | 7 +
src/visualProgrammingUI/VisProgUI.tsx | 132 +++++++
.../components/DragDropSidebar.tsx | 141 +++++++
.../components/NodeDefinitions.tsx | 111 ++++++
16 files changed, 1251 insertions(+), 116 deletions(-)
create mode 100644 src/assets/data.ts
create mode 100644 src/pages/Logging/Logging.module.css
create mode 100644 src/pages/Logging/Logging.tsx
delete mode 100644 src/pages/ServerComms/ServerComms.css
create mode 100644 src/pages/VisProgPage/VisProg.tsx
create mode 100644 src/visualProgrammingUI/VisProgUI.css
create mode 100644 src/visualProgrammingUI/VisProgUI.tsx
create mode 100644 src/visualProgrammingUI/components/DragDropSidebar.tsx
create mode 100644 src/visualProgrammingUI/components/NodeDefinitions.tsx
diff --git a/package-lock.json b/package-lock.json
index 0b92f5c..db54d49 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,8 @@
"name": "pepperplus-ui",
"version": "0.0.0",
"dependencies": {
+ "@neodrag/react": "^2.3.1",
+ "@xyflow/react": "^12.8.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.9.3"
@@ -1006,6 +1008,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@neodrag/react": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/@neodrag/react/-/react-2.3.1.tgz",
+ "integrity": "sha512-mOVefo3mFmaVLs9PB5F5wMXnnclG81qjOaPHyf8YZUnw/Ciz0pAqyJDwDJk0nPTIK5I2x1JdjXSchGNdCxZNRQ==",
+ "license": "MIT"
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1404,6 +1412,55 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1422,7 +1479,7 @@
"version": "19.1.13",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
"integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -1730,6 +1787,38 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@xyflow/react": {
+ "version": "12.8.6",
+ "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz",
+ "integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==",
+ "license": "MIT",
+ "dependencies": {
+ "@xyflow/system": "0.0.70",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.0"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@xyflow/system": {
+ "version": "0.0.70",
+ "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz",
+ "integrity": "sha512-PpC//u9zxdjj0tfTSmZrg3+sRbTz6kop/Amky44U2Dl51sxzDTIUfXMwETOYpmr2dqICWXBIJwXL2a9QWtX2XA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-drag": "^3.0.7",
+ "@types/d3-interpolate": "^3.0.4",
+ "@types/d3-selection": "^3.0.10",
+ "@types/d3-transition": "^3.0.8",
+ "@types/d3-zoom": "^3.0.8",
+ "d3-drag": "^3.0.0",
+ "d3-interpolate": "^3.0.1",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0"
+ }
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -1916,6 +2005,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+ "license": "MIT"
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1978,9 +2073,114 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3287,6 +3487,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
+ "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/vite": {
"version": "7.1.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz",
@@ -3438,6 +3647,34 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 6070bd8..252cf4f 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,8 @@
"preview": "vite preview"
},
"dependencies": {
+ "@neodrag/react": "^2.3.1",
+ "@xyflow/react": "^12.8.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.9.3"
diff --git a/src/App.css b/src/App.css
index dcb46cf..0c641e7 100644
--- a/src/App.css
+++ b/src/App.css
@@ -5,18 +5,6 @@
text-align: center;
}
-.logo {
- height: 6em;
- padding: 1.5em;
- will-change: filter;
- transition: filter 300ms;
-}
-.logo:hover {
- filter: drop-shadow(0 0 2em #646cffaa);
-}
-.logo.react:hover {
- filter: drop-shadow(0 0 2em #61dafbaa);
-}
.logopepper {
@@ -32,27 +20,21 @@
filter: drop-shadow(0 0 10em #4eff14aa);
}
-@keyframes logo-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-
@keyframes logo-pepper-spin {
- from {
- transform: rotate(-20deg);
+ 0% {
+ transform: rotate(0);
}
- to {
+ 25% {
transform: rotate(20deg);
}
+ 75% {
+ transform: rotate(-20deg);
+ }
+ 100% {
+ transform: rotate(0);
+ }
}
-
-
@keyframes logo-pepper-scale {
from {
transform: scale(1,1);
@@ -63,19 +45,13 @@
}
@media (prefers-reduced-motion: no-preference) {
- a:nth-of-type(2) .logo {
- animation: logo-spin infinite 20s linear;
+ .logopepper:hover {
+ animation: logo-pepper-spin infinite 1s linear;
}
}
@media (prefers-reduced-motion: no-preference) {
- .logopepper {
- animation: logo-pepper-spin infinite 1s linear alternate;
- }
-}
-
-@media (prefers-reduced-motion: no-preference) {
- .logoPepperScaling {
+ .logoPepperScaling:hover {
animation: logo-pepper-scale infinite 1s linear alternate;
}
}
@@ -113,3 +89,64 @@ button.movePage.right{
button.movePage:hover{
background-color: rgb(0, 176, 176);
}
+
+
+
+
+
+.flex-row {
+ display: flex;
+ flex-direction: row;
+}
+.flex-col {
+ display: flex;
+ flex-direction: column;
+}
+
+.flex-1 {
+ flex: 1;
+}
+
+.flex-wrap {
+ flex-wrap: wrap;
+}
+
+.align-center {
+ align-items: center;
+}
+.justify-center {
+ justify-content: center;
+}
+.justify-between {
+ justify-content: space-between;
+}
+
+.gap-sm {
+ gap: .25rem;
+}
+.gap-md {
+ gap: .5rem;
+}
+.gap-lg {
+ gap: 1rem;
+}
+
+.padding-sm {
+ padding: .25rem;
+}
+.padding-md {
+ padding: .5rem;
+}
+.padding-lg {
+ padding: 1rem;
+}
+
+.round-sm {
+ border-radius: .25rem;
+}
+.round-md {
+ border-radius: .5rem;
+}
+.round-lg {
+ border-radius: 1rem;
+}
\ No newline at end of file
diff --git a/src/App.tsx b/src/App.tsx
index 757e769..0833bdb 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,17 +1,25 @@
-import { Routes, Route } from 'react-router'
+import { Routes, Route, Link } from 'react-router'
import './App.css'
import TemplatePage from './pages/TemplatePage/Template.tsx'
import Home from './pages/Home/Home.tsx'
import ServerComms from './pages/ServerComms/ServerComms.tsx'
+import Logging from './pages/Logging/Logging.tsx'
+import VisProg from "./pages/VisProgPage/VisProg.tsx";
function App(){
return (
-
- } />
- } />
- } />
-
+
+ {/* Should not use inline styles like this */}
+ Home
+
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
)
}
diff --git a/src/assets/data.ts b/src/assets/data.ts
new file mode 100644
index 0000000..c1eacfb
--- /dev/null
+++ b/src/assets/data.ts
@@ -0,0 +1,361 @@
+export const DATA: LogEntry[] = [
+ {
+ id: "1",
+ timestamp: "2025-10-01T12:00:00Z",
+ level: "info",
+ msg: "User said: Hello, Pepper!",
+ type: "speech",
+ },
+ {
+ id: "2",
+ timestamp: "2025-10-01T12:00:05Z",
+ level: "debug",
+ msg: "Proximity sensor value: 0.85",
+ type: "sensor",
+ },
+ {
+ id: "3",
+ timestamp: "2025-10-01T12:00:10Z",
+ level: "warn",
+ msg: "Battery level low: 15%",
+ type: "system",
+ },
+ {
+ id: "4",
+ timestamp: "2025-10-01T12:00:15Z",
+ level: "info",
+ msg: "User requested weather update.",
+ type: "speech",
+ },
+ {
+ id: "5",
+ timestamp: "2025-10-01T12:00:20Z",
+ level: "debug",
+ msg: "Microphone activated.",
+ type: "system",
+ },
+ {
+ id: "6",
+ timestamp: "2025-10-01T12:00:25Z",
+ level: "warn",
+ msg: "Obstacle detected in front.",
+ type: "sensor",
+ },
+ {
+ id: "7",
+ timestamp: "2025-10-01T12:00:30Z",
+ level: "info",
+ msg: "User said: Thank you!",
+ type: "speech",
+ },
+ {
+ id: "8",
+ timestamp: "2025-10-01T12:00:35Z",
+ level: "debug",
+ msg: "Network latency: 120ms",
+ type: "system",
+ },
+ {
+ id: "9",
+ timestamp: "2025-10-01T12:00:40Z",
+ level: "warn",
+ msg: "High CPU usage detected.",
+ type: "system",
+ },
+ {
+ id: "10",
+ timestamp: "2025-10-01T12:00:45Z",
+ level: "info",
+ msg: "User started a new session.",
+ type: "system",
+ },
+ {
+ id: "11",
+ timestamp: "2025-10-01T12:01:00Z",
+ level: "info",
+ msg: "User asked: What's the weather?",
+ type: "speech",
+ },
+ {
+ id: "12",
+ timestamp: "2025-10-01T12:01:05Z",
+ level: "debug",
+ msg: "Camera initialized.",
+ type: "system",
+ },
+ {
+ id: "13",
+ timestamp: "2025-10-01T12:01:10Z",
+ level: "warn",
+ msg: "Temperature sensor disconnected.",
+ type: "sensor",
+ },
+ {
+ id: "14",
+ timestamp: "2025-10-01T12:01:15Z",
+ level: "info",
+ msg: "User said: Play some music.",
+ type: "speech",
+ },
+ {
+ id: "15",
+ timestamp: "2025-10-01T12:01:20Z",
+ level: "debug",
+ msg: "Audio output device selected: Speaker.",
+ type: "system",
+ },
+ {
+ id: "16",
+ timestamp: "2025-10-01T12:01:25Z",
+ level: "warn",
+ msg: "Low light detected in room.",
+ type: "sensor",
+ },
+ {
+ id: "17",
+ timestamp: "2025-10-01T12:01:30Z",
+ level: "info",
+ msg: "User said: Turn on the lights.",
+ type: "speech",
+ },
+ {
+ id: "18",
+ timestamp: "2025-10-01T12:01:35Z",
+ level: "debug",
+ msg: "Light control signal sent.",
+ type: "system",
+ },
+ {
+ id: "19",
+ timestamp: "2025-10-01T12:01:40Z",
+ level: "warn",
+ msg: "Light bulb not responding.",
+ type: "system",
+ },
+ {
+ id: "20",
+ timestamp: "2025-10-01T12:01:45Z",
+ level: "info",
+ msg: "User said: Good night.",
+ type: "speech",
+ },
+ {
+ id: "21",
+ timestamp: "2025-10-01T12:02:00Z",
+ level: "info",
+ msg: "User asked: What's the time?",
+ type: "speech",
+ },
+ {
+ id: "22",
+ timestamp: "2025-10-01T12:02:05Z",
+ level: "debug",
+ msg: "Time module loaded.",
+ type: "system",
+ },
+ {
+ id: "23",
+ timestamp: "2025-10-01T12:02:10Z",
+ level: "warn",
+ msg: "WiFi signal weak.",
+ type: "system",
+ },
+ {
+ id: "24",
+ timestamp: "2025-10-01T12:02:15Z",
+ level: "info",
+ msg: "User said: Set an alarm for 7 AM.",
+ type: "speech",
+ },
+ {
+ id: "25",
+ timestamp: "2025-10-01T12:02:20Z",
+ level: "debug",
+ msg: "Alarm scheduled for 7:00 AM.",
+ type: "system",
+ },
+ {
+ id: "26",
+ timestamp: "2025-10-01T12:02:25Z",
+ level: "warn",
+ msg: "Alarm module not responding.",
+ type: "system",
+ },
+ {
+ id: "27",
+ timestamp: "2025-10-01T12:02:30Z",
+ level: "info",
+ msg: "User said: Cancel the alarm.",
+ type: "speech",
+ },
+ {
+ id: "28",
+ timestamp: "2025-10-01T12:02:35Z",
+ level: "debug",
+ msg: "Alarm cancellation requested.",
+ type: "system",
+ },
+ {
+ id: "29",
+ timestamp: "2025-10-01T12:02:40Z",
+ level: "warn",
+ msg: "Alarm cancellation failed.",
+ type: "system",
+ },
+ {
+ id: "30",
+ timestamp: "2025-10-01T12:02:45Z",
+ level: "info",
+ msg: "User said: Open the window.",
+ type: "speech",
+ },
+ {
+ id: "31",
+ timestamp: "2025-10-01T12:03:00Z",
+ level: "info",
+ msg: "User asked: What's on my calendar?",
+ type: "speech",
+ },
+ {
+ id: "32",
+ timestamp: "2025-10-01T12:03:05Z",
+ level: "debug",
+ msg: "Calendar module loaded.",
+ type: "system",
+ },
+ {
+ id: "33",
+ timestamp: "2025-10-01T12:03:10Z",
+ level: "warn",
+ msg: "Calendar sync failed.",
+ type: "system",
+ },
+ {
+ id: "34",
+ timestamp: "2025-10-01T12:03:15Z",
+ level: "info",
+ msg: "User said: Remind me to call John.",
+ type: "speech",
+ },
+ {
+ id: "35",
+ timestamp: "2025-10-01T12:03:20Z",
+ level: "debug",
+ msg: "Reminder set for John.",
+ type: "system",
+ },
+ {
+ id: "36",
+ timestamp: "2025-10-01T12:03:25Z",
+ level: "warn",
+ msg: "Reminder module not available.",
+ type: "system",
+ },
+ {
+ id: "37",
+ timestamp: "2025-10-01T12:03:30Z",
+ level: "info",
+ msg: "User said: What's the news?",
+ type: "speech",
+ },
+ {
+ id: "38",
+ timestamp: "2025-10-01T12:03:35Z",
+ level: "debug",
+ msg: "News API request sent.",
+ type: "system",
+ },
+ {
+ id: "39",
+ timestamp: "2025-10-01T12:03:40Z",
+ level: "warn",
+ msg: "News API rate limit reached.",
+ type: "system",
+ },
+ {
+ id: "40",
+ timestamp: "2025-10-01T12:03:45Z",
+ level: "info",
+ msg: "User said: Tell me a joke.",
+ type: "speech",
+ },
+ {
+ id: "41",
+ timestamp: "2025-10-01T12:04:00Z",
+ level: "info",
+ msg: "User asked: What's the temperature?",
+ type: "speech",
+ },
+ {
+ id: "42",
+ timestamp: "2025-10-01T12:04:05Z",
+ level: "debug",
+ msg: "Temperature sensor reading: 22°C.",
+ type: "sensor",
+ },
+ {
+ id: "43",
+ timestamp: "2025-10-01T12:04:10Z",
+ level: "warn",
+ msg: "Temperature sensor calibration needed.",
+ type: "sensor",
+ },
+ {
+ id: "44",
+ timestamp: "2025-10-01T12:04:15Z",
+ level: "info",
+ msg: "User said: Start cleaning.",
+ type: "speech",
+ },
+ {
+ id: "45",
+ timestamp: "2025-10-01T12:04:20Z",
+ level: "debug",
+ msg: "Vacuum motor started.",
+ type: "system",
+ },
+ {
+ id: "46",
+ timestamp: "2025-10-01T12:04:25Z",
+ level: "warn",
+ msg: "Vacuum bin full.",
+ type: "system",
+ },
+ {
+ id: "47",
+ timestamp: "2025-10-01T12:04:30Z",
+ level: "info",
+ msg: "User said: Stop cleaning.",
+ type: "speech",
+ },
+ {
+ id: "48",
+ timestamp: "2025-10-01T12:04:35Z",
+ level: "debug",
+ msg: "Vacuum motor stopped.",
+ type: "system",
+ },
+ {
+ id: "49",
+ timestamp: "2025-10-01T12:04:40Z",
+ level: "warn",
+ msg: "Obstacle detected during cleaning.",
+ type: "sensor",
+ },
+ {
+ id: "50",
+ timestamp: "2025-10-01T12:04:45Z",
+ level: "info",
+ msg: "User said: Goodbye!",
+ type: "speech",
+ },
+];
+
+interface LogEntry {
+ id: string;
+ type?: string;
+ timestamp: string;
+ level: "info" | "debug" | "warn";
+ msg?: string;
+}
+
diff --git a/src/pages/Home/Home.module.css b/src/pages/Home/Home.module.css
index aed0f27..d609fe9 100644
--- a/src/pages/Home/Home.module.css
+++ b/src/pages/Home/Home.module.css
@@ -4,3 +4,9 @@
.card {
padding: 2em;
}
+
+.links {
+ display: flex;
+ flex-direction: column;
+ gap: 1em;
+}
\ No newline at end of file
diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx
index 8767db3..582357b 100644
--- a/src/pages/Home/Home.tsx
+++ b/src/pages/Home/Home.tsx
@@ -1,47 +1,20 @@
-//import { useState } from 'react'
import { Link } from 'react-router'
-import reactLogo from '../../assets/react.svg'
-import viteLogo from '../../assets/vite.svg'
import pepperLogo from '../../assets/pepper_transp2_small.svg'
-import style from './Home.module.css'
-import Counter from '../../components/components.tsx'
+import styles from './Home.module.css'
function Home() {
-
-
-
return (
<>
-
-
-
-
-
-
-
-
+
- Vite + React
-
-
-
- {}}>
- Page Cool --{'>'}
-
-
-
-
- Edit src/App.tsx and save to test HMR
-
-
-
- Click on the Vite and React logos to learn more
-
+
+ Robot interaction →
+ Node editor →
+ Logs →
+
>
)
}
diff --git a/src/pages/Logging/Logging.module.css b/src/pages/Logging/Logging.module.css
new file mode 100644
index 0000000..52191c3
--- /dev/null
+++ b/src/pages/Logging/Logging.module.css
@@ -0,0 +1,17 @@
+.DivToScroll{
+ background-color: color-mix(in srgb, canvas, #000 5%);
+ border: 1px solid color-mix(in srgb, canvas, #000 15%);
+ border-radius: 4px 0 4px 0;
+ color: #3B3C3E;
+ font-size: 12px;
+ font-weight: bold;
+ left: -1px;
+ padding: 10px 7px 5px;
+}
+
+.DivWithScroll{
+ height:50vh;
+ width:100vh;
+ overflow:scroll;
+ overflow-x:hidden;
+}
diff --git a/src/pages/Logging/Logging.tsx b/src/pages/Logging/Logging.tsx
new file mode 100644
index 0000000..31945c1
--- /dev/null
+++ b/src/pages/Logging/Logging.tsx
@@ -0,0 +1,78 @@
+import { useState } from 'react';
+import { DATA } from "../../assets/data";
+import styles from './Logging.module.css';
+
+
+// const dataType = DATA as { id: string; level: "debug"|"info"|"warn"|"error"; msg: string; timestamp?: string };
+type Level = "debug" | "info" | "warn" | "error";
+
+// make optional fields optional
+type LogEntry = {
+ id: string;
+ level: Level;
+ timestamp?: string;
+ msg?: string;
+ type?: "speech" | "sensor" | "system" | string;
+
+};
+
+function getLevelColor(level: Level) {
+ switch (level) {
+ case "debug":
+ return "gray";
+ case "info":
+ return "blue";
+ case "warn":
+ return "red";
+ case "error":
+ return "red";
+ default:
+ return "black";
+ }
+}
+
+function Logging() {
+ const [logs, setLogs] = useState([]);
+
+ const logDiv = (
+
+
+ {logs.map((log) => (
+
+
+ [{log.timestamp}]
+
+
+ {log.msg ? log.msg : "No message"}
+
+
+ ({log.level})
+
+
+ ))}
+
+
+ )
+ return (
+ <>
+ Log Screen
+ { logDiv }
+
+ setLogs(DATA)}>
+ Load sample logs
+
+
+
+ >
+ )
+}
+
+export default Logging
diff --git a/src/pages/ServerComms/ServerComms.css b/src/pages/ServerComms/ServerComms.css
deleted file mode 100644
index e69de29..0000000
diff --git a/src/pages/ServerComms/ServerComms.tsx b/src/pages/ServerComms/ServerComms.tsx
index c16ec81..6d15524 100644
--- a/src/pages/ServerComms/ServerComms.tsx
+++ b/src/pages/ServerComms/ServerComms.tsx
@@ -1,15 +1,12 @@
-import { useState, useEffect } from 'react'
-import { Link } from 'react-router'
-//import Counter from '../../components/components.tsx'
+import { useState, useEffect, useRef } from 'react'
-
-//this is your css file where you can style your buttons and such
-//you can still use css parts from App.css, but also overwrite them
-
-function ServerComms() {
+export default function ServerComms() {
const [message, setMessage] = useState('');
- const [sseMessage, setSseMessage] = useState('');
- const [spoken, setSpoken] = useState("");
+
+ const [listening, setListening] = useState(false);
+ const [conversation, setConversation] = useState<{"role": "user" | "assistant", "content": string}[]>([])
+ const conversationRef = useRef(null);
+ const [conversationIndex, setConversationIndex] = useState(0);
const sendMessage = async () => {
try {
@@ -31,22 +28,31 @@ function ServerComms() {
const eventSource = new EventSource("http://localhost:8000/sse");
eventSource.onmessage = (event) => {
- setSseMessage(event.data);
-
try {
const data = JSON.parse(event.data);
- if (data.speech) setSpoken(data.speech);
- } catch {}
+ if ("voice_active" in data) setListening(data.voice_active);
+ if ("speech" in data) setConversation(conversation => [...conversation, {"role": "user", "content": data.speech}]);
+ if ("llm_response" in data) setConversation(conversation => [...conversation, {"role": "assistant", "content": data.llm_response}]);
+ } catch {
+ console.log("Unparsable SSE message:", event.data);
+ }
};
return () => {
eventSource.close();
};
- }, []);
+ }, [conversationIndex]);
+
+ useEffect(() => {
+ if (!conversationRef || !conversationRef.current) return;
+ conversationRef.current.scrollTop = conversationRef.current.scrollHeight;
+ }, [conversation]);
return (
-
-
+
+
Robot interaction
+
Force robot speech
+
e.key === "Enter" && sendMessage().then(() => setMessage(""))}
placeholder="Enter a message"
/>
- Send Message to Backend
+ Speak
-
-
Message from Server (SSE):
-
{sseMessage}
-
-
-
Spoken text (SSE):
-
{spoken}
-
-
-
{/* here you link to the homepage, in App.tsx you can link new pages */}
-
{}}>
- Page {'<'}-- Go Home
-
-
+
+
Conversation
+
Listening {listening ? "🟢" : "🔴"}
+
+ {conversation.map((item) => (
+
{item["content"]}
+ ))}
+
+
+ {
+ setConversationIndex((conversationIndex) => conversationIndex + 1)
+ setConversation([])
+ }}>Reset
+ {
+ setConversationIndex((conversationIndex) => conversationIndex == -1 ? 0 : -1)
+ setConversation([])
+ }}>{conversationIndex == -1 ? "Start" : "Stop"}
+
-
-
);
}
-
-export default ServerComms
\ No newline at end of file
diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx
new file mode 100644
index 0000000..ec0055a
--- /dev/null
+++ b/src/pages/VisProgPage/VisProg.tsx
@@ -0,0 +1,11 @@
+import VisProgUI from "../../visualProgrammingUI/VisProgUI.tsx";
+
+function VisProgPage() {
+ return (
+ <>
+
+ >
+ )
+}
+
+export default VisProgPage
\ No newline at end of file
diff --git a/src/visualProgrammingUI/VisProgUI.css b/src/visualProgrammingUI/VisProgUI.css
new file mode 100644
index 0000000..8d82d09
--- /dev/null
+++ b/src/visualProgrammingUI/VisProgUI.css
@@ -0,0 +1,7 @@
+.default-node {
+ padding: 10px 20px;
+ background-color: canvas;
+ outline-style: solid;
+ border-radius: 5pt;
+ outline-width: 1pt;
+}
\ No newline at end of file
diff --git a/src/visualProgrammingUI/VisProgUI.tsx b/src/visualProgrammingUI/VisProgUI.tsx
new file mode 100644
index 0000000..a2b1e9c
--- /dev/null
+++ b/src/visualProgrammingUI/VisProgUI.tsx
@@ -0,0 +1,132 @@
+import './VisProgUI.css'
+
+import {
+ useCallback,
+ useRef
+} from 'react';
+import {
+ Background,
+ Controls,
+ ReactFlow,
+ ReactFlowProvider,
+ useNodesState,
+ useEdgesState,
+ reconnectEdge,
+ addEdge,
+ MarkerType,
+ type Edge,
+ type Connection,
+} from '@xyflow/react';
+import '@xyflow/react/dist/style.css';
+import {
+ StartNode,
+ EndNode,
+ PhaseNode,
+ NormNode
+} from "./components/NodeDefinitions.tsx";
+
+import { Sidebar } from './components/DragDropSidebar.tsx';
+
+const nodeTypes = {
+ start: StartNode,
+ end: EndNode,
+ phase: PhaseNode,
+ norm: NormNode
+};
+
+const initialNodes = [
+ {
+ id: 'start',
+ type: 'start',
+ position: {x: 0, y: 0},
+ data: {label: 'start'}
+ },
+ {
+ id: 'genericPhase',
+ 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'}
+ }
+];
+const initialEdges = [{id: 'start-end', source: 'start', target: 'end'}];
+
+const defaultEdgeOptions = {
+ type: 'floating',
+ markerEnd: {
+ type: MarkerType.ArrowClosed,
+ color: '#505050',
+ },
+};
+
+const VisProgUI = ()=> {
+ const edgeReconnectSuccessful = useRef(true);
+ const [nodes, , onNodesChange] = useNodesState(initialNodes);
+ const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
+
+ const onConnect = useCallback(
+ (params: Edge | Connection) => setEdges((els) => addEdge(params, els)),
+ [setEdges],
+ );
+
+ const onReconnectStart = useCallback(() => {
+ edgeReconnectSuccessful.current = false;
+ }, []);
+
+ const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => {
+ edgeReconnectSuccessful.current = true;
+ setEdges((els) => reconnectEdge(oldEdge, newConnection, els));
+ }, [setEdges]);
+
+ const onReconnectEnd = useCallback((_: unknown, edge: { id: string; }) => {
+ if (!edgeReconnectSuccessful.current) {
+ setEdges((eds) => eds.filter((e) => e.id !== edge.id));
+ }
+
+ edgeReconnectSuccessful.current = true;
+ }, [setEdges]);
+
+ return (
+
+
+ );
+};
+
+function VisualProgrammingUI(){
+ return (
+
+
+
+ );
+}
+
+export default VisualProgrammingUI;
\ No newline at end of file
diff --git a/src/visualProgrammingUI/components/DragDropSidebar.tsx b/src/visualProgrammingUI/components/DragDropSidebar.tsx
new file mode 100644
index 0000000..b3926d9
--- /dev/null
+++ b/src/visualProgrammingUI/components/DragDropSidebar.tsx
@@ -0,0 +1,141 @@
+import { useDraggable } from '@neodrag/react';
+import {
+ useReactFlow,
+ type XYPosition
+} from '@xyflow/react';
+import {
+ type ReactNode,
+ useCallback,
+ useRef,
+ useState
+} from 'react';
+
+
+// improve later to create better automatic IDs
+let id = 0;
+const getId = () => `dndnode_${id++}`;
+
+
+interface DraggableNodeProps {
+ className?: string;
+ children: ReactNode;
+ nodeType: string;
+ onDrop: (nodeType: string, position: XYPosition) => void;
+}
+
+function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeProps) {
+ const draggableRef = useRef
(null);
+ const [position, setPosition] = useState({ x: 0, y: 0 });
+
+
+ // @ts-ignore
+ useDraggable(draggableRef, {
+ position: position,
+ onDrag: ({ offsetX, offsetY }) => {
+ // Calculate position relative to the viewport
+ setPosition({
+ x: offsetX,
+ y: offsetY,
+ });
+ },
+ onDragEnd: ({ event }) => {
+ setPosition({ x: 0, y: 0 });
+ onDrop(nodeType, {
+ x: event.clientX,
+ y: event.clientY,
+ });
+ },
+ });
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function Sidebar() {
+ const { setNodes, screenToFlowPosition } = useReactFlow();
+
+ const handleNodeDrop = useCallback(
+ (nodeType: string, screenPosition: XYPosition) => {
+ const flow = document.querySelector('.react-flow');
+ const flowRect = flow?.getBoundingClientRect();
+ const isInFlow =
+ flowRect &&
+ screenPosition.x >= flowRect.left &&
+ screenPosition.x <= flowRect.right &&
+ screenPosition.y >= flowRect.top &&
+ screenPosition.y <= flowRect.bottom;
+
+ // Create a new node and add it to the flow
+ if (isInFlow) {
+ const position = screenToFlowPosition(screenPosition);
+
+ const newNode = () => {
+ switch (nodeType) {
+ case "phase":
+ return {
+ id: getId(),
+ type: nodeType,
+ position,
+ data: {label: `"new"`, number: (-1)},
+ };
+ case "start":
+ return {
+ id: getId(),
+ type: nodeType,
+ position,
+ data: {label: `new start node`},
+ };
+ case "end":
+ return {
+ id: getId(),
+ type: nodeType,
+ position,
+ data: {label: `new end node`},
+ };
+ case "norm":
+ return {
+ id: getId(),
+ type: nodeType,
+ position,
+ data: {label: `new norm node`},
+ };
+ default: {
+ return {
+ id: getId(),
+ type: nodeType,
+ position,
+ data: {label: `new default node`},
+ };
+ }
+ }
+ }
+
+ setNodes((nds) => nds.concat(newNode()));
+ }
+ },
+ [setNodes, screenToFlowPosition],
+ );
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/visualProgrammingUI/components/NodeDefinitions.tsx b/src/visualProgrammingUI/components/NodeDefinitions.tsx
new file mode 100644
index 0000000..b4547b2
--- /dev/null
+++ b/src/visualProgrammingUI/components/NodeDefinitions.tsx
@@ -0,0 +1,111 @@
+import {Handle, NodeToolbar, Position, useReactFlow} from '@xyflow/react';
+import '@xyflow/react/dist/style.css';
+import '../VisProgUI.css';
+
+// Datatypes for NodeTypes
+
+type defaultNodeData = {
+ label: string;
+};
+
+type startNodeData = defaultNodeData;
+type endNodeData = defaultNodeData;
+type normNodeData = defaultNodeData;
+type phaseNodeData = defaultNodeData & {
+ number: number;
+};
+
+export type nodeData = defaultNodeData | startNodeData | phaseNodeData | endNodeData;
+
+// Node Toolbar definition
+
+type ToolbarProps= {
+ nodeId: string;
+};
+
+export function Toolbar({nodeId}:ToolbarProps) {
+ const { setNodes, setEdges } = useReactFlow();
+
+ const handleDelete = () => {
+ setNodes((nds) => nds.filter((n) => n.id !== nodeId));
+ setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId));
+ };
+ return (
+
+ delete
+ );
+}
+
+
+// Definitions of Nodes
+
+type StartNodeProps = {
+ id: string;
+ data: startNodeData;
+};
+
+export const StartNode= ({ id, data }: StartNodeProps) => {
+ return (
+ <>
+
+
+
data test {data.label}
+
+
+ >
+ );
+};
+
+type EndNodeProps = {
+ id: string;
+ data: endNodeData;
+};
+
+export const EndNode= ({ id, data }: EndNodeProps) => {
+ return (
+ <>
+
+
+ >
+ );
+};
+
+
+type PhaseNodeProps = {
+ id: string;
+ data: phaseNodeData;
+};
+
+export const PhaseNode= ({ id, data }: PhaseNodeProps) => {
+ return (
+ <>
+
+
+
phase {data.number} {data.label}
+
+
+
+
+ >
+ );
+};
+
+type NormNodeProps = {
+ id: string;
+ data: normNodeData;
+};
+
+export const NormNode= ({ id, data }: NormNodeProps) => {
+ return (
+ <>
+
+
+ >
+ );
+};
\ No newline at end of file
From b78cd53baa8f3fd73fc6f372cc3e42f2e07b614b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?=
Date: Tue, 7 Oct 2025 15:05:05 +0200
Subject: [PATCH 02/17] feat: Show connected robots in the UI when connection
event is received from CB.
Added two test buttons to mimic events from CB.
UI will listen to port localhost:8000 for data.
use the data.event = "robot_connected" and
data.event = "robot_disconnected".
(robot) ID is required, name and port are optional
but incentivized.
---
src/App.tsx | 2 +
src/pages/ConnectedRobots/ConnectedRobots.tsx | 116 ++++++++++++++++++
src/pages/Home/Home.tsx | 1 +
3 files changed, 119 insertions(+)
create mode 100644 src/pages/ConnectedRobots/ConnectedRobots.tsx
diff --git a/src/App.tsx b/src/App.tsx
index 0833bdb..fe6a9ad 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -3,6 +3,7 @@ import './App.css'
import TemplatePage from './pages/TemplatePage/Template.tsx'
import Home from './pages/Home/Home.tsx'
import ServerComms from './pages/ServerComms/ServerComms.tsx'
+import ConnectedRobots from './pages/ConnectedRobots/ConnectedRobots.tsx'
import Logging from './pages/Logging/Logging.tsx'
import VisProg from "./pages/VisProgPage/VisProg.tsx";
@@ -18,6 +19,7 @@ function App(){
} />
} />
} />
+ } />
)
diff --git a/src/pages/ConnectedRobots/ConnectedRobots.tsx b/src/pages/ConnectedRobots/ConnectedRobots.tsx
new file mode 100644
index 0000000..148cfbe
--- /dev/null
+++ b/src/pages/ConnectedRobots/ConnectedRobots.tsx
@@ -0,0 +1,116 @@
+import { mergeAriaLabelConfig } from '@xyflow/system';
+import { useState, useEffect } from 'react'
+
+// Define the robot type
+type Robot = {
+ id: string;
+ name: string;
+ port: number;
+};
+
+export default function ConnectedRobots() {
+ const [connectedRobots, setConnectedRobots] = useState
([]);
+
+ useEffect(() => {
+ const eventSource = new EventSource("http://localhost:8000/sse");
+ eventSource.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+
+ // Example: data = { event: "robot_connected", id: "pepper_robot1", name: "Pepper", port: 1234 }
+ if (data.event === "robot_connected") {
+
+ // Safeguard id in request.
+ if (data.id === null || data.id === undefined) {
+ console.log("Missing robot id in connection request.")
+ return () => eventSource.close();
+ }
+
+ // Safeguard duplicates
+ if (connectedRobots.some(robot => robot.id === data.id))
+ console.log("connection request was sent for id: ", data.id,
+ " however this id was already present at current time");
+
+ // Add to connected robots while checking name and port for undefineds.
+ else {
+ const name = data.name ?? "no given name";
+ const port = typeof data.port === "number" ? data.port : -1;
+ setConnectedRobots(robots => [...robots, { id: data.id, name: name, port: port }]);
+ }
+ }
+ if (data.event === "robot_disconnected") {
+ // Safeguard id in request.
+ if (data.id === null || data.id === undefined) {
+ console.log("Missing robot id in connection request.")
+ return () => eventSource.close();
+ }
+
+ // Filter out same ids (should only be one)
+ setConnectedRobots(robots => robots.filter(robot => robot.id !== data.id));
+ }
+ } catch {
+ console.log("Unparsable SSE message:", event.data);
+ }
+ };
+ return () => eventSource.close();
+ }, [connectedRobots]);
+
+ return (
+
+
Robots Connected
+
+
Connected Robots
+
+ {connectedRobots.map(robot =>
+
+ {robot.name} (ID: {robot.id}, Port: {robot.port === -1 ? "No given port" : robot.port})
+
+ )}
+
+
+
+
+ {
+ // Reload from CB database (other ticket)
+ }}>Reload from CB
+ {
+ // Example dummy robots
+ const connection_dummies: Robot[] = [
+ { id: "pepper_robot1", name: "Pepper One", port: 8001 },
+ { id: "pepper_robot2", name: "Pepper Two", port: 8002 },
+ { id: "naoqi_robot1", name: "Naoqi One", port: 9001 },
+ { id: "naoqi_robot2", name: "Naoqi Two", port: 9002 },
+ { id: "noport1", name: "I dont have a port in my request >:)", port: -1},
+ { id: "noname1", name: "no given name", port: 2001}
+ ];
+ const randomIndex = Math.floor(Math.random() * connection_dummies.length);
+ const randomConnectionDummy = connection_dummies[randomIndex];
+
+ if (connectedRobots.some(robot => robot.id === randomConnectionDummy.id))
+ console.log("connection request was sent for id: ", randomConnectionDummy.id,
+ " however this id was already present at current time");
+ else
+ setConnectedRobots(robots => [...robots, randomConnectionDummy]);
+ }}>'Sent connected event'
+ {
+ const disconnection_dummies = [
+ { id: "pepper_robot1" },
+ { id: "pepper_robot2" },
+ { id: "naoqi_robot1" },
+ { id: "naoqi_robot2" },
+ { id: "noport1", name: "I dont have a port in my request >:)", port: -1},
+ { id: "noname1", name: "no given name", port: 2001}
+ ];
+ const randomIndex = Math.floor(Math.random() * disconnection_dummies.length);
+ const randomDisconnectionDummy = disconnection_dummies[randomIndex];
+
+ setConnectedRobots(robots => robots.filter(robot => robot.id !== randomDisconnectionDummy.id));
+ }}>'Sent disconnected event'
+ {
+ setConnectedRobots([]);
+ }}>Reset
+
+
+
+ );
+}
diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx
index 582357b..422ccfc 100644
--- a/src/pages/Home/Home.tsx
+++ b/src/pages/Home/Home.tsx
@@ -14,6 +14,7 @@ function Home() {
Robot interaction →
Node editor →
Logs →
+ Connected Robots →
>
)
From ec4f45b984528a4dde896486cf00fb132e16a903 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?=
Date: Wed, 8 Oct 2025 12:40:01 +0200
Subject: [PATCH 03/17] fix: Keep the conencted robots in a global list
ref: N25B-142
---
src/App.tsx | 23 +++++++++++++++++--
src/pages/ConnectedRobots/ConnectedRobots.tsx | 13 ++++++++---
2 files changed, 31 insertions(+), 5 deletions(-)
diff --git a/src/App.tsx b/src/App.tsx
index fe6a9ad..eff9667 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,5 @@
import { Routes, Route, Link } from 'react-router'
+import { useState, useEffect } from 'react'
import './App.css'
import TemplatePage from './pages/TemplatePage/Template.tsx'
import Home from './pages/Home/Home.tsx'
@@ -8,7 +9,16 @@ import Logging from './pages/Logging/Logging.tsx'
import VisProg from "./pages/VisProgPage/VisProg.tsx";
function App(){
-
+
+ // Define what our conencted robot should include
+ type Robot = {
+ id: string;
+ name: string;
+ port: number;
+ };
+
+ const [connectedRobots, setConnectedRobots] = useState([]);
+
return (
{/* Should not use inline styles like this */}
@@ -19,7 +29,16 @@ function App(){
} />
} />
} />
- } />
+
+ }
+ />
)
diff --git a/src/pages/ConnectedRobots/ConnectedRobots.tsx b/src/pages/ConnectedRobots/ConnectedRobots.tsx
index 148cfbe..cd88158 100644
--- a/src/pages/ConnectedRobots/ConnectedRobots.tsx
+++ b/src/pages/ConnectedRobots/ConnectedRobots.tsx
@@ -8,15 +8,22 @@ type Robot = {
port: number;
};
-export default function ConnectedRobots() {
- const [connectedRobots, setConnectedRobots] = useState([]);
+// Define the expected arguments
+type ConnectedRobotsProps = {
+ connectedRobots: Robot[];
+ setConnectedRobots: React.Dispatch>;
+};
+export default function ConnectedRobots({
+ connectedRobots, setConnectedRobots}: ConnectedRobotsProps) {
+
useEffect(() => {
const eventSource = new EventSource("http://localhost:8000/sse");
eventSource.onmessage = (event) => {
try {
+ console.log("message received :", event.data)
const data = JSON.parse(event.data);
-
+
// Example: data = { event: "robot_connected", id: "pepper_robot1", name: "Pepper", port: 1234 }
if (data.event === "robot_connected") {
From 72d61e398506e135d8aefe037d474c18b1c5e638 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?=
Date: Wed, 8 Oct 2025 14:35:20 +0200
Subject: [PATCH 04/17] chore: fixed wrong imports and deleted some unnecessary
prints.
ref: N25B-142
---
src/App.tsx | 3 ++-
src/pages/ConnectedRobots/ConnectedRobots.tsx | 6 +++---
2 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/src/App.tsx b/src/App.tsx
index eff9667..1dd4c9d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,5 +1,5 @@
import { Routes, Route, Link } from 'react-router'
-import { useState, useEffect } from 'react'
+import { useState } from 'react'
import './App.css'
import TemplatePage from './pages/TemplatePage/Template.tsx'
import Home from './pages/Home/Home.tsx'
@@ -17,6 +17,7 @@ function App(){
port: number;
};
+ // (Acces to) the array of connected robots
const [connectedRobots, setConnectedRobots] = useState([]);
return (
diff --git a/src/pages/ConnectedRobots/ConnectedRobots.tsx b/src/pages/ConnectedRobots/ConnectedRobots.tsx
index cd88158..45559dd 100644
--- a/src/pages/ConnectedRobots/ConnectedRobots.tsx
+++ b/src/pages/ConnectedRobots/ConnectedRobots.tsx
@@ -1,5 +1,4 @@
-import { mergeAriaLabelConfig } from '@xyflow/system';
-import { useState, useEffect } from 'react'
+import { useEffect } from 'react'
// Define the robot type
type Robot = {
@@ -21,7 +20,6 @@ export default function ConnectedRobots({
const eventSource = new EventSource("http://localhost:8000/sse");
eventSource.onmessage = (event) => {
try {
- console.log("message received :", event.data)
const data = JSON.parse(event.data);
// Example: data = { event: "robot_connected", id: "pepper_robot1", name: "Pepper", port: 1234 }
@@ -69,6 +67,7 @@ export default function ConnectedRobots({
Connected Robots
{connectedRobots.map(robot =>
+ // Map all the robots in an unordered list
{robot.name} (ID: {robot.id}, Port: {robot.port === -1 ? "No given port" : robot.port})
@@ -100,6 +99,7 @@ export default function ConnectedRobots({
setConnectedRobots(robots => [...robots, randomConnectionDummy]);
}}>'Sent connected event'
{
+ // Example disconnection bots
const disconnection_dummies = [
{ id: "pepper_robot1" },
{ id: "pepper_robot2" },
From 1a0fd92e0fc5572515bbba5dec2e6d64221cf226 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?=
Date: Wed, 8 Oct 2025 16:49:44 +0200
Subject: [PATCH 05/17] chore: complete merging with functionality
ref: N25B-142
additional comments: The reload from CB doesn't work yet.
---
src/App.tsx | 11 +++++++++++
src/pages/Home/Home.tsx | 1 +
2 files changed, 12 insertions(+)
diff --git a/src/App.tsx b/src/App.tsx
index 22be116..78c273f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -4,6 +4,7 @@ import './App.css'
import TemplatePage from './pages/TemplatePage/Template.tsx'
import Home from './pages/Home/Home.tsx'
import Robot from './pages/Robot/Robot.tsx';
+import ConnectedRobots from './pages/ConnectedRobots/ConnectedRobots.tsx'
function App(){
@@ -27,6 +28,16 @@ function App(){
} />
} />
} />
+
+ }
+ />
diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx
index cb70de0..0c811ac 100644
--- a/src/pages/Home/Home.tsx
+++ b/src/pages/Home/Home.tsx
@@ -13,6 +13,7 @@ function Home() {
Robot Interaction →
Template →
+ Connected Robots →
)
From fa046e6b2a8524643dbf41a70a370ec7e399cae1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?=
Date: Wed, 8 Oct 2025 17:41:29 +0200
Subject: [PATCH 06/17] feat: dummy reload from CB added.
ref: N25B-153
---
src/pages/ConnectedRobots/ConnectedRobots.tsx | 78 ++++++++++++++++---
1 file changed, 67 insertions(+), 11 deletions(-)
diff --git a/src/pages/ConnectedRobots/ConnectedRobots.tsx b/src/pages/ConnectedRobots/ConnectedRobots.tsx
index 45559dd..abce238 100644
--- a/src/pages/ConnectedRobots/ConnectedRobots.tsx
+++ b/src/pages/ConnectedRobots/ConnectedRobots.tsx
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
+import Logging from '../Logging/Logging';
// Define the robot type
type Robot = {
@@ -14,20 +15,21 @@ type ConnectedRobotsProps = {
};
export default function ConnectedRobots({
- connectedRobots, setConnectedRobots}: ConnectedRobotsProps) {
-
+ connectedRobots, setConnectedRobots }: ConnectedRobotsProps) {
+
useEffect(() => {
const eventSource = new EventSource("http://localhost:8000/sse");
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
-
+
// Example: data = { event: "robot_connected", id: "pepper_robot1", name: "Pepper", port: 1234 }
if (data.event === "robot_connected") {
// Safeguard id in request.
if (data.id === null || data.id === undefined) {
- console.log("Missing robot id in connection request.")
+ console.log(`Missing robot id in connection request.
+ Use format: 'data: {event = 'robot_connected', id = , (optional) name = , (optional) port = }'.`)
return () => eventSource.close();
}
@@ -46,13 +48,23 @@ export default function ConnectedRobots({
if (data.event === "robot_disconnected") {
// Safeguard id in request.
if (data.id === null || data.id === undefined) {
- console.log("Missing robot id in connection request.")
+ console.log("Missing robot id in connection request. Use format: 'data: {event = 'robot_disconnected', id = }'.");
return () => eventSource.close();
}
// Filter out same ids (should only be one)
setConnectedRobots(robots => robots.filter(robot => robot.id !== data.id));
}
+ if (data.event === "robot_list") {
+ if (data.list === null || data.list === undefined) {
+ console.log("Missing list in robot_list request. Use format: 'data: {event = 'robot_list', list = }'.");
+ return () => eventSource.close();
+ }
+
+ // Set the robot list to the one found in CB
+ setConnectedRobots(data.list);
+ }
+
} catch {
console.log("Unparsable SSE message:", event.data);
}
@@ -77,8 +89,52 @@ export default function ConnectedRobots({
{
- // Reload from CB database (other ticket)
- }}>Reload from CB
+ // Let's test the reload function.
+ const example_list = [{ id: "pepper_robot1", name: "Pepper1", port: 1234 },
+ { id: "pepper_robot2", name: "Pepper2", port: 1235 },
+ { id: "pepper_robot3", name: "Pepper3", port: 1236 },
+ { id: "pepper_robot4", name: "Pepper4", port: 1237 }]
+
+ const example_event = `{
+ "event": "robot_list", "list":
+ [{ "id": "pepper_robot1",
+ "name": "Pepper1",
+ "port": 1234 },{
+
+ "id": "pepper_robot2",
+ "name": "Pepper2",
+ "port": 1235 },{
+
+ "id": "pepper_robot3",
+ "name": "Pepper3",
+ "port": 1236 }, {
+
+ "id": "pepper_robot4",
+ "name": "Pepper4",
+ "port": 1237 }]}`
+
+ // Now let's put it through the same steps as the event would do. :)
+ try {
+ const data = JSON.parse(example_event);
+ if (data.event === "robot_list") {
+ if (data.list === null || data.list === undefined) {
+ console.log("Missing list in robot_list request. Use format: 'data: {event = 'robot_list', list =
}'.");
+ return;
+ }
+ // Check if it is as expected.
+ if (JSON.stringify(data.list) !== JSON.stringify(example_list)) {
+ console.log("Dummy reload failed: list don't match.")
+ }
+ else {
+ console.log("Dummy reload succes!!")
+ }
+ } else {
+ console.log("Dummy reload failed, didn't parse to 'data.event === 'robot_list'.'")
+ }
+ } catch {
+ console.log("Dummy reload failed: didnt parse correctly.")
+ }
+ }}>Dummy Reload from CB
{
// Example dummy robots
const connection_dummies: Robot[] = [
@@ -86,8 +142,8 @@ export default function ConnectedRobots({
{ id: "pepper_robot2", name: "Pepper Two", port: 8002 },
{ id: "naoqi_robot1", name: "Naoqi One", port: 9001 },
{ id: "naoqi_robot2", name: "Naoqi Two", port: 9002 },
- { id: "noport1", name: "I dont have a port in my request >:)", port: -1},
- { id: "noname1", name: "no given name", port: 2001}
+ { id: "noport1", name: "I dont have a port in my request >:)", port: -1 },
+ { id: "noname1", name: "no given name", port: 2001 }
];
const randomIndex = Math.floor(Math.random() * connection_dummies.length);
const randomConnectionDummy = connection_dummies[randomIndex];
@@ -105,8 +161,8 @@ export default function ConnectedRobots({
{ id: "pepper_robot2" },
{ id: "naoqi_robot1" },
{ id: "naoqi_robot2" },
- { id: "noport1", name: "I dont have a port in my request >:)", port: -1},
- { id: "noname1", name: "no given name", port: 2001}
+ { id: "noport1", name: "I dont have a port in my request >:)", port: -1 },
+ { id: "noname1", name: "no given name", port: 2001 }
];
const randomIndex = Math.floor(Math.random() * disconnection_dummies.length);
const randomDisconnectionDummy = disconnection_dummies[randomIndex];
From 4181454a73b82180a4240a0789c5a196ac9291ae Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?=
Date: Thu, 30 Oct 2025 13:05:56 +0100
Subject: [PATCH 07/17] feat: show robots page easier - quick connected sign.
Quick reload - no need for manual reloads or anything.
ref: N25B-142
---
package-lock.json | 126 +++++++++++++
src/pages/ConnectedRobots/ConnectedRobots.tsx | 176 ++----------------
2 files changed, 144 insertions(+), 158 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index b4dc078..665dad5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1967,6 +1967,12 @@
"@tybys/wasm-util": "^0.10.0"
}
},
+ "node_modules/@neodrag/react": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/@neodrag/react/-/react-2.3.1.tgz",
+ "integrity": "sha512-mOVefo3mFmaVLs9PB5F5wMXnnclG81qjOaPHyf8YZUnw/Ciz0pAqyJDwDJk0nPTIK5I2x1JdjXSchGNdCxZNRQ==",
+ "license": "MIT"
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3769,6 +3775,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+ "license": "MIT"
+ },
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -3951,6 +3963,111 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
@@ -7591,6 +7708,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
diff --git a/src/pages/ConnectedRobots/ConnectedRobots.tsx b/src/pages/ConnectedRobots/ConnectedRobots.tsx
index abce238..a6f7ce7 100644
--- a/src/pages/ConnectedRobots/ConnectedRobots.tsx
+++ b/src/pages/ConnectedRobots/ConnectedRobots.tsx
@@ -1,178 +1,38 @@
-import { useEffect } from 'react'
-import Logging from '../Logging/Logging';
-
-// Define the robot type
-type Robot = {
- id: string;
- name: string;
- port: number;
-};
-
-// Define the expected arguments
-type ConnectedRobotsProps = {
- connectedRobots: Robot[];
- setConnectedRobots: React.Dispatch>;
-};
-
-export default function ConnectedRobots({
- connectedRobots, setConnectedRobots }: ConnectedRobotsProps) {
+import { useEffect, useState } from 'react'
+export default function ConnectedRobots() {
+
+ const [connected, setConnected] = useState(null);
useEffect(() => {
- const eventSource = new EventSource("http://localhost:8000/sse");
+ // 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);
- // Example: data = { event: "robot_connected", id: "pepper_robot1", name: "Pepper", port: 1234 }
- if (data.event === "robot_connected") {
-
- // Safeguard id in request.
- if (data.id === null || data.id === undefined) {
- console.log(`Missing robot id in connection request.
- Use format: 'data: {event = 'robot_connected', id = , (optional) name = , (optional) port = }'.`)
- return () => eventSource.close();
- }
-
- // Safeguard duplicates
- if (connectedRobots.some(robot => robot.id === data.id))
- console.log("connection request was sent for id: ", data.id,
- " however this id was already present at current time");
-
- // Add to connected robots while checking name and port for undefineds.
- else {
- const name = data.name ?? "no given name";
- const port = typeof data.port === "number" ? data.port : -1;
- setConnectedRobots(robots => [...robots, { id: data.id, name: name, port: port }]);
- }
+ // Set connected to value.
+ try {
+ setConnected(data)
}
- if (data.event === "robot_disconnected") {
- // Safeguard id in request.
- if (data.id === null || data.id === undefined) {
- console.log("Missing robot id in connection request. Use format: 'data: {event = 'robot_disconnected', id = }'.");
- return () => eventSource.close();
- }
-
- // Filter out same ids (should only be one)
- setConnectedRobots(robots => robots.filter(robot => robot.id !== data.id));
- }
- if (data.event === "robot_list") {
- if (data.list === null || data.list === undefined) {
- console.log("Missing list in robot_list request. Use format: 'data: {event = 'robot_list', list = }'.");
- return () => eventSource.close();
- }
-
- // Set the robot list to the one found in CB
- setConnectedRobots(data.list);
+ catch {
+ console.log("couldnt extract connected from incoming ping data")
}
} catch {
- console.log("Unparsable SSE message:", event.data);
+ console.log("Ping message not in correct format:", event.data);
}
};
return () => eventSource.close();
- }, [connectedRobots]);
+ });
return (
-
Robots Connected
+
Is robot currently connected?
-
Connected Robots
-
- {connectedRobots.map(robot =>
- // Map all the robots in an unordered list
-
- {robot.name} (ID: {robot.id}, Port: {robot.port === -1 ? "No given port" : robot.port})
-
- )}
-
-
-
-
- {
- // Let's test the reload function.
- const example_list = [{ id: "pepper_robot1", name: "Pepper1", port: 1234 },
- { id: "pepper_robot2", name: "Pepper2", port: 1235 },
- { id: "pepper_robot3", name: "Pepper3", port: 1236 },
- { id: "pepper_robot4", name: "Pepper4", port: 1237 }]
-
- const example_event = `{
- "event": "robot_list", "list":
- [{ "id": "pepper_robot1",
- "name": "Pepper1",
- "port": 1234 },{
-
- "id": "pepper_robot2",
- "name": "Pepper2",
- "port": 1235 },{
-
- "id": "pepper_robot3",
- "name": "Pepper3",
- "port": 1236 }, {
-
- "id": "pepper_robot4",
- "name": "Pepper4",
- "port": 1237 }]}`
-
- // Now let's put it through the same steps as the event would do. :)
- try {
- const data = JSON.parse(example_event);
- if (data.event === "robot_list") {
- if (data.list === null || data.list === undefined) {
- console.log("Missing list in robot_list request. Use format: 'data: {event = 'robot_list', list = }'.");
- return;
- }
- // Check if it is as expected.
- if (JSON.stringify(data.list) !== JSON.stringify(example_list)) {
- console.log("Dummy reload failed: list don't match.")
- }
- else {
- console.log("Dummy reload succes!!")
- }
- } else {
- console.log("Dummy reload failed, didn't parse to 'data.event === 'robot_list'.'")
- }
- } catch {
- console.log("Dummy reload failed: didnt parse correctly.")
- }
- }}>Dummy Reload from CB
- {
- // Example dummy robots
- const connection_dummies: Robot[] = [
- { id: "pepper_robot1", name: "Pepper One", port: 8001 },
- { id: "pepper_robot2", name: "Pepper Two", port: 8002 },
- { id: "naoqi_robot1", name: "Naoqi One", port: 9001 },
- { id: "naoqi_robot2", name: "Naoqi Two", port: 9002 },
- { id: "noport1", name: "I dont have a port in my request >:)", port: -1 },
- { id: "noname1", name: "no given name", port: 2001 }
- ];
- const randomIndex = Math.floor(Math.random() * connection_dummies.length);
- const randomConnectionDummy = connection_dummies[randomIndex];
-
- if (connectedRobots.some(robot => robot.id === randomConnectionDummy.id))
- console.log("connection request was sent for id: ", randomConnectionDummy.id,
- " however this id was already present at current time");
- else
- setConnectedRobots(robots => [...robots, randomConnectionDummy]);
- }}>'Sent connected event'
- {
- // Example disconnection bots
- const disconnection_dummies = [
- { id: "pepper_robot1" },
- { id: "pepper_robot2" },
- { id: "naoqi_robot1" },
- { id: "naoqi_robot2" },
- { id: "noport1", name: "I dont have a port in my request >:)", port: -1 },
- { id: "noname1", name: "no given name", port: 2001 }
- ];
- const randomIndex = Math.floor(Math.random() * disconnection_dummies.length);
- const randomDisconnectionDummy = disconnection_dummies[randomIndex];
-
- setConnectedRobots(robots => robots.filter(robot => robot.id !== randomDisconnectionDummy.id));
- }}>'Sent disconnected event'
- {
- setConnectedRobots([]);
- }}>Reset
-
+
Robot is currently: {connected == null ? "checking..." : (connected ? "connected! 🟢" : "not connected... 🔴")}
);
From 6a88aa3d755db2ec9f9ed76019545846f39f3a16 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?=
Date: Thu, 30 Oct 2025 14:57:50 +0100
Subject: [PATCH 08/17] merge branch dev into show-connected-robots pt2
---
package-lock.json | 127 ------------------------------
src/App.tsx | 3 -
src/pages/VisProgPage/VisProg.tsx | 11 ---
3 files changed, 141 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index b3caba6..c7346fa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3323,69 +3323,6 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
- "node_modules/@xyflow/react": {
- "version": "12.8.6",
- "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz",
- "integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==",
- "license": "MIT",
- "dependencies": {
- "@xyflow/system": "0.0.70",
- "classcat": "^5.0.3",
- "zustand": "^4.4.0"
- },
- "peerDependencies": {
- "react": ">=17",
- "react-dom": ">=17"
- }
- },
-<<<<<<< HEAD
-=======
- "node_modules/@xyflow/react/node_modules/zustand": {
- "version": "4.5.7",
- "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
- "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
- "license": "MIT",
- "dependencies": {
- "use-sync-external-store": "^1.2.2"
- },
- "engines": {
- "node": ">=12.7.0"
- },
- "peerDependencies": {
- "@types/react": ">=16.8",
- "immer": ">=9.0.6",
- "react": ">=16.8"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "immer": {
- "optional": true
- },
- "react": {
- "optional": true
- }
- }
- },
->>>>>>> origin/dev
- "node_modules/@xyflow/system": {
- "version": "0.0.70",
- "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz",
- "integrity": "sha512-PpC//u9zxdjj0tfTSmZrg3+sRbTz6kop/Amky44U2Dl51sxzDTIUfXMwETOYpmr2dqICWXBIJwXL2a9QWtX2XA==",
- "license": "MIT",
- "dependencies": {
- "@types/d3-drag": "^3.0.7",
- "@types/d3-interpolate": "^3.0.4",
- "@types/d3-selection": "^3.0.10",
- "@types/d3-transition": "^3.0.8",
- "@types/d3-zoom": "^3.0.8",
- "d3-drag": "^3.0.0",
- "d3-interpolate": "^3.0.1",
- "d3-selection": "^3.0.0",
- "d3-zoom": "^3.0.0"
- }
- },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -7740,21 +7677,6 @@
"punycode": "^2.1.0"
}
},
- "node_modules/use-sync-external-store": {
-<<<<<<< HEAD
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
- "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
-=======
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
- "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
->>>>>>> origin/dev
- "license": "MIT",
- "peerDependencies": {
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- }
- },
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@@ -8237,55 +8159,6 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
- },
- "node_modules/zustand": {
-<<<<<<< HEAD
- "version": "4.5.7",
- "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
- "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
- "license": "MIT",
- "dependencies": {
- "use-sync-external-store": "^1.2.2"
- },
- "engines": {
- "node": ">=12.7.0"
- },
- "peerDependencies": {
- "@types/react": ">=16.8",
- "immer": ">=9.0.6",
- "react": ">=16.8"
-=======
- "version": "5.0.8",
- "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
- "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
- "license": "MIT",
- "engines": {
- "node": ">=12.20.0"
- },
- "peerDependencies": {
- "@types/react": ">=18.0.0",
- "immer": ">=9.0.6",
- "react": ">=18.0.0",
- "use-sync-external-store": ">=1.2.0"
->>>>>>> origin/dev
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "immer": {
- "optional": true
- },
- "react": {
- "optional": true
-<<<<<<< HEAD
-=======
- },
- "use-sync-external-store": {
- "optional": true
->>>>>>> origin/dev
- }
- }
}
}
}
diff --git a/src/App.tsx b/src/App.tsx
index fd9404c..819dae4 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -4,11 +4,8 @@ import './App.css'
import TemplatePage from './pages/TemplatePage/Template.tsx'
import Home from './pages/Home/Home.tsx'
import Robot from './pages/Robot/Robot.tsx';
-<<<<<<< HEAD
import ConnectedRobots from './pages/ConnectedRobots/ConnectedRobots.tsx'
-=======
import VisProg from "./pages/VisProgPage/VisProg.tsx";
->>>>>>> origin/dev
function App(){
diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx
index ec2078c..4b8944c 100644
--- a/src/pages/VisProgPage/VisProg.tsx
+++ b/src/pages/VisProgPage/VisProg.tsx
@@ -1,13 +1,3 @@
-<<<<<<< HEAD
-import VisProgUI from "../../visualProgrammingUI/VisProgUI.tsx";
-
-function VisProgPage() {
- return (
- <>
-
- >
- )
-=======
import {
Background,
Controls,
@@ -144,7 +134,6 @@ function VisProgPage() {
>
)
->>>>>>> origin/dev
}
export default VisProgPage
\ No newline at end of file
From 5e707224cffabb3320e87e8810f77fecb1b8305e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?=
Date: Thu, 30 Oct 2025 15:47:09 +0100
Subject: [PATCH 09/17] feat: Show connected robots finished with unit test 94%
coverage
ref: N25B-142
---
package-lock.json | 98 +++++++++++++++++
src/App.tsx | 22 +---
src/pages/ConnectedRobots/ConnectedRobots.tsx | 6 ++
.../connectedRobots/ConnectedRobots.test.tsx | 102 ++++++++++++++++++
4 files changed, 207 insertions(+), 21 deletions(-)
create mode 100644 test/pages/connectedRobots/ConnectedRobots.test.tsx
diff --git a/package-lock.json b/package-lock.json
index c7346fa..40f413f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3323,6 +3323,66 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@xyflow/react": {
+ "version": "12.9.1",
+ "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.1.tgz",
+ "integrity": "sha512-JRPCT5p7NnPdVSIh15AFvUSSm+8GUyz2I6iuBEC1LG2lKgig/L48AM/ImMHCc3ZUCg+AgTOJDaX2fcRyPA9BTA==",
+ "license": "MIT",
+ "dependencies": {
+ "@xyflow/system": "0.0.72",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.0"
+ },
+ "peerDependencies": {
+ "react": ">=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@xyflow/react/node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@xyflow/system": {
+ "version": "0.0.72",
+ "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz",
+ "integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-drag": "^3.0.7",
+ "@types/d3-interpolate": "^3.0.4",
+ "@types/d3-selection": "^3.0.10",
+ "@types/d3-transition": "^3.0.8",
+ "@types/d3-zoom": "^3.0.8",
+ "d3-drag": "^3.0.0",
+ "d3-interpolate": "^3.0.1",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0"
+ }
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -7677,6 +7737,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@@ -8159,6 +8228,35 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
+ "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/src/App.tsx b/src/App.tsx
index 819dae4..4fb4c42 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,5 +1,4 @@
import { Routes, Route, Link } from 'react-router'
-import { useState } from 'react'
import './App.css'
import TemplatePage from './pages/TemplatePage/Template.tsx'
import Home from './pages/Home/Home.tsx'
@@ -9,16 +8,6 @@ import VisProg from "./pages/VisProgPage/VisProg.tsx";
function App(){
- // Define what our conencted robot should include
- type Robot = {
- id: string;
- name: string;
- port: number;
- };
-
- // (Acces to) the array of connected robots
- const [connectedRobots, setConnectedRobots] = useState([]);
-
return (
@@ -30,16 +19,7 @@ function App(){
} />
} />
} />
-
- }
- />
+ } />
diff --git a/src/pages/ConnectedRobots/ConnectedRobots.tsx b/src/pages/ConnectedRobots/ConnectedRobots.tsx
index a6f7ce7..8b4b898 100644
--- a/src/pages/ConnectedRobots/ConnectedRobots.tsx
+++ b/src/pages/ConnectedRobots/ConnectedRobots.tsx
@@ -3,6 +3,9 @@ import { useEffect, useState } from 'react'
export default function ConnectedRobots() {
const [connected, setConnected] = useState(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");
@@ -33,6 +36,9 @@ export default function ConnectedRobots() {
Is robot currently connected?
Robot is currently: {connected == null ? "checking..." : (connected ? "connected! 🟢" : "not connected... 🔴")}
+
+ {connected == null ? "If checking continues, make sure CB is properly loaded with robot at least once." : ""}
+
);
diff --git a/test/pages/connectedRobots/ConnectedRobots.test.tsx b/test/pages/connectedRobots/ConnectedRobots.test.tsx
new file mode 100644
index 0000000..ffea6e3
--- /dev/null
+++ b/test/pages/connectedRobots/ConnectedRobots.test.tsx
@@ -0,0 +1,102 @@
+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: any) {
+ // 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(() => {
+ (globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url));
+});
+
+// clean after tests
+afterEach(() => {
+ cleanup();
+ jest.restoreAllMocks();
+ mockInstances.length = 0;
+});
+
+describe('ConnectedRobots', () => {
+ test('renders initial state correctly', () => {
+ render(
);
+
+ // 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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ const eventSource = mockInstances[0];
+ const closeSpy = jest.spyOn(eventSource, 'close');
+ cleanup();
+ expect(closeSpy).toHaveBeenCalled();
+ expect(eventSource.closed).toBe(true);
+ });
+});
\ No newline at end of file
From 333bd6e6fd1b7db071e1b1399d0260de606a375f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?=
Date: Wed, 5 Nov 2025 16:11:36 +0100
Subject: [PATCH 10/17] chore: single typing change
---
test/pages/connectedRobots/ConnectedRobots.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/test/pages/connectedRobots/ConnectedRobots.test.tsx b/test/pages/connectedRobots/ConnectedRobots.test.tsx
index ffea6e3..c2d3749 100644
--- a/test/pages/connectedRobots/ConnectedRobots.test.tsx
+++ b/test/pages/connectedRobots/ConnectedRobots.test.tsx
@@ -13,7 +13,7 @@ class MockEventSource {
mockInstances.push(this);
}
- sendMessage(data: any) {
+ sendMessage(data: string) {
// Trigger whatever the component listens to
this.onmessage?.({ data } as MessageEvent);
}
From 1b8095376b31027fd7bc39199c9d8c96d4e9ba69 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?=
Date: Wed, 5 Nov 2025 17:21:36 +0100
Subject: [PATCH 11/17] fix: fixed npx eslint (also accounting for justins
part)
ref: N25B-142
---
src/visualProgrammingUI/components/DragDropSidebar.tsx | 3 +--
test/pages/connectedRobots/ConnectedRobots.test.tsx | 4 +++-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/visualProgrammingUI/components/DragDropSidebar.tsx b/src/visualProgrammingUI/components/DragDropSidebar.tsx
index b3926d9..f1bf6bd 100644
--- a/src/visualProgrammingUI/components/DragDropSidebar.tsx
+++ b/src/visualProgrammingUI/components/DragDropSidebar.tsx
@@ -27,8 +27,7 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP
const draggableRef = useRef(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
-
- // @ts-ignore
+ // @ts-expect-error we expect the null referece here.
useDraggable(draggableRef, {
position: position,
onDrag: ({ offsetX, offsetY }) => {
diff --git a/test/pages/connectedRobots/ConnectedRobots.test.tsx b/test/pages/connectedRobots/ConnectedRobots.test.tsx
index c2d3749..017b2a2 100644
--- a/test/pages/connectedRobots/ConnectedRobots.test.tsx
+++ b/test/pages/connectedRobots/ConnectedRobots.test.tsx
@@ -25,7 +25,9 @@ class MockEventSource {
// mock event source generation with fake function that returns our fake mock source
beforeAll(() => {
- (globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url));
+ // 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
From 8733bb3c040706f301ba08a9c042257e254577cb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?=
Date: Tue, 11 Nov 2025 10:25:27 +0100
Subject: [PATCH 12/17] chore: remove old remnants from project
---
src/visualProgrammingUI/VisProgUI.css | 7 -
src/visualProgrammingUI/VisProgUI.tsx | 132 -----------------
.../components/DragDropSidebar.tsx | 140 ------------------
.../components/NodeDefinitions.tsx | 111 --------------
4 files changed, 390 deletions(-)
delete mode 100644 src/visualProgrammingUI/VisProgUI.css
delete mode 100644 src/visualProgrammingUI/VisProgUI.tsx
delete mode 100644 src/visualProgrammingUI/components/DragDropSidebar.tsx
delete mode 100644 src/visualProgrammingUI/components/NodeDefinitions.tsx
diff --git a/src/visualProgrammingUI/VisProgUI.css b/src/visualProgrammingUI/VisProgUI.css
deleted file mode 100644
index 8d82d09..0000000
--- a/src/visualProgrammingUI/VisProgUI.css
+++ /dev/null
@@ -1,7 +0,0 @@
-.default-node {
- padding: 10px 20px;
- background-color: canvas;
- outline-style: solid;
- border-radius: 5pt;
- outline-width: 1pt;
-}
\ No newline at end of file
diff --git a/src/visualProgrammingUI/VisProgUI.tsx b/src/visualProgrammingUI/VisProgUI.tsx
deleted file mode 100644
index a2b1e9c..0000000
--- a/src/visualProgrammingUI/VisProgUI.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-import './VisProgUI.css'
-
-import {
- useCallback,
- useRef
-} from 'react';
-import {
- Background,
- Controls,
- ReactFlow,
- ReactFlowProvider,
- useNodesState,
- useEdgesState,
- reconnectEdge,
- addEdge,
- MarkerType,
- type Edge,
- type Connection,
-} from '@xyflow/react';
-import '@xyflow/react/dist/style.css';
-import {
- StartNode,
- EndNode,
- PhaseNode,
- NormNode
-} from "./components/NodeDefinitions.tsx";
-
-import { Sidebar } from './components/DragDropSidebar.tsx';
-
-const nodeTypes = {
- start: StartNode,
- end: EndNode,
- phase: PhaseNode,
- norm: NormNode
-};
-
-const initialNodes = [
- {
- id: 'start',
- type: 'start',
- position: {x: 0, y: 0},
- data: {label: 'start'}
- },
- {
- id: 'genericPhase',
- 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'}
- }
-];
-const initialEdges = [{id: 'start-end', source: 'start', target: 'end'}];
-
-const defaultEdgeOptions = {
- type: 'floating',
- markerEnd: {
- type: MarkerType.ArrowClosed,
- color: '#505050',
- },
-};
-
-const VisProgUI = ()=> {
- const edgeReconnectSuccessful = useRef(true);
- const [nodes, , onNodesChange] = useNodesState(initialNodes);
- const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
-
- const onConnect = useCallback(
- (params: Edge | Connection) => setEdges((els) => addEdge(params, els)),
- [setEdges],
- );
-
- const onReconnectStart = useCallback(() => {
- edgeReconnectSuccessful.current = false;
- }, []);
-
- const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => {
- edgeReconnectSuccessful.current = true;
- setEdges((els) => reconnectEdge(oldEdge, newConnection, els));
- }, [setEdges]);
-
- const onReconnectEnd = useCallback((_: unknown, edge: { id: string; }) => {
- if (!edgeReconnectSuccessful.current) {
- setEdges((eds) => eds.filter((e) => e.id !== edge.id));
- }
-
- edgeReconnectSuccessful.current = true;
- }, [setEdges]);
-
- return (
-
-
- );
-};
-
-function VisualProgrammingUI(){
- return (
-
-
-
- );
-}
-
-export default VisualProgrammingUI;
\ No newline at end of file
diff --git a/src/visualProgrammingUI/components/DragDropSidebar.tsx b/src/visualProgrammingUI/components/DragDropSidebar.tsx
deleted file mode 100644
index f1bf6bd..0000000
--- a/src/visualProgrammingUI/components/DragDropSidebar.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import { useDraggable } from '@neodrag/react';
-import {
- useReactFlow,
- type XYPosition
-} from '@xyflow/react';
-import {
- type ReactNode,
- useCallback,
- useRef,
- useState
-} from 'react';
-
-
-// improve later to create better automatic IDs
-let id = 0;
-const getId = () => `dndnode_${id++}`;
-
-
-interface DraggableNodeProps {
- className?: string;
- children: ReactNode;
- nodeType: string;
- onDrop: (nodeType: string, position: XYPosition) => void;
-}
-
-function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeProps) {
- const draggableRef = useRef(null);
- const [position, setPosition] = useState({ x: 0, y: 0 });
-
- // @ts-expect-error we expect the null referece here.
- useDraggable(draggableRef, {
- position: position,
- onDrag: ({ offsetX, offsetY }) => {
- // Calculate position relative to the viewport
- setPosition({
- x: offsetX,
- y: offsetY,
- });
- },
- onDragEnd: ({ event }) => {
- setPosition({ x: 0, y: 0 });
- onDrop(nodeType, {
- x: event.clientX,
- y: event.clientY,
- });
- },
- });
-
- return (
-
- {children}
-
- );
-}
-
-export function Sidebar() {
- const { setNodes, screenToFlowPosition } = useReactFlow();
-
- const handleNodeDrop = useCallback(
- (nodeType: string, screenPosition: XYPosition) => {
- const flow = document.querySelector('.react-flow');
- const flowRect = flow?.getBoundingClientRect();
- const isInFlow =
- flowRect &&
- screenPosition.x >= flowRect.left &&
- screenPosition.x <= flowRect.right &&
- screenPosition.y >= flowRect.top &&
- screenPosition.y <= flowRect.bottom;
-
- // Create a new node and add it to the flow
- if (isInFlow) {
- const position = screenToFlowPosition(screenPosition);
-
- const newNode = () => {
- switch (nodeType) {
- case "phase":
- return {
- id: getId(),
- type: nodeType,
- position,
- data: {label: `"new"`, number: (-1)},
- };
- case "start":
- return {
- id: getId(),
- type: nodeType,
- position,
- data: {label: `new start node`},
- };
- case "end":
- return {
- id: getId(),
- type: nodeType,
- position,
- data: {label: `new end node`},
- };
- case "norm":
- return {
- id: getId(),
- type: nodeType,
- position,
- data: {label: `new norm node`},
- };
- default: {
- return {
- id: getId(),
- type: nodeType,
- position,
- data: {label: `new default node`},
- };
- }
- }
- }
-
- setNodes((nds) => nds.concat(newNode()));
- }
- },
- [setNodes, screenToFlowPosition],
- );
-
- return (
-
- );
-}
\ No newline at end of file
diff --git a/src/visualProgrammingUI/components/NodeDefinitions.tsx b/src/visualProgrammingUI/components/NodeDefinitions.tsx
deleted file mode 100644
index b4547b2..0000000
--- a/src/visualProgrammingUI/components/NodeDefinitions.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-import {Handle, NodeToolbar, Position, useReactFlow} from '@xyflow/react';
-import '@xyflow/react/dist/style.css';
-import '../VisProgUI.css';
-
-// Datatypes for NodeTypes
-
-type defaultNodeData = {
- label: string;
-};
-
-type startNodeData = defaultNodeData;
-type endNodeData = defaultNodeData;
-type normNodeData = defaultNodeData;
-type phaseNodeData = defaultNodeData & {
- number: number;
-};
-
-export type nodeData = defaultNodeData | startNodeData | phaseNodeData | endNodeData;
-
-// Node Toolbar definition
-
-type ToolbarProps= {
- nodeId: string;
-};
-
-export function Toolbar({nodeId}:ToolbarProps) {
- const { setNodes, setEdges } = useReactFlow();
-
- const handleDelete = () => {
- setNodes((nds) => nds.filter((n) => n.id !== nodeId));
- setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId));
- };
- return (
-
- delete
- );
-}
-
-
-// Definitions of Nodes
-
-type StartNodeProps = {
- id: string;
- data: startNodeData;
-};
-
-export const StartNode= ({ id, data }: StartNodeProps) => {
- return (
- <>
-
-
-
data test {data.label}
-
-
- >
- );
-};
-
-type EndNodeProps = {
- id: string;
- data: endNodeData;
-};
-
-export const EndNode= ({ id, data }: EndNodeProps) => {
- return (
- <>
-
-
- >
- );
-};
-
-
-type PhaseNodeProps = {
- id: string;
- data: phaseNodeData;
-};
-
-export const PhaseNode= ({ id, data }: PhaseNodeProps) => {
- return (
- <>
-
-
-
phase {data.number} {data.label}
-
-
-
-
- >
- );
-};
-
-type NormNodeProps = {
- id: string;
- data: normNodeData;
-};
-
-export const NormNode= ({ id, data }: NormNodeProps) => {
- return (
- <>
-
-
- >
- );
-};
\ No newline at end of file
From df4346150e2bf6a81d9629cdbc48884572bae3a5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?=
Date: Tue, 11 Nov 2025 11:11:46 +0100
Subject: [PATCH 13/17] chore: remove old code pt 2
---
src/assets/data.ts | 361 ---------------------------
src/pages/Logging/Logging.module.css | 17 --
src/pages/Logging/Logging.tsx | 78 ------
3 files changed, 456 deletions(-)
delete mode 100644 src/assets/data.ts
delete mode 100644 src/pages/Logging/Logging.module.css
delete mode 100644 src/pages/Logging/Logging.tsx
diff --git a/src/assets/data.ts b/src/assets/data.ts
deleted file mode 100644
index c1eacfb..0000000
--- a/src/assets/data.ts
+++ /dev/null
@@ -1,361 +0,0 @@
-export const DATA: LogEntry[] = [
- {
- id: "1",
- timestamp: "2025-10-01T12:00:00Z",
- level: "info",
- msg: "User said: Hello, Pepper!",
- type: "speech",
- },
- {
- id: "2",
- timestamp: "2025-10-01T12:00:05Z",
- level: "debug",
- msg: "Proximity sensor value: 0.85",
- type: "sensor",
- },
- {
- id: "3",
- timestamp: "2025-10-01T12:00:10Z",
- level: "warn",
- msg: "Battery level low: 15%",
- type: "system",
- },
- {
- id: "4",
- timestamp: "2025-10-01T12:00:15Z",
- level: "info",
- msg: "User requested weather update.",
- type: "speech",
- },
- {
- id: "5",
- timestamp: "2025-10-01T12:00:20Z",
- level: "debug",
- msg: "Microphone activated.",
- type: "system",
- },
- {
- id: "6",
- timestamp: "2025-10-01T12:00:25Z",
- level: "warn",
- msg: "Obstacle detected in front.",
- type: "sensor",
- },
- {
- id: "7",
- timestamp: "2025-10-01T12:00:30Z",
- level: "info",
- msg: "User said: Thank you!",
- type: "speech",
- },
- {
- id: "8",
- timestamp: "2025-10-01T12:00:35Z",
- level: "debug",
- msg: "Network latency: 120ms",
- type: "system",
- },
- {
- id: "9",
- timestamp: "2025-10-01T12:00:40Z",
- level: "warn",
- msg: "High CPU usage detected.",
- type: "system",
- },
- {
- id: "10",
- timestamp: "2025-10-01T12:00:45Z",
- level: "info",
- msg: "User started a new session.",
- type: "system",
- },
- {
- id: "11",
- timestamp: "2025-10-01T12:01:00Z",
- level: "info",
- msg: "User asked: What's the weather?",
- type: "speech",
- },
- {
- id: "12",
- timestamp: "2025-10-01T12:01:05Z",
- level: "debug",
- msg: "Camera initialized.",
- type: "system",
- },
- {
- id: "13",
- timestamp: "2025-10-01T12:01:10Z",
- level: "warn",
- msg: "Temperature sensor disconnected.",
- type: "sensor",
- },
- {
- id: "14",
- timestamp: "2025-10-01T12:01:15Z",
- level: "info",
- msg: "User said: Play some music.",
- type: "speech",
- },
- {
- id: "15",
- timestamp: "2025-10-01T12:01:20Z",
- level: "debug",
- msg: "Audio output device selected: Speaker.",
- type: "system",
- },
- {
- id: "16",
- timestamp: "2025-10-01T12:01:25Z",
- level: "warn",
- msg: "Low light detected in room.",
- type: "sensor",
- },
- {
- id: "17",
- timestamp: "2025-10-01T12:01:30Z",
- level: "info",
- msg: "User said: Turn on the lights.",
- type: "speech",
- },
- {
- id: "18",
- timestamp: "2025-10-01T12:01:35Z",
- level: "debug",
- msg: "Light control signal sent.",
- type: "system",
- },
- {
- id: "19",
- timestamp: "2025-10-01T12:01:40Z",
- level: "warn",
- msg: "Light bulb not responding.",
- type: "system",
- },
- {
- id: "20",
- timestamp: "2025-10-01T12:01:45Z",
- level: "info",
- msg: "User said: Good night.",
- type: "speech",
- },
- {
- id: "21",
- timestamp: "2025-10-01T12:02:00Z",
- level: "info",
- msg: "User asked: What's the time?",
- type: "speech",
- },
- {
- id: "22",
- timestamp: "2025-10-01T12:02:05Z",
- level: "debug",
- msg: "Time module loaded.",
- type: "system",
- },
- {
- id: "23",
- timestamp: "2025-10-01T12:02:10Z",
- level: "warn",
- msg: "WiFi signal weak.",
- type: "system",
- },
- {
- id: "24",
- timestamp: "2025-10-01T12:02:15Z",
- level: "info",
- msg: "User said: Set an alarm for 7 AM.",
- type: "speech",
- },
- {
- id: "25",
- timestamp: "2025-10-01T12:02:20Z",
- level: "debug",
- msg: "Alarm scheduled for 7:00 AM.",
- type: "system",
- },
- {
- id: "26",
- timestamp: "2025-10-01T12:02:25Z",
- level: "warn",
- msg: "Alarm module not responding.",
- type: "system",
- },
- {
- id: "27",
- timestamp: "2025-10-01T12:02:30Z",
- level: "info",
- msg: "User said: Cancel the alarm.",
- type: "speech",
- },
- {
- id: "28",
- timestamp: "2025-10-01T12:02:35Z",
- level: "debug",
- msg: "Alarm cancellation requested.",
- type: "system",
- },
- {
- id: "29",
- timestamp: "2025-10-01T12:02:40Z",
- level: "warn",
- msg: "Alarm cancellation failed.",
- type: "system",
- },
- {
- id: "30",
- timestamp: "2025-10-01T12:02:45Z",
- level: "info",
- msg: "User said: Open the window.",
- type: "speech",
- },
- {
- id: "31",
- timestamp: "2025-10-01T12:03:00Z",
- level: "info",
- msg: "User asked: What's on my calendar?",
- type: "speech",
- },
- {
- id: "32",
- timestamp: "2025-10-01T12:03:05Z",
- level: "debug",
- msg: "Calendar module loaded.",
- type: "system",
- },
- {
- id: "33",
- timestamp: "2025-10-01T12:03:10Z",
- level: "warn",
- msg: "Calendar sync failed.",
- type: "system",
- },
- {
- id: "34",
- timestamp: "2025-10-01T12:03:15Z",
- level: "info",
- msg: "User said: Remind me to call John.",
- type: "speech",
- },
- {
- id: "35",
- timestamp: "2025-10-01T12:03:20Z",
- level: "debug",
- msg: "Reminder set for John.",
- type: "system",
- },
- {
- id: "36",
- timestamp: "2025-10-01T12:03:25Z",
- level: "warn",
- msg: "Reminder module not available.",
- type: "system",
- },
- {
- id: "37",
- timestamp: "2025-10-01T12:03:30Z",
- level: "info",
- msg: "User said: What's the news?",
- type: "speech",
- },
- {
- id: "38",
- timestamp: "2025-10-01T12:03:35Z",
- level: "debug",
- msg: "News API request sent.",
- type: "system",
- },
- {
- id: "39",
- timestamp: "2025-10-01T12:03:40Z",
- level: "warn",
- msg: "News API rate limit reached.",
- type: "system",
- },
- {
- id: "40",
- timestamp: "2025-10-01T12:03:45Z",
- level: "info",
- msg: "User said: Tell me a joke.",
- type: "speech",
- },
- {
- id: "41",
- timestamp: "2025-10-01T12:04:00Z",
- level: "info",
- msg: "User asked: What's the temperature?",
- type: "speech",
- },
- {
- id: "42",
- timestamp: "2025-10-01T12:04:05Z",
- level: "debug",
- msg: "Temperature sensor reading: 22°C.",
- type: "sensor",
- },
- {
- id: "43",
- timestamp: "2025-10-01T12:04:10Z",
- level: "warn",
- msg: "Temperature sensor calibration needed.",
- type: "sensor",
- },
- {
- id: "44",
- timestamp: "2025-10-01T12:04:15Z",
- level: "info",
- msg: "User said: Start cleaning.",
- type: "speech",
- },
- {
- id: "45",
- timestamp: "2025-10-01T12:04:20Z",
- level: "debug",
- msg: "Vacuum motor started.",
- type: "system",
- },
- {
- id: "46",
- timestamp: "2025-10-01T12:04:25Z",
- level: "warn",
- msg: "Vacuum bin full.",
- type: "system",
- },
- {
- id: "47",
- timestamp: "2025-10-01T12:04:30Z",
- level: "info",
- msg: "User said: Stop cleaning.",
- type: "speech",
- },
- {
- id: "48",
- timestamp: "2025-10-01T12:04:35Z",
- level: "debug",
- msg: "Vacuum motor stopped.",
- type: "system",
- },
- {
- id: "49",
- timestamp: "2025-10-01T12:04:40Z",
- level: "warn",
- msg: "Obstacle detected during cleaning.",
- type: "sensor",
- },
- {
- id: "50",
- timestamp: "2025-10-01T12:04:45Z",
- level: "info",
- msg: "User said: Goodbye!",
- type: "speech",
- },
-];
-
-interface LogEntry {
- id: string;
- type?: string;
- timestamp: string;
- level: "info" | "debug" | "warn";
- msg?: string;
-}
-
diff --git a/src/pages/Logging/Logging.module.css b/src/pages/Logging/Logging.module.css
deleted file mode 100644
index 52191c3..0000000
--- a/src/pages/Logging/Logging.module.css
+++ /dev/null
@@ -1,17 +0,0 @@
-.DivToScroll{
- background-color: color-mix(in srgb, canvas, #000 5%);
- border: 1px solid color-mix(in srgb, canvas, #000 15%);
- border-radius: 4px 0 4px 0;
- color: #3B3C3E;
- font-size: 12px;
- font-weight: bold;
- left: -1px;
- padding: 10px 7px 5px;
-}
-
-.DivWithScroll{
- height:50vh;
- width:100vh;
- overflow:scroll;
- overflow-x:hidden;
-}
diff --git a/src/pages/Logging/Logging.tsx b/src/pages/Logging/Logging.tsx
deleted file mode 100644
index 31945c1..0000000
--- a/src/pages/Logging/Logging.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import { useState } from 'react';
-import { DATA } from "../../assets/data";
-import styles from './Logging.module.css';
-
-
-// const dataType = DATA as { id: string; level: "debug"|"info"|"warn"|"error"; msg: string; timestamp?: string };
-type Level = "debug" | "info" | "warn" | "error";
-
-// make optional fields optional
-type LogEntry = {
- id: string;
- level: Level;
- timestamp?: string;
- msg?: string;
- type?: "speech" | "sensor" | "system" | string;
-
-};
-
-function getLevelColor(level: Level) {
- switch (level) {
- case "debug":
- return "gray";
- case "info":
- return "blue";
- case "warn":
- return "red";
- case "error":
- return "red";
- default:
- return "black";
- }
-}
-
-function Logging() {
- const [logs, setLogs] = useState([]);
-
- const logDiv = (
-
-
- {logs.map((log) => (
-
-
- [{log.timestamp}]
-
-
- {log.msg ? log.msg : "No message"}
-
-
- ({log.level})
-
-
- ))}
-
-
- )
- return (
- <>
- Log Screen
- { logDiv }
-
- setLogs(DATA)}>
- Load sample logs
-
-
-
- >
- )
-}
-
-export default Logging
From 87cf723c95d2f0e235a53ce6d2d42273f80f8dea Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?=
Date: Tue, 11 Nov 2025 11:42:28 +0100
Subject: [PATCH 14/17] chore: fixed merge request suggestion for adding
depency array
---
src/pages/ConnectedRobots/ConnectedRobots.tsx | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/src/pages/ConnectedRobots/ConnectedRobots.tsx b/src/pages/ConnectedRobots/ConnectedRobots.tsx
index 8b4b898..b7ec65f 100644
--- a/src/pages/ConnectedRobots/ConnectedRobots.tsx
+++ b/src/pages/ConnectedRobots/ConnectedRobots.tsx
@@ -4,8 +4,6 @@ export default function ConnectedRobots() {
const [connected, setConnected] = useState(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");
@@ -29,7 +27,7 @@ export default function ConnectedRobots() {
}
};
return () => eventSource.close();
- });
+ }, []);
return (
From 231d7a5ba151ddf3c43abd3bfd7a8646236d9b8e Mon Sep 17 00:00:00 2001
From: Twirre
Date: Wed, 12 Nov 2025 14:35:38 +0000
Subject: [PATCH 15/17] Add logging with filters
---
eslint.config.js | 33 ++-
src/App.css | 86 +++++-
src/App.tsx | 30 +-
src/components/Logging/Filters.module.css | 34 +++
src/components/Logging/Filters.tsx | 200 +++++++++++++
src/components/Logging/Logging.module.css | 39 +++
src/components/Logging/Logging.tsx | 129 +++++++++
src/components/Logging/useLogs.ts | 146 ++++++++++
src/components/ScrollIntoView.tsx | 14 +
src/index.css | 17 +-
src/pages/VisProgPage/VisProg.module.css | 16 +-
src/pages/VisProgPage/VisProg.tsx | 46 ++-
src/utils/cellStore.ts | 29 ++
src/utils/formatDuration.ts | 21 ++
src/utils/priorityFiltering.ts | 24 ++
test/components/Logging/Filters.test.tsx | 328 ++++++++++++++++++++++
test/components/Logging/Logging.test.tsx | 239 ++++++++++++++++
test/components/Logging/useLogs.test.tsx | 246 ++++++++++++++++
test/eslint.config.js.ts | 0
test/utils/cellStore.test.tsx | 156 ++++++++++
test/utils/formatDuration.test.ts | 53 ++++
test/utils/priorityFiltering.test.ts | 81 ++++++
22 files changed, 1899 insertions(+), 68 deletions(-)
create mode 100644 src/components/Logging/Filters.module.css
create mode 100644 src/components/Logging/Filters.tsx
create mode 100644 src/components/Logging/Logging.module.css
create mode 100644 src/components/Logging/Logging.tsx
create mode 100644 src/components/Logging/useLogs.ts
create mode 100644 src/components/ScrollIntoView.tsx
create mode 100644 src/utils/cellStore.ts
create mode 100644 src/utils/formatDuration.ts
create mode 100644 src/utils/priorityFiltering.ts
create mode 100644 test/components/Logging/Filters.test.tsx
create mode 100644 test/components/Logging/Logging.test.tsx
create mode 100644 test/components/Logging/useLogs.test.tsx
create mode 100644 test/eslint.config.js.ts
create mode 100644 test/utils/cellStore.test.tsx
create mode 100644 test/utils/formatDuration.test.ts
create mode 100644 test/utils/priorityFiltering.test.ts
diff --git a/eslint.config.js b/eslint.config.js
index b19330b..cd2d447 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,23 +1,38 @@
-import js from '@eslint/js'
-import globals from 'globals'
-import reactHooks from 'eslint-plugin-react-hooks'
-import reactRefresh from 'eslint-plugin-react-refresh'
-import tseslint from 'typescript-eslint'
-import { defineConfig, globalIgnores } from 'eslint/config'
+import js from "@eslint/js"
+import globals from "globals"
+import reactHooks from "eslint-plugin-react-hooks"
+import reactRefresh from "eslint-plugin-react-refresh"
+import tseslint from "typescript-eslint"
+import { defineConfig, globalIgnores } from "eslint/config"
export default defineConfig([
- globalIgnores(['dist']),
+ globalIgnores(["dist"]),
{
- files: ['**/*.{ts,tsx}'],
+ files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
- reactHooks.configs['recommended-latest'],
+ reactHooks.configs["recommended-latest"],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
+ rules: {
+ "@typescript-eslint/no-unused-vars": [
+ "warn",
+ {
+ argsIgnorePattern: "^_",
+ varsIgnorePattern: "^_",
+ },
+ ],
+ },
+ },
+ {
+ files: ["test/**/*.{ts,tsx}"],
+ rules: {
+ "@typescript-eslint/no-explicit-any": "off",
+ },
},
])
diff --git a/src/App.css b/src/App.css
index ab28aa0..8ce14c8 100644
--- a/src/App.css
+++ b/src/App.css
@@ -82,6 +82,10 @@ button.movePage:hover{
}
+#root {
+ display: flex;
+ flex-direction: column;
+}
header {
position: sticky;
@@ -96,6 +100,7 @@ header {
align-items: center;
justify-content: center;
+ background-color: var(--accent-color);
backdrop-filter: blur(10px);
z-index: 1; /* Otherwise any translated elements render above the blur?? */
}
@@ -121,6 +126,14 @@ main {
flex-wrap: wrap;
}
+.min-height-0 {
+ min-height: 0;
+}
+
+.scroll-y {
+ overflow-y: scroll;
+}
+
.align-center {
align-items: center;
}
@@ -141,6 +154,10 @@ main {
gap: 1rem;
}
+.margin-0 {
+ margin: 0;
+}
+
.padding-sm {
padding: .25rem;
}
@@ -150,7 +167,19 @@ main {
.padding-lg {
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 {
border-radius: .25rem;
}
@@ -159,4 +188,59 @@ main {
}
.round-lg {
border-radius: 1rem;
-}
\ No newline at end of file
+}
+
+.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;
+ }
+}
diff --git a/src/App.tsx b/src/App.tsx
index 968c979..70fc815 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -4,23 +4,31 @@ import TemplatePage from './pages/TemplatePage/Template.tsx'
import Home from './pages/Home/Home.tsx'
import Robot from './pages/Robot/Robot.tsx';
import VisProg from "./pages/VisProgPage/VisProg.tsx";
+import {useState} from "react";
+import Logging from "./components/Logging/Logging.tsx";
function App(){
+ const [showLogs, setShowLogs] = useState(false);
+
return (
-
+ <>
Home
+ setShowLogs(!showLogs)}>Toggle Logging
-
-
- } />
- } />
- } />
- } />
-
-
-
- )
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+ {showLogs && }
+
+ >
+ );
}
export default App
diff --git a/src/components/Logging/Filters.module.css b/src/components/Logging/Filters.module.css
new file mode 100644
index 0000000..405560c
--- /dev/null
+++ b/src/components/Logging/Filters.module.css
@@ -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;
+ }
+}
diff --git a/src/components/Logging/Filters.tsx b/src/components/Logging/Filters.tsx
new file mode 100644
index 0000000..446a9c6
--- /dev/null
+++ b/src/components/Logging/Filters.tsx
@@ -0,0 +1,200 @@
+import {useEffect, useRef, useState} from "react";
+
+import type {LogFilterPredicate} from "./useLogs.ts";
+
+import styles from "./Filters.module.css";
+
+type Setter = (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
+
+ {onDelete
+ ? {normalizedName}:
+ : normalizedName + ':'
+ }
+
+ setLevel(e.target.value)}
+ >
+ {Array.from(optionMapping.keys()).map((key) => (
+ {key}
+ ))}
+
+
+}
+
+const GLOBAL_LOG_LEVEL_PREDICATE_KEY = "global_log_level";
+
+function GlobalLevelFilter({
+ filterPredicates,
+ setFilterPredicates,
+}: {
+ filterPredicates: Map;
+ setFilterPredicates: Setter>;
+}) {
+ 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 ;
+}
+
+const AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX = "agent_log_level_";
+
+function AgentLevelFilters({
+ filterPredicates,
+ setFilterPredicates,
+ agentNames,
+}: {
+ filterPredicates: Map;
+ setFilterPredicates: Setter>;
+ agentNames: Set;
+}) {
+ const rootRef = useRef(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 setAgentPredicate(agentName, level)}
+ onDelete={() => deleteAgentPredicate(agentName)}
+ />;
+ })}
+
+ Add:
+ !!e.target.value && setAgentPredicate(e.target.value)}
+ >
+ {["", ...agentNames.keys()].map((key) => (
+ {key.split(".").pop()}
+ ))}
+
+
+ >;
+}
+
+export default function Filters({
+ filterPredicates,
+ setFilterPredicates,
+ agentNames,
+}: {
+ filterPredicates: Map;
+ setFilterPredicates: Setter>;
+ agentNames: Set;
+}) {
+ return ;
+}
diff --git a/src/components/Logging/Logging.module.css b/src/components/Logging/Logging.module.css
new file mode 100644
index 0000000..6fc2988
--- /dev/null
+++ b/src/components/Logging/Logging.module.css
@@ -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);
+}
\ No newline at end of file
diff --git a/src/components/Logging/Logging.tsx b/src/components/Logging/Logging.tsx
new file mode 100644
index 0000000..ede0bcc
--- /dev/null
+++ b/src/components/Logging/Logging.tsx
@@ -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((set) => ({
+ showRelativeTime: false,
+ setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }),
+ scrollToBottom: true,
+ setScrollToBottom: (scrollToBottom: boolean) => set({ scrollToBottom }),
+}));
+
+function LogMessage({
+ recordCell,
+ onUpdate,
+}: {
+ recordCell: Cell,
+ 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
+
+ {record.levelname}
+ setShowRelativeTime(!showRelativeTime)}
+ >{showRelativeTime
+ ? formatDuration(record.relativeCreated)
+ : new Date(record.created * 1000).toLocaleTimeString()
+ }
+
+
+ {normalizedName}
+ {record.message}
+
+
;
+}
+
+function LogMessages({ recordCells }: { recordCells: Cell[] }) {
+ const scrollableRef = useRef(null);
+ const lastElementRef = useRef(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
+
+ {recordCells.map((recordCell, i) => (
+
+
+
+ ))}
+
+
+ {!scrollToBottom &&
{
+ setScrollToBottom(true);
+ scrollLastElementIntoView(true);
+ }}
+ >
+ Scroll to bottom
+ }
+
;
+}
+
+export default function Logging() {
+ const [filterPredicates, setFilterPredicates] = useState(new Map());
+ const { filteredLogs, distinctNames } = useLogs(filterPredicates)
+
+ return ;
+}
diff --git a/src/components/Logging/useLogs.ts b/src/components/Logging/useLogs.ts
new file mode 100644
index 0000000..76eed92
--- /dev/null
+++ b/src/components/Logging/useLogs.ts
@@ -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 & { value: any };
+
+export function useLogs(filterPredicates: Map) {
+ const [distinctNames, setDistinctNames] = useState>(new Set());
+ const [filtered, setFiltered] = useState[]>([]);
+
+ const sseRef = useRef(null);
+ const filtersRef = useRef(filterPredicates);
+ const logsRef = useRef([]);
+
+ /** Map to store the first message for each reference, instance can be updated to change contents. */
+ const firstByRefRef = useRef>>(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[] = [];
+ firstByRefRef.current = new Map();
+
+ for (const message of logsRef.current) {
+ const messageCell = cell({
+ ...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({
+ ...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};
+}
diff --git a/src/components/ScrollIntoView.tsx b/src/components/ScrollIntoView.tsx
new file mode 100644
index 0000000..bcbc7d4
--- /dev/null
+++ b/src/components/ScrollIntoView.tsx
@@ -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(null);
+
+ useEffect(() => {
+ if (elementRef.current) elementRef.current.scrollIntoView({ behavior: "smooth" });
+ });
+
+ return ;
+}
diff --git a/src/index.css b/src/index.css
index 4d39cfb..986e666 100644
--- a/src/index.css
+++ b/src/index.css
@@ -7,13 +7,15 @@
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
+ --accent-color: #008080;
+
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
-html, body {
+html, body, #root {
margin: 0;
padding: 0;
@@ -25,11 +27,7 @@ html, body {
a {
font-weight: 500;
- color: #646cff;
- text-decoration: inherit;
-}
-a:hover {
- color: #535bf2;
+ color: canvastext;
}
h1 {
@@ -49,7 +47,7 @@ button {
transition: border-color 0.25s;
}
button:hover {
- border-color: #646cff;
+ border-color: var(--accent-color);
}
button:focus,
button:focus-visible {
@@ -60,9 +58,8 @@ button:focus-visible {
:root {
color: #213547;
background-color: #ffffff;
- }
- a:hover {
- color: #747bff;
+
+ --accent-color: #00AAAA;
}
button {
background-color: #f9f9f9;
diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css
index c58d0f3..34a6ecc 100644
--- a/src/pages/VisProgPage/VisProg.module.css
+++ b/src/pages/VisProgPage/VisProg.module.css
@@ -1,19 +1,9 @@
/* 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 {
- outline-style: solid;
- border-radius: 10pt;
- width: 90%;
+ box-sizing: border-box;
+ margin: 1rem;
+ width: calc(100% - 2rem);
height: 100%;
}
diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx
index 8208a70..829bbfc 100644
--- a/src/pages/VisProgPage/VisProg.tsx
+++ b/src/pages/VisProgPage/VisProg.tsx
@@ -80,30 +80,28 @@ const VisProgUI = () => {
} = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore
return (
-
-
-
-
- {/* contains the drag and drop panel for nodes */}
-
-
-
-
-
+
+
+
+ {/* contains the drag and drop panel for nodes */}
+
+
+
+
);
};
diff --git a/src/utils/cellStore.ts b/src/utils/cellStore.ts
new file mode 100644
index 0000000..eb64907
--- /dev/null
+++ b/src/utils/cellStore.ts
@@ -0,0 +1,29 @@
+import {useSyncExternalStore} from "react";
+
+type Unsub = () => void;
+
+export type Cell = {
+ get: () => T;
+ set: (next: T | ((prev: T) => T)) => void;
+ subscribe: (callback: () => void) => Unsub;
+};
+
+export function cell(initial: T): Cell {
+ 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(c: Cell) {
+ return useSyncExternalStore(c.subscribe, c.get, c.get);
+}
diff --git a/src/utils/formatDuration.ts b/src/utils/formatDuration.ts
new file mode 100644
index 0000000..2e9f88d
--- /dev/null
+++ b/src/utils/formatDuration.ts
@@ -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')}`;
+}
diff --git a/src/utils/priorityFiltering.ts b/src/utils/priorityFiltering.ts
new file mode 100644
index 0000000..7638f34
--- /dev/null
+++ b/src/utils/priorityFiltering.ts
@@ -0,0 +1,24 @@
+export type PriorityFilterPredicate = {
+ 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(element: T, predicates: PriorityFilterPredicate[]): 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;
+}
diff --git a/test/components/Logging/Filters.test.tsx b/test/components/Logging/Filters.test.tsx
new file mode 100644
index 0000000..9d5e40b
--- /dev/null
+++ b/test/components/Logging/Filters.test.tsx
@@ -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(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();
+
+ const view = render(
+ ()}
+ />
+ );
+
+ // Effect sets default to INFO
+ await waitFor(() => {
+ expect(setFilterPredicates).toHaveBeenCalled();
+ });
+
+ const updater = getCallArg<(prev: Map) => Map>(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(
+ ()}
+ />
+ );
+
+ 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([
+ [
+ GLOBAL,
+ {
+ value: "INFO",
+ priority: 0,
+ predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
+ }
+ ]
+ ]);
+
+ const setFilterPredicates = jest.fn();
+ const user = userEvent.setup();
+
+ render(
+ ()}
+ />
+ );
+
+ const select = screen.getByLabelText("Global:");
+ await user.selectOptions(select, "ERROR");
+
+ const updater = getCallArg<(prev: Map) => Map>(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([
+ [
+ GLOBAL,
+ {
+ value: "WARNING",
+ priority: 0,
+ predicate: (r: any) => r.levelno >= optionMapping.get("WARNING")!
+ }
+ ]
+ ]);
+
+ const setFilterPredicates = jest.fn();
+ const user = userEvent.setup();
+
+ render(
+ (["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) => Map>(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([
+ [
+ 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(
+
+ );
+
+ const agentSelect = element.container.querySelector("select#log_level_pepper\\.speech")!;
+
+ await user.selectOptions(agentSelect, "ERROR");
+
+ const updater = getCallArg<(prev: Map) => Map>(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([
+ [
+ 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(
+ (["pepper.speech"])}
+ />
+ );
+
+ const deleteBtn = screen.getByRole("button", {name: "speech:"});
+ await user.click(deleteBtn);
+
+ const updater = getCallArg<(prev: Map) => Map>(setFilterPredicates);
+ const next = updater(existing);
+ expect(next.has(key)).toBe(false);
+ });
+ });
+
+ describe("Filter popup behavior", () => {
+ function renderWithPopupOpen() {
+ const existing = new Map([
+ [
+ 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(
+
+ );
+
+ 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);
+ });
+ });
+});
diff --git a/test/components/Logging/Logging.test.tsx b/test/components/Logging/Logging.test.tsx
new file mode 100644
index 0000000..03d4a92
--- /dev/null
+++ b/test/components/Logging/Logging.test.tsx
@@ -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) => 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;
+
+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 {
+ return {
+ name: "pepper.logger",
+ message: "default",
+ levelname: "INFO",
+ levelno: 20,
+ created: 1,
+ relativeCreated: 1,
+ firstCreated: 1,
+ firstRelativeCreated: 1,
+ ...overrides,
+ };
+}
+
+function makeCell(overrides: Partial = {}): Cell {
+ 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( );
+
+ 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( );
+
+ 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( );
+
+ 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) => ({
+ filteredLogs: [],
+ distinctNames: distinct,
+ }));
+
+ render( );
+
+ 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) => {
+ 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);
+ });
+});
diff --git a/test/components/Logging/useLogs.test.tsx b/test/components/Logging/useLogs.test.tsx
new file mode 100644
index 0000000..30a7c2d
--- /dev/null
+++ b/test/components/Logging/useLogs.test.tsx
@@ -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 }) {
+ const { filteredLogs, distinctNames } = useLogs(filters);
+
+ return (
+
+ {distinctNames.size}
+
+ {filteredLogs.map((c, i) => (
+
+ ))}
+
+
+ );
+}
+
+function LogItem({ cell: c, index }: { cell: ReturnType>; index: number }) {
+ const value = useCell(c);
+ return (
+
+ {value.name}
+ {value.message}
+ {String(value.firstCreated)}
+ {String(value.created)}
+ {value.reference ?? ""}
+
+ );
+}
+
+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(
+
+
+
+ );
+ 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(
+
+
+
+ );
+
+ 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( );
+
+ // 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();
+ const { rerender } = render( );
+
+ 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([["dummy", { value: true }]]);
+ rerender( );
+
+ // 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( );
+
+ // recompute should restore all three
+ expect(screen.getAllByRole("listitem")).toHaveLength(3);
+ });
+});
diff --git a/test/eslint.config.js.ts b/test/eslint.config.js.ts
new file mode 100644
index 0000000..e69de29
diff --git a/test/utils/cellStore.test.tsx b/test/utils/cellStore.test.tsx
new file mode 100644
index 0000000..96460b8
--- /dev/null
+++ b/test/utils/cellStore.test.tsx
@@ -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; label: string }) {
+ const v = useCell(c);
+ // count renders to verify re-render behavior
+ (View as any).__renders = ((View as any).__renders ?? 0) + 1;
+ return {String(v)} ;
+ }
+
+ it("reads initial value and updates on set", () => {
+ const c = cell("hello");
+
+ render( );
+
+ 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( );
+
+ 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( );
+
+ 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 {v} ;
+ }
+
+ function B() {
+ const v = useCell(b);
+ rendersB++;
+ return {v} ;
+ }
+
+ render(
+ <>
+
+
+ >
+ );
+
+ 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
+ });
+});
diff --git a/test/utils/formatDuration.test.ts b/test/utils/formatDuration.test.ts
new file mode 100644
index 0000000..b686a43
--- /dev/null
+++ b/test/utils/formatDuration.test.ts
@@ -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");
+ });
+});
diff --git a/test/utils/priorityFiltering.test.ts b/test/utils/priorityFiltering.test.ts
new file mode 100644
index 0000000..6cc8789
--- /dev/null
+++ b/test/utils/priorityFiltering.test.ts
@@ -0,0 +1,81 @@
+import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../src/utils/priorityFiltering";
+
+const makePred = (priority: number, fn: (el: T) => boolean | null): PriorityFilterPredicate => ({
+ 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(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(1, (_) => false);
+ const lowPass = makePred(1, (_) => true);
+ const highPass = makePred(10, (n) => n > 0);
+ const highFail = makePred(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(5, (n) => n % 2 === 0);
+ const high2 = makePred(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(1, (_) => false);
+ const highTrue = makePred(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(1, (_) => false);
+ const p3 = makePred(3, (_) => false);
+ const p5 = makePred(5, (n) => n > 0);
+ expect(applyPriorityPredicates(1, [p1, p3, p5])).toBe(true);
+ });
+
+ it("skips predicates that return null", () => {
+ const high = makePred(10, (n) => n === 0 ? true : null);
+ const low = makePred(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(0, (_) => false);
+ const high1 = makePred(5, (n) => n % 2 === 0);
+ const high2 = makePred(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(0, (_) => false);
+ const high = makePred(5, (n) => n === 3 ? true : null);
+ const result = elems.filter((e) => applyPriorityPredicates(e, [low, high]));
+ expect(result).toEqual([3]);
+ });
+});
From aeaf526797f1ea96ce9921923bf3c87831281a48 Mon Sep 17 00:00:00 2001
From: Twirre
Date: Thu, 13 Nov 2025 10:50:12 +0000
Subject: [PATCH 16/17] Make nodes editable: norms, goals and keyword triggers
---
src/App.css | 4 +
src/components/TextField.module.css | 27 ++++
src/components/TextField.tsx | 101 +++++++++++++
src/pages/VisProgPage/VisProg.module.css | 26 ++++
src/pages/VisProgPage/VisProg.tsx | 9 +-
.../visualProgrammingUI/GraphReducer.ts | 37 +++--
.../visualProgrammingUI/GraphReducerTypes.ts | 20 ++-
.../visualProgrammingUI/VisProgTypes.tsx | 7 +-
.../components/DragDropSidebar.tsx | 48 +++++-
.../components/NodeDefinitions.tsx | 143 ++++++++++--------
.../components/TriggerNodeComponent.tsx | 121 +++++++++++++++
src/utils/duplicateIndices.ts | 19 +++
.../visualProgrammingUI/GraphReducer.test.ts | 96 ++++++++++--
test/utils/duplicateIndices.test.ts | 22 +++
14 files changed, 580 insertions(+), 100 deletions(-)
create mode 100644 src/components/TextField.module.css
create mode 100644 src/components/TextField.tsx
create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/TriggerNodeComponent.tsx
create mode 100644 src/utils/duplicateIndices.ts
create mode 100644 test/utils/duplicateIndices.test.ts
diff --git a/src/App.css b/src/App.css
index 8ce14c8..a241d03 100644
--- a/src/App.css
+++ b/src/App.css
@@ -109,6 +109,10 @@ main {
padding: 1rem 0;
}
+input[type="checkbox"] {
+ cursor: pointer;
+}
+
.flex-row {
display: flex;
flex-direction: row;
diff --git a/src/components/TextField.module.css b/src/components/TextField.module.css
new file mode 100644
index 0000000..de66531
--- /dev/null
+++ b/src/components/TextField.module.css
@@ -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%);
+}
diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx
new file mode 100644
index 0000000..58de55d
--- /dev/null
+++ b/src/components/TextField.tsx
@@ -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) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); };
+
+ return 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 ;
+}
diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css
index 34a6ecc..e19b34a 100644
--- a/src/pages/VisProgPage/VisProg.module.css
+++ b/src/pages/VisProgPage/VisProg.module.css
@@ -71,6 +71,16 @@
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 {
outline: dodgerblue solid 2pt;
filter: drop-shadow(0 0 0.25rem dodgerblue);
@@ -102,6 +112,22 @@
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 {
padding: 3px 10px;
background-color: canvas;
diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx
index 829bbfc..1dd1804 100644
--- a/src/pages/VisProgPage/VisProg.tsx
+++ b/src/pages/VisProgPage/VisProg.tsx
@@ -13,13 +13,15 @@ import {
StartNodeComponent,
EndNodeComponent,
PhaseNodeComponent,
- NormNodeComponent
+ NormNodeComponent,
+ GoalNodeComponent,
} from './visualProgrammingUI/components/NodeDefinitions.tsx';
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
import graphReducer from "./visualProgrammingUI/GraphReducer.ts";
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
import styles from './VisProg.module.css'
+import TriggerNodeComponent from "./visualProgrammingUI/components/TriggerNodeComponent.tsx";
// --| config starting params for flow |--
@@ -30,7 +32,9 @@ const NODE_TYPES = {
start: StartNodeComponent,
end: EndNodeComponent,
phase: PhaseNodeComponent,
- norm: NormNodeComponent
+ norm: NormNodeComponent,
+ goal: GoalNodeComponent,
+ trigger: TriggerNodeComponent,
};
/**
@@ -126,6 +130,7 @@ function VisualProgrammingUI() {
function runProgram() {
const program = graphReducer();
console.log(program);
+ console.log(JSON.stringify(program, null, 2));
}
/**
diff --git a/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts
index 138eb82..6a4ee55 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts
+++ b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts
@@ -15,14 +15,15 @@ import type {
Phase,
PhaseReducer,
PreparedGraph,
- PreparedPhase
+ PreparedPhase, Reduced, TriggerReducer
} from "./GraphReducerTypes.ts";
import type {
AppNode,
GoalNode,
NormNode,
- PhaseNode
+ PhaseNode, TriggerNode
} from "./VisProgTypes.tsx";
+import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx";
/**
* Reduces the current graph inside the visual programming editor into a BehaviorProgram
@@ -31,13 +32,15 @@ import type {
* @param {PhaseReducer} phaseReducer
* @param {NormReducer} normReducer
* @param {GoalReducer} goalReducer
+ * @param {TriggerReducer} triggerReducer
* @returns {BehaviorProgram}
*/
export default function graphReducer(
graphPreprocessor: GraphPreprocessor = defaultGraphPreprocessor,
phaseReducer: PhaseReducer = defaultPhaseReducer,
normReducer: NormReducer = defaultNormReducer,
- goalReducer: GoalReducer = defaultGoalReducer
+ goalReducer: GoalReducer = defaultGoalReducer,
+ triggerReducer: TriggerReducer = defaultTriggerReducer,
) : BehaviorProgram {
const nodes: AppNode[] = useFlowStore.getState().nodes;
const edges: Edge[] = useFlowStore.getState().edges;
@@ -47,7 +50,8 @@ export default function graphReducer(
phaseReducer(
preparedPhase,
normReducer,
- goalReducer
+ goalReducer,
+ triggerReducer,
));
};
@@ -58,12 +62,14 @@ export default function graphReducer(
* @param {PreparedPhase} phase
* @param {NormReducer} normReducer
* @param {GoalReducer} goalReducer
+ * @param {TriggerReducer} triggerReducer
* @returns {Phase}
*/
export function defaultPhaseReducer(
phase: PreparedPhase,
normReducer: NormReducer = defaultNormReducer,
- goalReducer: GoalReducer = defaultGoalReducer
+ goalReducer: GoalReducer = defaultGoalReducer,
+ triggerReducer: TriggerReducer = defaultTriggerReducer,
) : Phase {
return {
id: phase.phaseNode.id,
@@ -71,7 +77,8 @@ export function defaultPhaseReducer(
nextPhaseId: phase.nextPhaseId,
phaseData: {
norms: phase.connectedNorms.map(normReducer),
- goals: phase.connectedGoals.map(goalReducer)
+ goals: phase.connectedGoals.map(goalReducer),
+ triggers: phase.connectedTriggers.map(triggerReducer),
}
}
}
@@ -82,11 +89,12 @@ export function defaultPhaseReducer(
* @param {GoalNode} node
* @returns {GoalData}
*/
-function defaultGoalReducer(node: GoalNode) : GoalData {
+function defaultGoalReducer(node: GoalNode) : Reduced {
return {
id: node.id,
name: node.data.label,
- value: node.data.value
+ description: node.data.description,
+ achieved: node.data.achieved,
}
}
@@ -96,7 +104,7 @@ function defaultGoalReducer(node: GoalNode) : GoalData {
* @param {NormNode} node
* @returns {NormData}
*/
-function defaultNormReducer(node: NormNode) :NormData {
+function defaultNormReducer(node: NormNode) :Reduced {
return {
id: node.id,
name: node.data.label,
@@ -104,6 +112,13 @@ function defaultNormReducer(node: NormNode) :NormData {
}
}
+function defaultTriggerReducer(node: TriggerNode): Reduced {
+ return {
+ id: node.id,
+ ...node.data,
+ }
+}
+
// Graph preprocessing functions:
/**
@@ -117,6 +132,7 @@ function defaultNormReducer(node: NormNode) :NormData {
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 triggers : TriggerNode[] = nodes.filter((node) => node.type === 'trigger') as TriggerNode[];
const orderedPhases : OrderedPhases = orderPhases(nodes, edges);
return orderedPhases.phaseNodes.map((phase: PhaseNode) : PreparedPhase => {
@@ -125,7 +141,8 @@ export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : Prep
phaseNode: phase,
nextPhaseId: nextPhase as string,
connectedNorms: getIncomers({id: phase.id}, norms,edges),
- connectedGoals: getIncomers({id: phase.id}, goals,edges)
+ connectedGoals: getIncomers({id: phase.id}, goals,edges),
+ connectedTriggers: getIncomers({id: phase.id}, triggers, edges),
};
});
}
diff --git a/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts b/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts
index 9151b56..1826286 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts
+++ b/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts
@@ -1,12 +1,14 @@
import type {Edge} from "@xyflow/react";
-import type {AppNode, GoalNode, NormNode, PhaseNode} from "./VisProgTypes.tsx";
+import type {AppNode, GoalNode, NormNode, PhaseNode, TriggerNode} from "./VisProgTypes.tsx";
+import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx";
+export type Reduced = { id: string } & T;
+
/**
* defines how a norm is represented in the simplified behavior program
*/
export type NormData = {
- id: string;
name: string;
value: string;
};
@@ -15,9 +17,9 @@ export type NormData = {
* defines how a goal is represented in the simplified behavior program
*/
export type GoalData = {
- id: string;
name: string;
- value: string;
+ description: string;
+ achieved: boolean;
};
/**
@@ -27,6 +29,7 @@ export type GoalData = {
export type PhaseData = {
norms: NormData[];
goals: GoalData[];
+ triggers: TriggerNodeProps[];
};
/**
@@ -55,12 +58,14 @@ export type BehaviorProgram = Phase[];
-export type NormReducer = (node: NormNode) => NormData;
-export type GoalReducer = (node: GoalNode) => GoalData;
+export type NormReducer = (node: NormNode) => Reduced;
+export type GoalReducer = (node: GoalNode) => Reduced;
+export type TriggerReducer = (node: TriggerNode) => Reduced;
export type PhaseReducer = (
preparedPhase: PreparedPhase,
normReducer: NormReducer,
- goalReducer: GoalReducer
+ goalReducer: GoalReducer,
+ triggerReducer: TriggerReducer,
) => Phase;
/**
@@ -90,6 +95,7 @@ export type PreparedPhase = {
nextPhaseId: string;
connectedNorms: NormNode[];
connectedGoals: GoalNode[];
+ connectedTriggers: TriggerNode[];
};
/**
diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx
index bb7c28c..8bfc715 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx
@@ -6,18 +6,21 @@ import {
type OnConnect,
type OnReconnect,
} from '@xyflow/react';
+import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx";
type defaultNodeData = {
label: string;
};
+type OurNode = Node;
+
export type StartNode = Node;
export type EndNode = Node;
-export type GoalNode = Node;
+export type GoalNode = Node;
export type NormNode = Node;
export type PhaseNode = Node;
-
+export type TriggerNode = OurNode;
/**
* a type meant to house different node types, currently not used
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx
index c9e1496..ea6b387 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx
@@ -11,7 +11,7 @@ import {
} from 'react';
import useFlowStore from "../VisProgStores.tsx";
import styles from "../../VisProg.module.css"
-import type {AppNode, PhaseNode, NormNode} from "../VisProgTypes.tsx";
+import type {AppNode, PhaseNode, NormNode, GoalNode, TriggerNode} from "../VisProgTypes.tsx";
@@ -106,10 +106,48 @@ export function addNode(nodeType: string, position: XYPosition) {
id: `norm-${normNumber}`,
type: nodeType,
position,
- data: {label: `new norm node`, value: "Pepper should be formal"},
+ data: {label: `new norm node`, value: ""},
}
return normNode;
}
+ case "goal":
+ {
+ const goalNodes= nds.filter((node) => node.type === 'goal');
+ let goalNumber
+ if (goalNodes.length > 0) {
+ const finalGoalId : number = +(goalNodes[goalNodes.length - 1].id.split('-')[1]);
+ goalNumber = finalGoalId + 1;
+ } else {
+ goalNumber = 1;
+ }
+
+ const goalNode : GoalNode = {
+ id: `goal-${goalNumber}`,
+ type: nodeType,
+ position,
+ data: {label: `new goal node`, description: "", achieved: false},
+ }
+ return goalNode;
+ }
+ case "trigger":
+ {
+ const triggerNodes= nds.filter((node) => node.type === 'trigger');
+ let triggerNumber
+ if (triggerNodes.length > 0) {
+ const finalGoalId : number = +(triggerNodes[triggerNodes.length - 1].id.split('-')[1]);
+ triggerNumber = finalGoalId + 1;
+ } else {
+ triggerNumber = 1;
+ }
+
+ const triggerNode : TriggerNode = {
+ id: `trigger-${triggerNumber}`,
+ type: nodeType,
+ position,
+ data: {label: `new trigger node`, type: "keywords", value: []},
+ }
+ return triggerNode;
+ }
default: {
throw new Error(`Node ${nodeType} not found`);
}
@@ -161,6 +199,12 @@ export function DndToolbar() {
norm Node
+
+ goal Node
+
+
+ trigger Node
+
|
);
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx
index 19f56dd..3f7868d 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx
@@ -11,8 +11,9 @@ import type {
StartNode,
EndNode,
PhaseNode,
- NormNode
+ NormNode, GoalNode
} from "../VisProgTypes.tsx";
+import {TextField} from "../../../../components/TextField.tsx";
//Toolbar definitions
@@ -44,56 +45,6 @@ export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
);
}
-// 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) => {
- 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) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); };
-
- const enableEditing = (event: React.MouseEvent) => {
- 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 (
-
- name:
-
-
- )
-}
-
// Definitions of Nodes
@@ -148,11 +99,25 @@ export const EndNodeComponent = ({id, data}: NodeProps) => {
* @constructor
*/
export const PhaseNodeComponent = ({id, data}: NodeProps) => {
+ const {updateNodeData} = useFlowStore();
+
+ const updateLabel = (value: string) => updateNodeData(id, {...data, label: value});
+
+ const label_input_id = `phase_${id}_label_input`;
+
return (
<>
-
+
+ Name:
+
+
@@ -167,17 +132,69 @@ export const PhaseNodeComponent = ({id, data}: NodeProps) => {
*
* @param {string} id
* @param {defaultNodeData & {value: string}} data
- * @returns {React.JSX.Element}
- * @constructor
*/
export const NormNodeComponent = ({id, data}: NodeProps) => {
- return (
- <>
-
-
-
-
+ const {updateNodeData} = useFlowStore();
+
+ const text_input_id = `norm_${id}_text_input`;
+
+ const setValue = (value: string) => {
+ updateNodeData(id, {value: value});
+ }
+
+ return <>
+
+
+
+ Norm:
+ setValue(val)}
+ placeholder={"Pepper should ..."}
+ />
- >
- );
-};
\ No newline at end of file
+
+
+ >;
+};
+
+export const GoalNodeComponent = ({id, data}: NodeProps) => {
+ const {updateNodeData} = useFlowStore();
+
+ const text_input_id = `goal_${id}_text_input`;
+ const checkbox_id = `goal_${id}_checkbox`;
+
+ const setDescription = (value: string) => {
+ updateNodeData(id, {...data, description: value});
+ }
+
+ const setAchieved = (value: boolean) => {
+ updateNodeData(id, {...data, achieved: value});
+ }
+
+ return <>
+
+
+
+ Goal:
+ setDescription(val)}
+ placeholder={"To ..."}
+ />
+
+
+ Achieved:
+ setAchieved(e.target.checked)}
+ />
+
+
+
+ >;
+}
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/TriggerNodeComponent.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/TriggerNodeComponent.tsx
new file mode 100644
index 0000000..7fca1ff
--- /dev/null
+++ b/src/pages/VisProgPage/visualProgrammingUI/components/TriggerNodeComponent.tsx
@@ -0,0 +1,121 @@
+import {Handle, type NodeProps, Position} from "@xyflow/react";
+import type {TriggerNode} from "../VisProgTypes.tsx";
+import useFlowStore from "../VisProgStores.tsx";
+import styles from "../../VisProg.module.css";
+import {RealtimeTextField, TextField} from "../../../../components/TextField.tsx";
+import {Toolbar} from "./NodeDefinitions.tsx";
+import {useState} from "react";
+import duplicateIndices from "../../../../utils/duplicateIndices.ts";
+
+export type EmotionTriggerNodeProps = {
+ type: "emotion";
+ value: string;
+}
+
+type Keyword = { id: string, keyword: string };
+
+export type KeywordTriggerNodeProps = {
+ type: "keywords";
+ value: Keyword[];
+}
+
+export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;
+
+function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) {
+ const [input, setInput] = useState("");
+
+ const text_input_id = "keyword_adder_input";
+
+ return
+ New Keyword:
+ {
+ if (!input) return;
+ addKeyword(input);
+ setInput("");
+ }}
+ placeholder={"..."}
+ className={"flex-1"}
+ />
+
;
+}
+
+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([]);
+
+ 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 <>
+ Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken.
+ {[...keywords].map(({id, keyword}, index) => {
+ return
+ Keyword:
+ replace(id, val)}
+ placeholder={"..."}
+ className={"flex-1"}
+ invalid={duplicates.includes(index)}
+ />
+
;
+ })}
+
+ >;
+}
+
+export default function TriggerNodeComponent({
+ id,
+ data,
+}: NodeProps) {
+ const {updateNodeData} = useFlowStore();
+
+ const setKeywords = (keywords: Keyword[]) => {
+ updateNodeData(id, {...data, value: keywords});
+ }
+
+ return <>
+
+
+ {data.type === "emotion" && (
+
Emotion?
+ )}
+ {data.type === "keywords" && (
+
+ )}
+
+
+ >;
+}
diff --git a/src/utils/duplicateIndices.ts b/src/utils/duplicateIndices.ts
new file mode 100644
index 0000000..08a4d43
--- /dev/null
+++ b/src/utils/duplicateIndices.ts
@@ -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(array: T[]): number[] {
+ const positions = new Map();
+
+ 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();
+}
diff --git a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts
index 4473b82..246972c 100644
--- a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts
+++ b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts
@@ -457,6 +457,7 @@ describe('Graph Reducer Tests', () => {
nextPhaseId: 'end',
connectedNorms: [],
connectedGoals: [],
+ connectedTriggers: [],
}]
},
{
@@ -472,6 +473,7 @@ describe('Graph Reducer Tests', () => {
nextPhaseId: 'phase-2',
connectedNorms: [],
connectedGoals: [],
+ connectedTriggers: [],
},
{
phaseNode: {
@@ -483,6 +485,7 @@ describe('Graph Reducer Tests', () => {
nextPhaseId: 'phase-3',
connectedNorms: [],
connectedGoals: [],
+ connectedTriggers: [],
},
{
phaseNode: {
@@ -494,6 +497,7 @@ describe('Graph Reducer Tests', () => {
nextPhaseId: 'end',
connectedNorms: [],
connectedGoals: [],
+ connectedTriggers: [],
}]
},
{
@@ -509,6 +513,7 @@ describe('Graph Reducer Tests', () => {
nextPhaseId: 'phase-2',
connectedNorms: [],
connectedGoals: [],
+ connectedTriggers: [],
},
{
phaseNode: {
@@ -525,6 +530,7 @@ describe('Graph Reducer Tests', () => {
data: {label: 'Generic Norm', value: "generic"},
}],
connectedGoals: [],
+ connectedTriggers: [],
},
{
phaseNode: {
@@ -541,6 +547,7 @@ describe('Graph Reducer Tests', () => {
data: {label: 'Generic Norm', value: "generic"},
}],
connectedGoals: [],
+ connectedTriggers: [],
}]
},
{
@@ -561,6 +568,7 @@ describe('Graph Reducer Tests', () => {
data: {label: 'Generic Norm', value: "generic"},
}],
connectedGoals: [],
+ connectedTriggers: [],
},
{
phaseNode: {
@@ -583,6 +591,7 @@ describe('Graph Reducer Tests', () => {
data: {label: 'Generic Norm', value: "generic"},
}],
connectedGoals: [],
+ connectedTriggers: [],
},
{
phaseNode: {
@@ -605,6 +614,7 @@ describe('Graph Reducer Tests', () => {
data: {label: 'Generic Norm', value: "generic"},
}],
connectedGoals: [],
+ connectedTriggers: [],
}]
},
{
@@ -732,6 +742,7 @@ describe('Graph Reducer Tests', () => {
nextPhaseId: 'end',
connectedNorms: [],
connectedGoals: [],
+ connectedTriggers: [],
}
const output = defaultPhaseReducer(input);
expect(output).toEqual({
@@ -740,7 +751,8 @@ describe('Graph Reducer Tests', () => {
nextPhaseId: 'end',
phaseData: {
norms: [],
- goals: []
+ goals: [],
+ triggers: [],
}
});
});
@@ -760,6 +772,7 @@ describe('Graph Reducer Tests', () => {
data: {label: 'Generic Norm', value: "generic"},
}],
connectedGoals: [],
+ connectedTriggers: [],
}
const output = defaultPhaseReducer(input);
expect(output).toEqual({
@@ -772,7 +785,8 @@ describe('Graph Reducer Tests', () => {
name: 'Generic Norm',
value: "generic"
}],
- goals: []
+ goals: [],
+ triggers: [],
}
});
});
@@ -790,8 +804,9 @@ describe('Graph Reducer Tests', () => {
id: 'goal-1',
type: 'goal',
position: {x: 0, y: 150},
- data: {label: 'Generic Goal', value: "generic"},
+ data: {label: 'Generic Goal', description: "generic", achieved: false},
}],
+ connectedTriggers: [],
}
const output = defaultPhaseReducer(input);
expect(output).toEqual({
@@ -803,7 +818,50 @@ describe('Graph Reducer Tests', () => {
goals: [{
id: 'goal-1',
name: 'Generic Goal',
- value: "generic"
+ description: "generic",
+ achieved: false,
+ }],
+ triggers: [],
+ }
+ });
+ });
+ test("defaultTriggerReducer reduces triggers 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: [],
+ connectedTriggers: [{
+ id: 'trigger-1',
+ type: 'trigger',
+ position: {x: 0, y: 150},
+ data: {label: 'Keyword Trigger', type: "keywords", value: [
+ {id: "some_id", keyword: "generic"},
+ {id: "another_id", keyword: "another"},
+ ]},
+ }],
+ }
+ const output = defaultPhaseReducer(input);
+ expect(output).toEqual({
+ id: 'phase-1',
+ name: 'Generic Phase',
+ nextPhaseId: 'end',
+ phaseData: {
+ norms: [],
+ goals: [],
+ triggers: [{
+ id: 'trigger-1',
+ label: 'Keyword Trigger',
+ type: "keywords",
+ value: [
+ {id: "some_id", keyword: "generic"},
+ {id: "another_id", keyword: "another"},
+ ]
}]
}
});
@@ -820,7 +878,8 @@ describe('Graph Reducer Tests', () => {
nextPhaseId: 'end',
phaseData: {
norms: [],
- goals: []
+ goals: [],
+ triggers: [],
}
}]
},
@@ -833,7 +892,8 @@ describe('Graph Reducer Tests', () => {
nextPhaseId: 'phase-2',
phaseData: {
norms: [],
- goals: []
+ goals: [],
+ triggers: [],
}
},
{
@@ -842,7 +902,8 @@ describe('Graph Reducer Tests', () => {
nextPhaseId: 'phase-3',
phaseData: {
norms: [],
- goals: []
+ goals: [],
+ triggers: [],
}
},
{
@@ -851,7 +912,8 @@ describe('Graph Reducer Tests', () => {
nextPhaseId: 'end',
phaseData: {
norms: [],
- goals: []
+ goals: [],
+ triggers: [],
}
}]
},
@@ -864,7 +926,8 @@ describe('Graph Reducer Tests', () => {
nextPhaseId: 'phase-2',
phaseData: {
norms: [],
- goals: []
+ goals: [],
+ triggers: [],
}
},
{
@@ -879,7 +942,8 @@ describe('Graph Reducer Tests', () => {
value: "generic"
}
],
- goals: []
+ goals: [],
+ triggers: [],
}
},
{
@@ -892,7 +956,8 @@ describe('Graph Reducer Tests', () => {
name: 'Generic Norm',
value: "generic"
}],
- goals: []
+ goals: [],
+ triggers: [],
}
}]
},
@@ -909,7 +974,8 @@ describe('Graph Reducer Tests', () => {
name: 'Generic Norm',
value: "generic"
}],
- goals: []
+ goals: [],
+ triggers: [],
}
},
{
@@ -929,7 +995,8 @@ describe('Graph Reducer Tests', () => {
value: "generic"
}
],
- goals: []
+ goals: [],
+ triggers: [],
}
},
{
@@ -947,7 +1014,8 @@ describe('Graph Reducer Tests', () => {
name: 'Generic Norm',
value: "generic"
}],
- goals: []
+ goals: [],
+ triggers: [],
}
}]
},
diff --git a/test/utils/duplicateIndices.test.ts b/test/utils/duplicateIndices.test.ts
new file mode 100644
index 0000000..25dce1a
--- /dev/null
+++ b/test/utils/duplicateIndices.test.ts
@@ -0,0 +1,22 @@
+import duplicateIndices from "../../src/utils/duplicateIndices.ts";
+
+describe("duplicateIndices (unit)", () => {
+ it("returns an empty array for empty input", () => {
+ expect(duplicateIndices([])).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]);
+ });
+});
From 2f7a48415bdac4f6a69799b19ae6494bd805b779 Mon Sep 17 00:00:00 2001
From: "Gerla, J. (Justin)"
Date: Fri, 14 Nov 2025 11:46:44 +0000
Subject: [PATCH 17/17] refactor: removed unnecessary else blocks in
orderPhases
---
.../visualProgrammingUI/GraphReducer.ts | 35 +++++++++----------
1 file changed, 16 insertions(+), 19 deletions(-)
diff --git a/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts
index 6a4ee55..3d85216 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts
+++ b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts
@@ -167,32 +167,29 @@ export function orderPhases(nodes: AppNode[],edges: Edge[]) : 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);
+ 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`);
- }
- }
- }
}
+ // handle erroneous states
+ if (nextNodes.length === 0) {
+ throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" doesn't have any outgoing connections`);
+ }
+ if (nextNodes.length > 1) {
+ throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" connects to too many targets`);
+ }
+ if (nextNodes[0].type === "end") {
+ connections.set(currentPhase.id, "end");
+ // returns the final output of the function
+ return {phaseNodes: phases, connections: connections};
+ }
+ 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