Compare commits

...

71 Commits

Author SHA1 Message Date
Twirre Meulenbelt
1bfcfc0458 feat: use input element directly
Previously, a button proxy was used which required the use of complicated reference management. Using the HTML `input` element directly simplifies the implementation.

Also moved some styles.

ref: N25B-189
2025-12-04 12:55:36 +01:00
Pim Hutting
e9ea0fb37e Merge remote-tracking branch 'origin/dev' into feat/save-load-nodes 2025-12-04 11:04:56 +01:00
Pim Hutting
413fb05cd8 chore: applied feedback from merge request
Removed all the DOM manipulations and created a utils file so npx eslint
is happy.
Also changed the tests to test the new version of the code.

ref: N25B-189
2025-12-04 09:12:01 +01:00
JobvAlewijk
608bd54617 Merge branch 'feat/ci-cd' into 'dev'
Add CI/CD to UI

See merge request ics/sp/2025/n25b/pepperplus-ui!28
2025-12-03 15:12:17 +00:00
Twirre Meulenbelt
d41a45793f Merge remote-tracking branch 'origin/dev' into feat/ci-cd 2025-12-03 11:30:12 +01:00
Björn Otgaar
df255a83b6 Merge branch 'feat/send-program' into 'dev'
Send program to backend in the latest form

See merge request ics/sp/2025/n25b/pepperplus-ui!22
2025-12-02 15:56:27 +00:00
Twirre Meulenbelt
e680ad3195 fix: add test script to package.json
ref: N25B-366
2025-12-02 16:46:56 +01:00
Twirre Meulenbelt
ea85a05f27 fix: use install artifacts
Uses install artifacts in later stages.

ref: N25B-366
2025-12-02 16:42:00 +01:00
Twirre Meulenbelt
7d3c63630a feat: introduce CI/CD runner
Installs dependencies, checks style, runs tests.

ref: N25B-366
2025-12-02 16:06:14 +01:00
Twirre Meulenbelt
3d7997e8d0 feat: introduce git hooks
Make installing git hooks easy using Husky. Also, updating the commit message checks. Includes setup instructions in the README.

ref: N25B-366
2025-12-02 15:02:48 +01:00
JobvAlewijk
3bcc865dd8 build: merge dev
ref: N25B-189
2025-12-02 12:56:53 +01:00
Twirre Meulenbelt
ff4ee7e111 Merge remote-tracking branch 'origin/dev' into feat/send-program
# Conflicts:
#	src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx
2025-12-02 10:52:48 +01:00
Twirre Meulenbelt
381fdaca1a fix: re-render TextField when input changes from parent
ref: N25B-189
2025-11-27 10:58:09 +01:00
Gerla, J. (Justin)
0ec6f556c9 Merge branch 'docs/ui-documentation' into 'dev'
docs: create-and-check-documentation

See merge request ics/sp/2025/n25b/pepperplus-ui!23
2025-11-26 13:41:18 +00:00
Arthur van Assenbergh
10a2c0c3cd docs: create-and-check-documentation 2025-11-26 13:41:18 +00:00
Pim Hutting
5287cb3bf3 Merge remote-tracking branch 'origin/dev' into feat/save-load-nodes 2025-11-26 14:05:38 +01:00
Twirre Meulenbelt
32c8c985c3 chore: more general type required 2025-11-25 11:06:11 +01:00
Twirre Meulenbelt
690880faa4 feat: send program to backend in the latest form
ref: N25B-198
2025-11-25 10:55:57 +01:00
JobvAlewijk
f87c7fed03 Merge branch 'refactor/node-encapsulation' into 'dev'
Refactoring all nodes functionality into their own files, create a modular framework for the visual programming.

See merge request ics/sp/2025/n25b/pepperplus-ui!21
2025-11-20 15:00:00 +00:00
Björn Otgaar
79b645df88 chore: apply suggestions from threads for merge. 2025-11-20 14:53:42 +01:00
Björn Otgaar
1dfc14ede8 chore: remove unused style reference 2025-11-20 14:33:23 +01:00
Björn Otgaar
c84f730782 Apply 1 suggestion(s) to 1 file(s)
Co-authored-by: Twirre <s.a.meulenbelt@students.uu.nl>
2025-11-19 17:31:13 +00:00
Björn Otgaar
f892db7be2 Merge branch 'dev' into refactor/node-encapsulation 2025-11-19 10:47:56 +01:00
JobvAlewijk
4f7c730916 Merge branch 'docs/generate-html' into 'dev'
Introduce documentation generator

See merge request ics/sp/2025/n25b/pepperplus-ui!20
2025-11-19 09:30:54 +00:00
Björn Otgaar
1f70ebd799 chore: remove a single console.log that wasn't needed... :) 2025-11-19 10:21:46 +01:00
Björn Otgaar
f37df1c726 chore: cleanup broken tests, add extra documentation, make sure everything is clean and code style isn't inconsistant 2025-11-19 10:13:08 +01:00
Björn Otgaar
8c2e51114e chore: delete graph tests that fail 2025-11-18 19:23:25 +01:00
Björn Otgaar
bd7620a182 chore: fix eslints and spelling 2025-11-18 18:49:11 +01:00
Björn Otgaar
bb4e9d0b26 fix: fixed the program reduce algorithm to be flexable and correctly use the different phase variables.
ref: N25B-294
2025-11-18 18:47:08 +01:00
Björn Otgaar
0bbb6101ae refactor: make sure that the droppable styles are kept, update some nodes to reflect their used functionality.
ref: N25B-294
2025-11-18 15:36:18 +01:00
Björn Otgaar
3e73e78ee9 chore: merge the rest of the nodes back into this structure, and make sure that start and end nodes are not deletable. 2025-11-18 13:25:13 +01:00
Björn Otgaar
941658a817 Merge branch 'dev' into refactor/node-encapsulation 2025-11-18 12:35:53 +01:00
Twirre Meulenbelt
eabc7c8b04 docs: fix run command
ref: N25B-288
2025-11-18 11:45:10 +01:00
Twirre Meulenbelt
000d221538 docs: introduce documentation generator
ref: N25B-288
2025-11-18 10:23:45 +01:00
Björn Otgaar
047e22ce4d chore: very small package fix 2025-11-17 16:15:39 +01:00
Björn Otgaar
35ff58eca8 refactor: defaults should be in their own file, respecting eslint/ react standards. all tests fail, obviously.
ref: N25B-294
2025-11-17 16:00:36 +01:00
Björn Otgaar
c5dc825ca3 refactor: Initial working framework of node encapsulation works- polymorphic implementation of nodes in creating and connecting calls correct functions
ref: N25B-294
2025-11-17 14:25:01 +01:00
Pim Hutting
96bd1c697c Merge branch 'feat/show-connected-robots' into 'dev'
feat: show connected robots in ui when getting cb pings

See merge request ics/sp/2025/n25b/pepperplus-ui!15
2025-11-14 13:09:19 +00:00
JobvAlewijk
476c538464 build: fixed small merge conflict
ref: N25B-142
2025-11-14 13:15:11 +01:00
2584433
fe8e04d305 Merge branch 'refactor/improve-orderPhases' into 'dev'
refactor: removed unnecessary else blocks in orderPhases

See merge request ics/sp/2025/n25b/pepperplus-ui!19
2025-11-14 11:46:44 +00:00
Gerla, J. (Justin)
2f7a48415b refactor: removed unnecessary else blocks in orderPhases 2025-11-14 11:46:44 +00:00
Gerla, J. (Justin)
e5fee333fb Merge branch 'feat/editable-norms-and-goals' into 'dev'
Make nodes editable: norms, goals and keyword triggers

See merge request ics/sp/2025/n25b/pepperplus-ui!18
2025-11-13 10:50:12 +00:00
Twirre
aeaf526797 Make nodes editable: norms, goals and keyword triggers 2025-11-13 10:50:12 +00:00
Gerla, J. (Justin)
f534f0cefa Merge branch 'feat/logging' into 'dev'
Add logging with filters

See merge request ics/sp/2025/n25b/pepperplus-ui!16
2025-11-12 14:35:38 +00:00
Twirre
231d7a5ba1 Add logging with filters 2025-11-12 14:35:38 +00:00
Pim Hutting
221fbe42c2 chore: added tests
got 50.72% code coverage. Not sure if it is feasible to mock import behaviour

ref: N25B-189
2025-11-12 14:29:59 +01:00
Pim Hutting
22da2ca664 feat: added functionality of saving and loadiing
for supported browsers, using the File System Access API.
otherwise, fallback to download the file and then you can load from download

ref: N25B-189
2025-11-12 11:17:15 +01:00
Björn Otgaar
be4fb0e7cd Merge remote-tracking branch 'origin/dev' into feat/show-connected-robots 2025-11-12 11:05:59 +01:00
Pim Hutting
bb7d24b7be chore: merge current dev into this branche
ref: N25B-189
2025-11-12 09:36:22 +01:00
Pim Hutting
3cbf983b41 fix: save and load are now buttons
Really small change so me and Arthur can work on this toegether at the same time

feat: N25B-189
2025-11-11 16:32:37 +01:00
Tuurminator69
45e133e255 feat: added temporary dummy button menu
ref: N25B-189
2025-11-11 16:05:25 +01:00
Gerla, J. (Justin)
b7eb0cb5ec Merge branch 'feat/add-naming-component-to-editor-nodes' into 'dev'
feat: added basic functionality for editable name bar

See merge request ics/sp/2025/n25b/pepperplus-ui!17
2025-11-11 13:50:45 +00:00
Gerla, J. (Justin)
d4d1aecb8c feat: added basic functionality for editable name bar 2025-11-11 13:50:45 +00:00
Björn Otgaar
87cf723c95 chore: fixed merge request suggestion for adding depency array 2025-11-11 11:42:28 +01:00
Björn Otgaar
df4346150e chore: remove old code pt 2 2025-11-11 11:11:46 +01:00
Björn Otgaar
8733bb3c04 chore: remove old remnants from project 2025-11-11 10:25:27 +01:00
Björn Otgaar
1b8095376b fix: fixed npx eslint (also accounting for justins part)
ref: N25B-142
2025-11-05 17:21:36 +01:00
Björn Otgaar
571908cd70 Merge remote-tracking branch 'origin/dev' into feat/show-connected-robots 2025-11-05 16:22:46 +01:00
Björn Otgaar
333bd6e6fd chore: single typing change 2025-11-05 16:11:36 +01:00
Björn Otgaar
5e707224cf feat: Show connected robots finished with unit test 94% coverage
ref: N25B-142
2025-10-30 15:47:09 +01:00
Björn Otgaar
6a88aa3d75 merge branch dev into show-connected-robots pt2 2025-10-30 14:57:50 +01:00
Björn Otgaar
32938edca8 Merge remote-tracking branch 'origin/dev' into feat/show-connected-robots 2025-10-30 14:57:18 +01:00
Björn Otgaar
4181454a73 feat: show robots page easier - quick connected sign. Quick reload - no need for manual reloads or anything.
ref: N25B-142
2025-10-30 13:05:56 +01:00
Björn Otgaar
ea17b95a53 Merge remote-tracking branch 'origin/dev' into feat/show-connected-robots 2025-10-22 12:00:00 +02:00
Björn Otgaar
fa046e6b2a feat: dummy reload from CB added.
ref: N25B-153
2025-10-08 17:41:29 +02:00
Björn Otgaar
1a0fd92e0f chore: complete merging with functionality
ref: N25B-142

additional comments: The reload from CB doesn't work yet.
2025-10-08 16:49:44 +02:00
Björn Otgaar
60b925e4e7 chore: merged dev into show-connected-robots
ref: N25B-142
2025-10-08 16:37:57 +02:00
Björn Otgaar
72d61e3985 chore: fixed wrong imports and deleted some
unnecessary prints.

ref: N25B-142
2025-10-08 14:35:20 +02:00
Björn Otgaar
ec4f45b984 fix: Keep the conencted robots in a global list
ref: N25B-142
2025-10-08 12:40:01 +02:00
Björn Otgaar
b78cd53baa 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.
2025-10-07 15:05:05 +02:00
Twirre Meulenbelt
10522b71c3 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.
2025-10-01 22:56:03 +02:00
70 changed files with 4781 additions and 1851 deletions

77
.githooks/check-branch-name.sh Executable file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# This script checks if the current branch name follows the specified format.
# It's designed to be used as a 'pre-commit' git hook.
# Format: <type>/<short-description>
# Example: feat/add-user-login
# --- Configuration ---
# An array of allowed commit types
ALLOWED_TYPES=(feat fix refactor perf style test docs build chore revert)
# An array of branches to ignore
IGNORED_BRANCHES=(main dev demo)
# --- Colors for Output ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# --- Helper Functions ---
error_exit() {
echo -e "${RED}ERROR: $1${NC}" >&2
echo -e "${YELLOW}Branch name format is incorrect. Aborting commit.${NC}" >&2
exit 1
}
# --- Main Logic ---
# 1. Get the current branch name
BRANCH_NAME=$(git symbolic-ref --short HEAD)
# 2. Check if the current branch is in the ignored list
for ignored_branch in "${IGNORED_BRANCHES[@]}"; do
if [ "$BRANCH_NAME" == "$ignored_branch" ]; then
echo -e "${GREEN}Branch check skipped for default branch: $BRANCH_NAME${NC}"
exit 0
fi
done
# 3. Validate the overall structure: <type>/<description>
if ! [[ "$BRANCH_NAME" =~ ^[a-z]+/.+$ ]]; then
error_exit "Branch name must be in the format: <type>/<short-description>\nExample: feat/add-user-login"
fi
# 4. Extract the type and description
TYPE=$(echo "$BRANCH_NAME" | cut -d'/' -f1)
DESCRIPTION=$(echo "$BRANCH_NAME" | cut -d'/' -f2-)
# 5. Validate the <type>
type_valid=false
for allowed_type in "${ALLOWED_TYPES[@]}"; do
if [ "$TYPE" == "$allowed_type" ]; then
type_valid=true
break
fi
done
if [ "$type_valid" == false ]; then
error_exit "Invalid type '$TYPE'.\nAllowed types are: ${ALLOWED_TYPES[*]}"
fi
# 6. Validate the <short-description>
# Regex breakdown:
# ^[a-z0-9]+ - Starts with one or more lowercase letters/numbers (the first word).
# (-[a-z0-9]+){0,5} - Followed by a group of (dash + word) 0 to 5 times.
# $ - End of the string.
# This entire pattern enforces 1 to 6 words total, separated by dashes.
DESCRIPTION_REGEX="^[a-z0-9]+(-[a-z0-9]+){0,5}$"
if ! [[ "$DESCRIPTION" =~ $DESCRIPTION_REGEX ]]; then
error_exit "Invalid short description '$DESCRIPTION'.\nIt must be a maximum of 6 words, all lowercase, separated by dashes.\nExample: add-new-user-authentication-feature"
fi
# If all checks pass, exit successfully
echo -e "${GREEN}Branch name '$BRANCH_NAME' is valid.${NC}"
exit 0

138
.githooks/check-commit-msg.sh Executable file
View File

@@ -0,0 +1,138 @@
#!/usr/bin/env bash
# This script checks if a commit message follows the specified format.
# It's designed to be used as a 'commit-msg' git hook.
# Format:
# <type>: <short description>
#
# [optional]<body>
#
# [ref/close]: <issue identifier>
# --- Configuration ---
# An array of allowed commit types
ALLOWED_TYPES=(feat fix refactor perf style test docs build chore revert)
# --- Colors for Output ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# The first argument to the hook is the path to the file containing the commit message
COMMIT_MSG_FILE=$1
# --- Automated Commit Detection ---
# Read the first line (header) for initial checks
HEADER=$(head -n 1 "$COMMIT_MSG_FILE")
echo 'Given commit message:'
echo $HEADER
# Check for Merge commits (covers 'git merge' and PR merges from GitHub/GitLab)
# Examples: "Merge branch 'main' into ...", "Merge pull request #123 from ..."
MERGE_PATTERN="^Merge (remote-tracking )?(branch|pull request|tag) .*"
if [[ "$HEADER" =~ $MERGE_PATTERN ]]; then
echo -e "${GREEN}Merge commit detected by message content. Skipping validation.${NC}"
exit 0
fi
# Check for Revert commits
# Example: "Revert "feat: add new feature""
REVERT_PATTERN="^Revert \".*\""
if [[ "$HEADER" =~ $REVERT_PATTERN ]]; then
echo -e "${GREEN}Revert commit detected by message content. Skipping validation.${NC}"
exit 0
fi
# Check for Cherry-pick commits (this pattern appears at the end of the message)
# Example: "(cherry picked from commit deadbeef...)"
# We use grep -q to search the whole file quietly.
CHERRY_PICK_PATTERN="\(cherry picked from commit [a-f0-9]{7,40}\)"
if grep -qE "$CHERRY_PICK_PATTERN" "$COMMIT_MSG_FILE"; then
echo -e "${GREEN}Cherry-pick detected by message content. Skipping validation.${NC}"
exit 0
fi
# Check for Squash
# Example: "Squash commits ..."
SQUASH_PATTERN="^Squash .+"
if [[ "$HEADER" =~ $SQUASH_PATTERN ]]; then
echo -e "${GREEN}Squash commit detected by message content. Skipping validation.${NC}"
exit 0
fi
# --- Validation Functions ---
# Function to print an error message and exit
# Usage: error_exit "Your error message here"
error_exit() {
# >&2 redirects echo to stderr
echo -e "${RED}ERROR: $1${NC}" >&2
echo -e "${YELLOW}Commit message format is incorrect. Aborting commit.${NC}" >&2
exit 1
}
# --- Main Logic ---
# 1. Read the header (first line) of the commit message
HEADER=$(head -n 1 "$COMMIT_MSG_FILE")
# 2. Validate the header format: <type>: <description>
# Regex breakdown:
# ^(type1|type2|...) - Starts with one of the allowed types
# : - Followed by a literal colon
# \s - Followed by a single space
# .+ - Followed by one or more characters for the description
# $ - End of the line
TYPES_REGEX=$(
IFS="|"
echo "${ALLOWED_TYPES[*]}"
)
HEADER_REGEX="^($TYPES_REGEX): .+$"
if ! [[ "$HEADER" =~ $HEADER_REGEX ]]; then
error_exit "Invalid header format.\n\nHeader must be in the format: <type>: <short description>\nAllowed types: ${ALLOWED_TYPES[*]}\nExample: feat: add new user authentication feature"
fi
# Only validate footer if commit type is not chore
TYPE=$(echo "$HEADER" | cut -d':' -f1)
if [ "$TYPE" != "chore" ]; then
# 3. Validate the footer (last line) of the commit message
FOOTER=$(tail -n 1 "$COMMIT_MSG_FILE")
# Regex breakdown:
# ^(ref|close) - Starts with 'ref' or 'close'
# : - Followed by a literal colon
# \s - Followed by a single space
# N25B- - Followed by the literal string 'N25B-'
# [0-9]+ - Followed by one or more digits
# $ - End of the line
FOOTER_REGEX="^(ref|close): N25B-[0-9]+$"
if ! [[ "$FOOTER" =~ $FOOTER_REGEX ]]; then
error_exit "Invalid footer format.\n\nFooter must be in the format: [ref/close]: <issue identifier>\nExample: ref: N25B-123"
fi
fi
# 4. If the message has more than 2 lines, validate the separator
# A blank line must exist between the header and the body.
LINE_COUNT=$(wc -l <"$COMMIT_MSG_FILE" | xargs) # xargs trims whitespace
# We only care if there is a body. Header + Footer = 2 lines.
# Header + Blank Line + Body... + Footer > 2 lines.
if [ "$LINE_COUNT" -gt 2 ]; then
# Get the second line
SECOND_LINE=$(sed -n '2p' "$COMMIT_MSG_FILE")
# Check if the second line is NOT empty. If it's not, it's an error.
if [ -n "$SECOND_LINE" ]; then
error_exit "Missing blank line between header and body.\n\nThe second line of your commit message must be empty if a body is present."
fi
fi
# If all checks pass, exit with success
echo -e "${GREEN}Commit message is valid.${NC}"
exit 0

View File

@@ -1,16 +0,0 @@
#!/bin/sh
commit_msg_file=$1
commit_msg=$(cat "$commit_msg_file")
if echo "$commit_msg" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert): .+"; then
if echo "$commit_msg" | grep -Eq "^(ref|close):\sN25B-.+"; then
exit 0
else
echo "❌ Commit message invalid! Must end with [ref/close]: N25B-000"
exit 1
fi
else
echo "❌ Commit message invalid! Must start with <type>: <description>"
exit 1
fi

View File

@@ -1,17 +0,0 @@
#!/bin/sh
# Get current branch
branch=$(git rev-parse --abbrev-ref HEAD)
if echo "$branch" | grep -Eq "(dev|main)"; then
echo 0
fi
# allowed pattern <type/>
if echo "$branch" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert)\/\w+(-\w+){0,5}$"; then
exit 0
else
echo "❌ Invalid branch name: $branch"
echo "Branch must be named <type>/<description-of-branch> (must have one to six words separated by a dash)"
exit 1
fi

View File

@@ -1,9 +0,0 @@
#!/bin/sh
echo "#<type>: <description>
#[optional body]
#[optional footer(s)]
#[ref/close]: <issue identifier>" > $1

5
.gitignore vendored
View File

@@ -24,4 +24,7 @@ dist-ssr
*.sw?
# Coverage report
coverage
coverage
# Documentation pages (can be generated)
docs

53
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,53 @@
# ---------- GLOBAL SETUP ---------- #
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
stages:
- install
- lint
- test
variables:
NODE_VERSION: "24.11.1"
BASE_LAYER: trixie-slim
default:
image: docker.io/library/node:${NODE_VERSION}-${BASE_LAYER}
cache:
key: "${CI_COMMIT_REF_SLUG}"
paths:
- node_modules/
policy: pull-push
# --------- INSTALLING --------- #
install:
stage: install
tags:
- install
script:
- npm ci
artifacts:
paths:
- node_modules/
expire_in: 1h
# ---------- LINTING ---------- #
lint:
stage: lint
needs:
- install
tags:
- lint
script:
- npm run lint
# ---------- TESTING ---------- #
test:
stage: test
needs:
- install
tags:
- test
script:
- npm run test

1
.husky/commit-msg Normal file
View File

@@ -0,0 +1 @@
sh .githooks/check-commit-msg.sh $1

3
.husky/pre-commit Normal file
View File

@@ -0,0 +1,3 @@
sh .githooks/check-branch-name.sh
npm run lint

View File

@@ -28,16 +28,26 @@ npm run dev
It should automatically reload when you save changes.
## GitHooks
## Git Hooks
To activate automatic commits/branch name checks run:
To activate automatic linting, branch name checks and commit message checks, run:
```shell
git config --local core.hooksPath .githooks
```bash
npm run prepare
```
If your commit fails its either:
branch name != <type>/description-of-branch ,
commit name != <type>: description of the commit.
<ref>: N25B-Num's
You might get an error along the lines of `Can't install pre-commit with core.hooksPath` set. To fix this, simply unset the hooksPath by running:
```bash
git config --local --unset core.hooksPath
```
Then run the pre-commit install commands again.
## Documentation
Generate documentation webpages with the command:
```shell
npx typedoc --entryPointStrategy Expand src
```

View File

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

265
package-lock.json generated
View File

@@ -28,10 +28,12 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.4.0",
"husky": "^9.1.7",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"ts-jest": "^29.4.5",
"typedoc": "^0.28.14",
"typescript": "~5.8.3",
"typescript-eslint": "^8.44.0",
"vite": "^7.1.7"
@@ -1348,6 +1350,20 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@gerrit0/mini-shiki": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.15.0.tgz",
"integrity": "sha512-L5IHdZIDa4bG4yJaOzfasOH/o22MCesY0mx+n6VATbaiCtMeR59pdRqYk4bEiQkIHfxsHPNgdi7VJlZb2FhdMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@shikijs/engine-oniguruma": "^3.15.0",
"@shikijs/langs": "^3.15.0",
"@shikijs/themes": "^3.15.0",
"@shikijs/types": "^3.15.0",
"@shikijs/vscode-textmate": "^10.0.2"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1460,9 +1476,9 @@
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2351,6 +2367,55 @@
"win32"
]
},
"node_modules/@shikijs/engine-oniguruma": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.15.0.tgz",
"integrity": "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.15.0",
"@shikijs/vscode-textmate": "^10.0.2"
}
},
"node_modules/@shikijs/langs": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.15.0.tgz",
"integrity": "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.15.0"
}
},
"node_modules/@shikijs/themes": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.15.0.tgz",
"integrity": "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@shikijs/types": "3.15.0"
}
},
"node_modules/@shikijs/types": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.15.0.tgz",
"integrity": "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
}
},
"node_modules/@shikijs/vscode-textmate": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@sinclair/typebox": {
"version": "0.34.41",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
@@ -2637,6 +2702,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -2738,6 +2813,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/yargs": {
"version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
@@ -3324,12 +3406,12 @@
}
},
"node_modules/@xyflow/react": {
"version": "12.8.6",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz",
"integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==",
"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.70",
"@xyflow/system": "0.0.72",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
@@ -3367,9 +3449,9 @@
}
},
"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==",
"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",
@@ -4970,6 +5052,22 @@
"node": ">=10.17.0"
}
},
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"license": "MIT",
"bin": {
"husky": "bin.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -5908,9 +6006,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6055,6 +6153,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -6095,6 +6203,13 @@
"yallist": "^3.0.2"
}
},
"node_modules/lunr": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
"integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
"dev": true,
"license": "MIT"
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -6152,6 +6267,44 @@
"tmpl": "1.0.5"
}
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"dev": true,
"license": "MIT"
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -6704,6 +6857,16 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
@@ -7602,6 +7765,56 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typedoc": {
"version": "0.28.14",
"resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.14.tgz",
"integrity": "sha512-ftJYPvpVfQvFzpkoSfHLkJybdA/geDJ8BGQt/ZnkkhnBYoYW6lBgPQXu6vqLxO4X75dA55hX8Af847H5KXlEFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@gerrit0/mini-shiki": "^3.12.0",
"lunr": "^2.3.9",
"markdown-it": "^14.1.0",
"minimatch": "^9.0.5",
"yaml": "^2.8.1"
},
"bin": {
"typedoc": "bin/typedoc"
},
"engines": {
"node": ">= 18",
"pnpm": ">= 10"
},
"peerDependencies": {
"typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x"
}
},
"node_modules/typedoc/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/typedoc/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
@@ -7640,6 +7853,13 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"dev": true,
"license": "MIT"
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
@@ -7738,9 +7958,9 @@
}
},
"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==",
"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"
@@ -8142,6 +8362,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",

View File

@@ -6,8 +6,10 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"lint": "eslint src test",
"preview": "vite preview",
"test": "jest",
"prepare": "husky"
},
"dependencies": {
"@neodrag/react": "^2.3.1",
@@ -30,10 +32,12 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.4.0",
"husky": "^9.1.7",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"ts-jest": "^29.4.5",
"typedoc": "^0.28.14",
"typescript": "~5.8.3",
"typescript-eslint": "^8.44.0",
"vite": "^7.1.7"

View File

@@ -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?? */
}
@@ -104,6 +109,10 @@ main {
padding: 1rem 0;
}
input[type="checkbox"] {
cursor: pointer;
}
.flex-row {
display: flex;
flex-direction: row;
@@ -121,6 +130,14 @@ main {
flex-wrap: wrap;
}
.min-height-0 {
min-height: 0;
}
.scroll-y {
overflow-y: scroll;
}
.align-center {
align-items: center;
}
@@ -141,6 +158,10 @@ main {
gap: 1rem;
}
.margin-0 {
margin: 0;
}
.padding-sm {
padding: .25rem;
}
@@ -150,7 +171,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 +192,59 @@ main {
}
.round-lg {
border-radius: 1rem;
}
}
.border-sm {
border: 1px solid canvastext;
}
.border-md {
border: 2px solid canvastext;
}
.border-lg {
border: 3px solid canvastext;
}
.font-small {
font-size: .75rem;
}
.font-medium {
font-size: 1rem;
}
.font-large {
font-size: 1.25rem;
}
.mono {
font-family: ui-monospace, monospace;
}
.bold {
font-weight: bold;
}
.clickable {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.user-select-all {
-webkit-user-select: all;
user-select: all;
}
.user-select-none {
-webkit-user-select: none;
user-select: none;
}
button.no-button {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}

View File

@@ -3,24 +3,34 @@ 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'
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 (
<div>
<>
<header>
<Link to={"/"}>Home</Link>
<button onClick={() => setShowLogs(!showLogs)}>Toggle Logging</button>
</header>
<main className={"flex-col align-center"}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/template" element={<TemplatePage />} />
<Route path="/editor" element={<VisProg />} />
<Route path="/robot" element={<Robot />} />
<div className={"flex-row justify-center flex-1 min-height-0"}>
<main className={"flex-col align-center flex-1 scroll-y"}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/template" element={<TemplatePage />} />
<Route path="/editor" element={<VisProg />} />
<Route path="/robot" element={<Robot />} />
<Route path="/ConnectedRobots" element={<ConnectedRobots />} />
</Routes>
</main>
</div>
)
</main>
{showLogs && <Logging />}
</div>
</>
);
}
export default App

View File

@@ -0,0 +1,34 @@
.filter-root {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.filter-panel {
position: absolute;
display: flex;
flex-direction: column;
gap: .25rem;
top: 0;
right: 0;
z-index: 1;
background: canvas;
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
width: 300px;
*:first-child {
margin-top: 0;
}
*:last-child {
margin-bottom: 0;
}
}
button.deletable {
cursor: pointer;
&:hover {
text-decoration: line-through;
}
}

View File

@@ -0,0 +1,262 @@
import {useEffect, useRef, useState} from "react";
import type {LogFilterPredicate} from "./useLogs.ts";
import styles from "./Filters.module.css";
/**
* A generic setter type compatible with React's state setters.
*/
type Setter<T> = (value: T | ((prev: T) => T)) => void;
/**
* Mapping of log level names to their corresponding numeric severity.
* Used for comparison in log filtering predicates.
*/
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
]);
/**
* Renders a single log-level selector (dropdown) for a specific filter target.
*
* Used by both the global filter and agent-specific filters.
*
* @param name - The display name or identifier for the filter target.
* @param level - The currently selected log level.
* @param setLevel - Function to update the selected log level.
* @param onDelete - Optional callback for deleting this filter element.
* @returns A JSX element that renders a labeled dropdown for selecting log levels.
*/
function LevelPredicateElement({
name,
level,
setLevel,
onDelete,
}: {
name: string;
level: string;
setLevel: (level: string) => void;
onDelete?: () => void;
}) {
const normalizedName = name.split(".").pop() || name;
return <div className={"flex-row gap-sm align-center"}>
<label
htmlFor={`log_level_${name}`}
className={"font-small"}
>
{onDelete
? <button
className={`no-button ${styles.deletable}`}
onClick={onDelete}
>{normalizedName}:</button>
: normalizedName + ':'
}
</label>
<select
id={`log_level_${name}`}
value={level}
onChange={(e) => setLevel(e.target.value)}
>
{Array.from(optionMapping.keys()).map((key) => (
<option key={key} value={key}>{key}</option>
))}
</select>
</div>
}
/** Key used for the global log-level predicate in the filter map. */
const GLOBAL_LOG_LEVEL_PREDICATE_KEY = "global_log_level";
/**
* Renders and manages the **global log-level filter**.
*
* This component defines a baseline log level that all logs must meet or exceed
* to be displayed, unless overridden by per-agent filters.
*
* @param filterPredicates - Map of current log filter predicates.
* @param setFilterPredicates - Setter function to update the filter predicates map.
* @returns A JSX element rendering the global log-level selector.
*/
function GlobalLevelFilter({
filterPredicates,
setFilterPredicates,
}: {
filterPredicates: Map<string, LogFilterPredicate>;
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
}) {
const selected = filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value || "ALL";
const setSelected = (selected: string | null) => {
if (!selected || !optionMapping.has(selected)) return;
setFilterPredicates((curr) => {
const next = new Map(curr);
next.set(GLOBAL_LOG_LEVEL_PREDICATE_KEY, {
predicate: (record) => record.levelno >= optionMapping.get(selected)!,
priority: 0,
value: selected,
});
return next;
});
}
// Initialize default global level on mount.
useEffect(() => {
if (filterPredicates.has(GLOBAL_LOG_LEVEL_PREDICATE_KEY)) return;
setSelected("INFO");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run only once when the component mounts, not when anything changes
return <LevelPredicateElement
name={"Global"}
level={selected}
setLevel={setSelected}
/>;
}
/** Prefix for agent-specific log-level predicate keys in the filter map. */
const AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX = "agent_log_level_";
/**
* Renders and manages **per-agent log-level filters**.
*
* Allows the user to set specific log levels for individual agents, overriding
* the global filter for those agents. Includes functionality to add, edit,
* or remove agent-level filters.
*
* @param filterPredicates - Map of current log filter predicates.
* @param setFilterPredicates - Setter function to update the filter predicates map.
* @param agentNames - Set of agent names available for filtering.
* @returns A JSX element rendering agent-level filters and a dropdown to add new ones.
*/
function AgentLevelFilters({
filterPredicates,
setFilterPredicates,
agentNames,
}: {
filterPredicates: Map<string, LogFilterPredicate>;
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
agentNames: Set<string>;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
// Close dropdown or panels when clicking outside or pressing Escape.
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]);
// Identify which predicates correspond to agents.
const agentPredicates = [...filterPredicates.keys()].filter((key) =>
key.startsWith(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX));
/**
* Creates or updates the log filter predicate for a specific agent.
* Falls back to the global log level if no level is specified.
*
* @param agentName - The name of the agent to filter.
* @param level - Optional log level to apply; defaults to the global level.
*/
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;
});
}
/**
* Deletes the log filter predicate for a specific agent.
*
* @param agentName - The name of the agent whose filter should be removed.
*/
const deleteAgentPredicate = (agentName: string) => {
setFilterPredicates((curr) => {
const fullName = AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName;
if (!curr.has(fullName)) return curr; // Return unchanged, no re-render
const next = new Map(curr);
next.delete(fullName);
return next;
});
}
return <>
{agentPredicates.map((key) => {
const {agentName, level} = filterPredicates.get(key)!.value;
return <LevelPredicateElement
key={key}
name={agentName}
level={level}
setLevel={(level) => setAgentPredicate(agentName, level)}
onDelete={() => deleteAgentPredicate(agentName)}
/>;
})}
<div className={"flex-row gap-sm align-center"}>
<label htmlFor={"add_agent"} className={"font-small"}>Add:</label>
<select
id={"add_agent"}
value={""}
onChange={(e) => !!e.target.value && setAgentPredicate(e.target.value)}
>
{["", ...agentNames.keys()].map((key) => (
<option key={key} value={key}>{key.split(".").pop()}</option>
))}
</select>
</div>
</>;
}
/**
* Main Filters component that aggregates global and per-agent log filters.
*
* Combines the global log-level filter and agent-specific filters into a unified UI.
* Updates a shared `Map<string, LogFilterPredicate>` to determine which logs are shown.
*
* @param filterPredicates - The map of all active log filter predicates.
* @param setFilterPredicates - Setter to update the map of predicates.
* @param agentNames - Set of available agent names to display filters for.
* @returns A React component that renders all log filter controls.
*/
export default function Filters({
filterPredicates,
setFilterPredicates,
agentNames,
}: {
filterPredicates: Map<string, LogFilterPredicate>;
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
agentNames: Set<string>;
}) {
return <div className={"flex-1 flex-row flex-wrap gap-md align-center"}>
<GlobalLevelFilter filterPredicates={filterPredicates} setFilterPredicates={setFilterPredicates} />
<AgentLevelFilters filterPredicates={filterPredicates} setFilterPredicates={setFilterPredicates} agentNames={agentNames} />
</div>;
}

View File

@@ -0,0 +1,39 @@
.logging-container {
box-sizing: border-box;
width: max(30dvw, 500px);
flex-shrink: 0;
box-shadow: 0 0 1rem black;
padding: 1rem 1rem 0 1rem;
}
.no-numbers {
list-style-type: none;
counter-reset: none;
padding-inline-start: 0;
}
.log-container {
margin-bottom: .5rem;
.accented-0, .accented-10 {
background-color: color-mix(in oklab, canvas, rgb(159, 159, 159) 35%)
}
.accented-20 {
background-color: color-mix(in oklab, canvas, green 35%)
}
.accented-30 {
background-color: color-mix(in oklab, canvas, yellow 35%)
}
.accented-40, .accented-50 {
background-color: color-mix(in oklab, canvas, red 35%)
}
}
.floating-button {
position: fixed;
bottom: 1rem;
right: 1rem;
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
}

View File

@@ -0,0 +1,186 @@
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";
/**
* Zustand store definition for managing user preferences related to logging.
*
* Includes flags for toggling relative timestamps and automatic scroll behavior.
*/
type LoggingSettings = {
/** Whether to display log timestamps as relative (e.g., "2m 15s ago") instead of absolute. */
showRelativeTime: boolean;
/** Updates the `showRelativeTime` setting. */
setShowRelativeTime: (showRelativeTime: boolean) => void;
/** Whether the log view should automatically scroll to the newest entry. */
scrollToBottom: boolean;
/** Updates the `scrollToBottom` setting. */
setScrollToBottom: (scrollToBottom: boolean) => void;
};
/**
* Global Zustand store for logging UI preferences.
*/
const useLoggingSettings = create<LoggingSettings>((set) => ({
showRelativeTime: false,
setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }),
scrollToBottom: true,
setScrollToBottom: (scrollToBottom: boolean) => set({ scrollToBottom }),
}));
/**
* Renders a single log message entry with colored level indicators and timestamp formatting.
*
* This component automatically re-renders when the underlying log record (`recordCell`)
* changes. It also triggers the `onUpdate` callback whenever the record updates (e.g., for auto-scrolling).
*
* @param recordCell - A reactive `Cell` containing a single `LogRecord`.
* @param onUpdate - Optional callback triggered when the log entry updates.
* @returns A JSX element displaying a formatted log message.
*/
function LogMessage({
recordCell,
onUpdate,
}: {
recordCell: Cell<LogRecord>,
onUpdate?: () => void,
}) {
const { showRelativeTime, setShowRelativeTime } = useLoggingSettings();
const record = useCell(recordCell);
/**
* Normalizes the log level number to a multiple of 10,
* for which there are CSS styles. (e.g., INFO = 20, ERROR = 40).
*/
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;
})();
/** Simplifies the logger name by showing only the last path segment. */
const normalizedName = record.name.split(".").pop() || record.name;
// Notify parent component (e.g. for scroll updates) when this record changes.
useEffect(() => {
if (onUpdate) onUpdate();
}, [record, onUpdate]);
return <div className={`${styles.logContainer} round-md border-lg flex-row gap-md`}>
<div className={`${styles[`accented${normalizedLevelNo}`]} flex-col padding-sm justify-between`}>
<span className={"mono bold"}>{record.levelname}</span>
<span className={"mono clickable font-small"}
onClick={() => setShowRelativeTime(!showRelativeTime)}
>{showRelativeTime
? formatDuration(record.relativeCreated)
: new Date(record.created * 1000).toLocaleTimeString()
}</span>
</div>
<div className={"flex-col flex-1 padding-sm"}>
<span className={"mono"}>{normalizedName}</span>
<span>{record.message}</span>
</div>
</div>;
}
/**
* Displays a scrollable list of log messages.
*
* Handles:
* - Auto-scrolling when new messages arrive.
* - Allowing users to scroll manually and disable auto-scroll.
* - A floating "Scroll to bottom" button when not at the bottom.
*
* @param recordCells - Array of reactive log records to display.
* @returns A scrollable log list component.
*/
function LogMessages({ recordCells }: { recordCells: Cell<LogRecord>[] }) {
const scrollableRef = useRef<HTMLDivElement>(null);
const lastElementRef = useRef<HTMLLIElement>(null)
const { scrollToBottom, setScrollToBottom } = useLoggingSettings();
// Disable auto-scroll if the user manually scrolls.
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]);
/**
* Scrolls the last log message into view if auto-scroll is enabled,
* or if forced (e.g., user clicks "Scroll to bottom").
*
* @param force - If true, forces scrolling even if `scrollToBottom` is false.
*/
function scrollLastElementIntoView(force = false) {
if ((!scrollToBottom && !force) || !lastElementRef.current) return;
lastElementRef.current.scrollIntoView({ behavior: "smooth" });
}
return <div ref={scrollableRef} className={"min-height-0 scroll-y padding-b-md"}>
<ol className={`${styles.noNumbers} margin-0 flex-col gap-md`}>
{recordCells.map((recordCell, i) => (
<li key={`${i}_${recordCell.get().firstRelativeCreated}`}>
<LogMessage recordCell={recordCell} onUpdate={scrollLastElementIntoView} />
</li>
))}
<li ref={lastElementRef}></li>
</ol>
{!scrollToBottom && <button
className={styles.floatingButton}
onClick={() => {
setScrollToBottom(true);
scrollLastElementIntoView(true);
}}
>
Scroll to bottom
</button>}
</div>;
}
/**
* Top-level logging panel component.
*
* Combines:
* - The `Filters` component for adjusting log visibility.
* - The `LogMessages` component for displaying filtered logs.
* - Zustand-managed UI settings (auto-scroll, timestamp display).
*
* This component uses the `useLogs` hook to fetch and filter logs based on
* active predicates, and re-renders automatically as new logs arrive.
*
* @returns The complete logging UI as a React element.
*/
export default function Logging() {
const [filterPredicates, setFilterPredicates] = useState(new Map<string, LogFilterPredicate>());
const { filteredLogs, distinctNames } = useLogs(filterPredicates)
return <div className={`flex-col gap-lg min-height-0 ${styles.loggingContainer}`}>
<div className={"flex-row gap-lg justify-between align-center"}>
<h2 className={"margin-0"}>Logs</h2>
<Filters
filterPredicates={filterPredicates}
setFilterPredicates={setFilterPredicates}
agentNames={distinctNames}
/>
</div>
<LogMessages recordCells={filteredLogs} />
</div>;
}

View File

@@ -0,0 +1,212 @@
import {useCallback, useEffect, useRef, useState} from "react";
import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../utils/priorityFiltering.ts";
import {cell, type Cell} from "../../utils/cellStore.ts";
/**
* Represents a single log record emitted by the backend logging system.
*
* @property name - The name of the logger or source (e.g., `"agent.core"`).
* @property message - The message content of the log record.
* @property levelname - The human-readable severity level (e.g., `"INFO"`, `"ERROR"`).
* @property levelno - The numeric severity value corresponding to `levelname`.
* @property created - The UNIX timestamp (in seconds) when this record was created.
* @property relativeCreated - The time (in milliseconds) since the logging system started.
* @property reference - (Optional) A reference identifier linking related log messages.
* @property firstCreated - Timestamp of the first log in this reference group.
* @property firstRelativeCreated - Relative timestamp of the first log in this reference group.
*/
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;
};
/**
* A log filter predicate with priority support, used to determine whether
* a log record should be displayed.
*
* This extends a general `PriorityFilterPredicate` and includes an optional
* `value` field for UI metadata (e.g., selected log level or agent).
*
* @template T - The type of record being filtered (here, `LogRecord`).
*/
export type LogFilterPredicate = PriorityFilterPredicate<LogRecord> & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any };
/**
* React hook that manages the lifecycle of log records, including:
* - Receiving live log messages via Server-Sent Events (SSE),
* - Applying priority-based filtering rules,
* - Managing distinct logger names and reference-linked messages.
*
* Returns both the filtered logs (as reactive `Cell<LogRecord>` objects)
* and a set of distinct logger names for use in UI components (e.g., Filters).
*
* @param filterPredicates - A `Map` of log filter predicates, keyed by ID or type.
* @returns An object containing:
* - `filteredLogs`: The currently visible (filtered) log messages.
* - `distinctNames`: A set of all distinct logger names encountered.
*
* @example
* ```ts
* const { filteredLogs, distinctNames } = useLogs(activeFilters);
* ```
*/
export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
/** Distinct logger names encountered across all logs. */
const [distinctNames, setDistinctNames] = useState<Set<string>>(new Set());
/** Filtered logs that pass all active predicates, stored as reactive cells. */
const [filtered, setFiltered] = useState<Cell<LogRecord>[]>([]);
/** Persistent reference to the active EventSource connection. */
const sseRef = useRef<EventSource | null>(null);
/** Keeps a stable reference to the current filter map (avoids re-renders). */
const filtersRef = useRef(filterPredicates);
/** Stores all received logs (the unfiltered full history). */
const logsRef = useRef<LogRecord[]>([]);
/** Map to store the first message for each reference, instance can be updated to change contents. */
const firstByRefRef = useRef<Map<string, Cell<LogRecord>>>(new Map());
/**
* Apply all active filter predicates to a log record.
* @param log The log record to apply the filters to.
* @returns `true` if the record passes all filters; otherwise `false`.
*/
const applyFilters = useCallback((log: LogRecord) =>
applyPriorityPredicates(log, [...filtersRef.current.values()]), []);
/**
* Fully recomputes the filtered log list based on the current
* filter predicates and historical logs.
*
* Should be invoked whenever the filter map changes.
*/
const recomputeFiltered = useCallback(() => {
const newFiltered: Cell<LogRecord>[] = [];
firstByRefRef.current = new Map();
for (const message of logsRef.current) {
const messageCell = cell<LogRecord>({
...message,
firstCreated: message.created,
firstRelativeCreated: message.relativeCreated,
});
// Handle reference grouping: update the first message in the group.
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,
}));
continue; // Don't add it to the list again (it's a duplicate).
} else {
// Add the first message with this reference to the registry
firstByRefRef.current.set(message.reference, messageCell);
}
}
// Include only if it passes current filters.
if (applyFilters(message)) {
newFiltered.push(messageCell);
}
}
setFiltered(newFiltered);
}, [applyFilters, setFiltered]);
// Re-filter all logs whenever filter predicates change.
useEffect(() => {
filtersRef.current = filterPredicates;
recomputeFiltered();
}, [filterPredicates, recomputeFiltered]);
/**
* Handles a newly received log record.
* Updates the full log history, distinct names set, and filtered log list.
*
* @param message - The new log record to process.
*/
const handleNewMessage = useCallback((message: LogRecord) => {
// Store in complete history for future refiltering.
logsRef.current.push(message);
// Track distinct logger names.
setDistinctNames((prev) => {
if (prev.has(message.name)) return prev;
const newSet = new Set(prev);
newSet.add(message.name);
return newSet;
});
// Wrap in a reactive cell for UI binding.
const messageCell = cell<LogRecord>({
...message,
firstCreated: message.created,
firstRelativeCreated: message.relativeCreated,
});
// Handle reference-linked updates.
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,
}));
return; // Do not duplicate reference group entries.
} else {
firstByRefRef.current.set(message.reference, messageCell);
}
}
// Only append if message passes filters.
if (applyFilters(message)) {
setFiltered((curr) => [...curr, messageCell]);
}
}, [applyFilters, setFiltered]);
/**
* Initializes the SSE (Server-Sent Events) stream for real-time logs.
*
* Subscribes to messages from the backend logging endpoint and
* dispatches each message to `handleNewMessage`.
*
* Cleans up the EventSource connection when the component unmounts.
*/
useEffect(() => {
// Only create one SSE connection for the lifetime of the hook.
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};
}

View File

@@ -0,0 +1,22 @@
import {useEffect, useRef} from "react";
/**
* A React component that automatically scrolls itself into view whenever rendered.
*
* This component is especially useful in scrollable containers to keep the most
* recent content visible (e.g., chat applications, live logs, or notifications).
*
* It uses the browser's `Element.scrollIntoView()` API with smooth scrolling behavior.
*
* @returns A `<div>` element that scrolls into view when mounted or updated.
*/
export default function ScrollIntoView() {
/** Ref to the DOM element that will be scrolled into view. */
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (elementRef.current) elementRef.current.scrollIntoView({ behavior: "smooth" });
});
return <div ref={elementRef} />;
}

View File

@@ -0,0 +1,27 @@
.text-field {
border: 1px solid transparent;
border-radius: 5pt;
padding: 4px 8px;
outline: none;
background-color: canvas;
transition: border-color 0.2s, box-shadow 0.2s;
cursor: text;
}
.text-field.invalid {
border-color: red;
color: red;
}
.text-field:focus:not(.invalid) {
border-color: color-mix(in srgb, canvas, #777 10%);
}
.text-field:read-only {
cursor: pointer;
background-color: color-mix(in srgb, canvas, #777 5%);
}
.text-field:read-only:hover:not(.invalid) {
border-color: color-mix(in srgb, canvas, #777 10%);
}

View File

@@ -0,0 +1,123 @@
import {useEffect, useState} from "react";
import styles from "./TextField.module.css";
/**
* A styled text input that updates its value **in real time** at every keystroke.
*
* Automatically toggles between read-only and editable modes to integrate with
* drag-based UIs (like React Flow). Calls `onCommit` when editing is completed.
*
* @param props - Component properties.
* @param props.value - The current text input value.
* @param props.setValue - Callback invoked on every keystroke to update the value.
* @param props.onCommit - Callback invoked when editing is finalized (on blur or Enter).
* @param props.placeholder - Optional placeholder text displayed when the input is empty.
* @param props.className - Optional additional CSS class names.
* @param props.id - Optional unique HTML `id` for the input element.
* @param props.ariaLabel - Optional ARIA label for accessibility.
* @param props.invalid - If true, applies error styling to indicate invalid input.
*
* @returns A styled `<input>` element that updates its value in real time.
*/
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,
}) {
/** Tracks whether the input is currently read-only (for drag compatibility). */
const [readOnly, setReadOnly] = useState(true);
/** Finalizes editing and calls `onCommit` when the user exits the field. */
const updateData = () => {
setReadOnly(true);
onCommit();
};
/** Handles the Enter key — commits the input by triggering a blur event. */
const updateOnEnter = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter")
(event.target as HTMLInputElement).blur(); };
return <input
type={"text"}
placeholder={placeholder}
value={value}
onChange={(e) => setValue(e.target.value)}
onFocus={() => setReadOnly(false)}
onBlur={updateData}
onKeyDown={updateOnEnter}
readOnly={readOnly}
id={id}
// ReactFlow uses the "drag" / "nodrag" classes to enable / disable dragging of nodes
className={`${readOnly ? "drag" : "nodrag"} ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`}
aria-label={ariaLabel}
/>;
}
/**
* A styled text input that updates its value **only on commit** (when the user
* presses Enter or clicks outside the input).
*
* Internally wraps `RealtimeTextField` and buffers input changes locally,
* calling `setValue` only once editing is complete.
*
* @param props - Component properties.
* @param props.value - The current text input value.
* @param props.setValue - Callback invoked when the user commits the change.
* @param props.placeholder - Optional placeholder text displayed when the input is empty.
* @param props.className - Optional additional CSS class names.
* @param props.id - Optional unique HTML `id` for the input element.
* @param props.ariaLabel - Optional ARIA label for accessibility.
* @param props.invalid - If true, applies error styling to indicate invalid input.
*
* @returns A styled `<input>` element that updates its parent state only on commit.
*/
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);
// Re-render when the value gets updated externally
useEffect(() => setInputValue(value), [setInputValue, value]);
const onCommit = () => setValue(inputValue);
return <RealtimeTextField
placeholder={placeholder}
value={inputValue}
setValue={setInputValue}
onCommit={onCommit}
id={id}
className={className}
ariaLabel={ariaLabel}
invalid={invalid}
/>;
}

View File

@@ -1,6 +1,14 @@
import { useState } from 'react'
/**
* A minimal counter component that demonstrates basic React state handling.
*
* Maintains an internal count value and provides buttons to increment and reset it.
*
* @returns A JSX element rendering the counter UI.
*/
function Counter() {
/** The current counter value. */
const [count, setCount] = useState(0)
return (

View File

@@ -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;
@@ -24,12 +26,7 @@ html, body {
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
color: canvastext;
}
h1 {
@@ -49,7 +46,7 @@ button {
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
border-color: var(--accent-color);
}
button:focus,
button:focus-visible {
@@ -60,9 +57,8 @@ button:focus-visible {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
--accent-color: #00AAAA;
}
button {
background-color: #f9f9f9;

View File

@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react'
/**
* Displays the current connection status of a robot in real time.
*
* Opens an SSE connection to the backend (`/robot/ping_stream`) that emits
* simple boolean JSON messages (`true` or `false`). Updates automatically when
* the robot connects or disconnects.
*
* @returns A React element showing the current robot connection status.
*/
export default function ConnectedRobots() {
/**
* The current connection state:
* - `true`: Robot is connected.
* - `false`: Robot is not connected.
* - `null`: Connection status is unknown (initial check in progress).
*/
const [connected, setConnected] = useState<boolean | null>(null);
useEffect(() => {
// Open a Server-Sent Events (SSE) connection to receive live ping updates.
// We're expecting 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) => {
// Expecting messages in JSON format: `true` or `false`
console.log("received message:", event.data);
try {
const data = JSON.parse(event.data);
try {
setConnected(data)
}
catch {
console.log("couldnt extract connected from incoming ping data")
}
} catch {
console.log("Ping message not in correct format:", event.data);
}
};
// Clean up the SSE connection when the component unmounts.
return () => eventSource.close();
}, []);
return (
<div>
<h1>Is robot currently connected?</h1>
<div>
<h2>Robot is currently: {connected == null ? "checking..." : (connected ? "connected! 🟢" : "not connected... 🔴")} </h2>
<h3>
{connected == null ? "If checking continues, make sure CB is properly loaded with robot at least once." : ""}
</h3>
</div>
</div>
);
}

View File

@@ -2,6 +2,14 @@ import { Link } from 'react-router'
import pepperLogo from '../../assets/pepper_transp2_small.svg'
import styles from './Home.module.css'
/**
* The home page component providing navigation and project branding.
*
* Renders the Pepper logo and a set of navigational links
* implemented via React Router.
*
* @returns A JSX element representing the apps home page.
*/
function Home() {
return (
<div className={`flex-col ${styles.gapXl}`}>
@@ -14,6 +22,7 @@ function Home() {
<Link to={"/robot"}>Robot Interaction </Link>
<Link to={"/editor"}>Editor </Link>
<Link to={"/template"}>Template </Link>
<Link to={"/ConnectedRobots"}>Connected Robots </Link>
</div>
</div>
)

View File

@@ -1,13 +1,34 @@
import { useState, useEffect, useRef } from 'react'
/**
* Displays a live robot interaction panel with user input, conversation history,
* and real-time updates from the robot backend via Server-Sent Events (SSE).
*
* @returns A React element rendering the interactive robot UI.
*/
export default function Robot() {
/** The text message currently entered by the user. */
const [message, setMessage] = useState('');
/** Whether the robots microphone or listening mode is currently active. */
const [listening, setListening] = useState(false);
const [conversation, setConversation] = useState<{"role": "user" | "assistant", "content": string}[]>([])
/** The ongoing conversation history as a sequence of user/assistant messages. */
const [conversation, setConversation] = useState<
{"role": "user" | "assistant", "content": string}[]>([])
/** Reference to the scrollable conversation container for auto-scrolling. */
const conversationRef = useRef<HTMLDivElement | null>(null);
/**
* Index used to force refresh the SSE connection or clear conversation.
* Incrementing this value triggers a reset of the live data stream.
*/
const [conversationIndex, setConversationIndex] = useState(0);
/**
* Sends a message to the robot backend.
*
* Makes a POST request to `/message` with the users text.
* The backend may respond with confirmation or error information.
*/
const sendMessage = async () => {
try {
const response = await fetch("http://localhost:8000/message", {
@@ -24,6 +45,17 @@ export default function Robot() {
}
};
/**
* Establishes a persistent Server-Sent Events (SSE) connection
* to receive real-time updates from the robot backend.
*
* Handles three event types:
* - `voice_active`: whether the robot is currently listening.
* - `speech`: recognized user speech input.
* - `llm_response`: the robots language model-generated reply.
*
* The connection resets whenever `conversationIndex` changes.
*/
useEffect(() => {
const eventSource = new EventSource("http://localhost:8000/sse");
@@ -43,6 +75,10 @@ export default function Robot() {
};
}, [conversationIndex]);
/**
* Automatically scrolls the conversation view to the bottom
* whenever a new message is added.
*/
useEffect(() => {
if (!conversationRef || !conversationRef.current) return;
conversationRef.current.scrollTop = conversationRef.current.scrollHeight;

View File

@@ -1,26 +1,12 @@
/* 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%;
}
.dnd-panel {
margin-inline-start: auto;
margin-inline-end: auto;
@@ -55,34 +41,32 @@
filter: drop-shadow(0 0 0.75rem black);
}
.default-node-norm {
padding: 10px 15px;
background-color: canvas;
border-radius: 5pt;
outline: forestgreen solid 2pt;
.node-norm {
outline: rgb(0, 149, 25) solid 2pt;
filter: drop-shadow(0 0 0.25rem forestgreen);
}
.default-node-phase {
padding: 10px 15px;
background-color: canvas;
border-radius: 5pt;
.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);
}
.default-node-start {
padding: 10px 15px;
background-color: canvas;
border-radius: 5pt;
.node-start {
outline: orange solid 2pt;
filter: drop-shadow(0 0 0.25rem orange);
}
.default-node-end {
padding: 10px 15px;
background-color: canvas;
border-radius: 5pt;
.node-end {
outline: red solid 2pt;
filter: drop-shadow(0 0 0.25rem red);
}
@@ -103,6 +87,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;
@@ -126,4 +126,3 @@
outline: red solid 2pt;
filter: drop-shadow(0 0 0.25rem red);
}

View File

@@ -8,30 +8,15 @@ import {
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {useShallow} from 'zustand/react/shallow';
import {
StartNodeComponent,
EndNodeComponent,
PhaseNodeComponent,
NormNodeComponent
} 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 { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
// --| config starting params for flow |--
/**
* contains the types of all nodes that are available in the editor
*/
const NODE_TYPES = {
start: StartNodeComponent,
end: EndNodeComponent,
phase: PhaseNodeComponent,
norm: NormNodeComponent
};
/**
* defines how the default edge looks inside the editor
@@ -80,35 +65,37 @@ const VisProgUI = () => {
} = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore
return (
<div className={styles.outerEditorContainer}>
<div className={styles.innerEditorContainer}>
<ReactFlow
nodes={nodes}
edges={edges}
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
nodeTypes={NODE_TYPES}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
onConnect={onConnect}
snapToGrid
fitView
proOptions={{hideAttribution: true}}
>
<Panel position="top-center" className={styles.dndPanel}>
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
<div className={`${styles.innerEditorContainer} round-lg border-lg`}>
<ReactFlow
nodes={nodes}
edges={edges}
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
nodeTypes={NodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
onConnect={onConnect}
snapToGrid
fitView
proOptions={{hideAttribution: true}}
>
<Panel position="top-center" className={styles.dndPanel}>
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
</Panel>
<Controls/>
<Background/>
</ReactFlow>
</div>
<Panel position = "bottom-left" className={styles.saveLoadPanel}>
<SaveLoadPanel></SaveLoadPanel>
</Panel>
<Controls/>
<Background/>
</ReactFlow>
</div>
);
};
/**
* Places the VisProgUI component inside a ReactFlowProvider
*
@@ -126,8 +113,33 @@ function VisualProgrammingUI() {
// currently outputs the prepared program to the console
function runProgram() {
const program = graphReducer();
console.log(program);
const phases = graphReducer();
const program = {phases}
console.log(JSON.stringify(program, null, 2));
fetch(
"http://localhost:8000/program",
{
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(program),
}
).then((res) => {
if (!res.ok) throw new Error("Failed communicating with the backend.")
console.log("Successfully sent the program to the backend.");
}).catch(() => console.log("Failed to send program to the backend."));
}
/**
* Reduces the graph into its phases' information and recursively calls their reducing function
*/
function graphReducer() {
const { nodes } = useFlowStore.getState();
return nodes
.filter((n) => n.type == 'phase')
.map((n) => {
const reducer = NodeReduces['phase'];
return reducer(n, nodes)
});
}
/**
@@ -144,4 +156,4 @@ function VisProgPage() {
)
}
export default VisProgPage
export default VisProgPage

View File

@@ -1,188 +0,0 @@
import {
type Edge,
getIncomers,
getOutgoers
} from '@xyflow/react';
import useFlowStore from "./VisProgStores.tsx";
import type {
BehaviorProgram,
GoalData,
GoalReducer,
GraphPreprocessor,
NormData,
NormReducer,
OrderedPhases,
Phase,
PhaseReducer,
PreparedGraph,
PreparedPhase
} from "./GraphReducerTypes.ts";
import type {
AppNode,
GoalNode,
NormNode,
PhaseNode
} from "./VisProgTypes.tsx";
/**
* Reduces the current graph inside the visual programming editor into a BehaviorProgram
*
* @param {GraphPreprocessor} graphPreprocessor
* @param {PhaseReducer} phaseReducer
* @param {NormReducer} normReducer
* @param {GoalReducer} goalReducer
* @returns {BehaviorProgram}
*/
export default function graphReducer(
graphPreprocessor: GraphPreprocessor = defaultGraphPreprocessor,
phaseReducer: PhaseReducer = defaultPhaseReducer,
normReducer: NormReducer = defaultNormReducer,
goalReducer: GoalReducer = defaultGoalReducer
) : BehaviorProgram {
const nodes: AppNode[] = useFlowStore.getState().nodes;
const edges: Edge[] = useFlowStore.getState().edges;
const preparedGraph: PreparedGraph = graphPreprocessor(nodes, edges);
return preparedGraph.map((preparedPhase: PreparedPhase) : Phase =>
phaseReducer(
preparedPhase,
normReducer,
goalReducer
));
};
/**
* reduces a single preparedPhase to a Phase object
* the Phase object describes a single phase in a BehaviorProgram
*
* @param {PreparedPhase} phase
* @param {NormReducer} normReducer
* @param {GoalReducer} goalReducer
* @returns {Phase}
*/
export function defaultPhaseReducer(
phase: PreparedPhase,
normReducer: NormReducer = defaultNormReducer,
goalReducer: GoalReducer = defaultGoalReducer
) : Phase {
return {
id: phase.phaseNode.id,
name: phase.phaseNode.data.label,
nextPhaseId: phase.nextPhaseId,
phaseData: {
norms: phase.connectedNorms.map(normReducer),
goals: phase.connectedGoals.map(goalReducer)
}
}
}
/**
* the default implementation of the goalNode reducer function
*
* @param {GoalNode} node
* @returns {GoalData}
*/
function defaultGoalReducer(node: GoalNode) : GoalData {
return {
id: node.id,
name: node.data.label,
value: node.data.value
}
}
/**
* the default implementation of the normNode reducer function
*
* @param {NormNode} node
* @returns {NormData}
*/
function defaultNormReducer(node: NormNode) :NormData {
return {
id: node.id,
name: node.data.label,
value: node.data.value
}
}
// Graph preprocessing functions:
/**
* Preprocesses the provide state of the behavior editor graph, preparing it for further processing in
* the graphReducer function
*
* @param {AppNode[]} nodes
* @param {Edge[]} edges
* @returns {PreparedGraph}
*/
export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : PreparedGraph {
const norms : NormNode[] = nodes.filter((node) => node.type === 'norm') as NormNode[];
const goals : GoalNode[] = nodes.filter((node) => node.type === 'goal') as GoalNode[];
const orderedPhases : OrderedPhases = orderPhases(nodes, edges);
return orderedPhases.phaseNodes.map((phase: PhaseNode) : PreparedPhase => {
const nextPhase = orderedPhases.connections.get(phase.id);
return {
phaseNode: phase,
nextPhaseId: nextPhase as string,
connectedNorms: getIncomers({id: phase.id}, norms,edges),
connectedGoals: getIncomers({id: phase.id}, goals,edges)
};
});
}
/**
* orderPhases takes the state of the graph created by the editor and turns it into an OrderedPhases object.
*
* @param {AppNode[]} nodes
* @param {Edge[]} edges
* @returns {OrderedPhases}
*/
export function orderPhases(nodes: AppNode[],edges: Edge[]) : OrderedPhases {
// find the first Phase node
const phaseNodes : PhaseNode[] = nodes.filter((node) => node.type === 'phase') as PhaseNode[];
const startNodeIndex = nodes.findIndex((node : AppNode):boolean => {return (node.type === 'start');});
const firstPhaseNode = getOutgoers({ id: nodes[startNodeIndex].id },phaseNodes,edges);
// recursively adds the phase nodes to a list in the order they are connected in the graph
const nextPhase = (
currentIndex: number,
{ phaseNodes: phases, connections: connections} : OrderedPhases
) : OrderedPhases => {
// get the current phase and the next phases;
const currentPhase = phases[currentIndex];
const nextPhaseNodes = getOutgoers(currentPhase,phaseNodes,edges);
const nextNodes = getOutgoers(currentPhase,nodes, edges);
// handles adding of the next phase to the chain, and error handle if an invalid state is received
if (nextPhaseNodes.length === 1 && nextNodes.length === 1) {
connections.set(currentPhase.id, nextPhaseNodes[0].id);
return nextPhase(phases.push(nextPhaseNodes[0] as PhaseNode) - 1, {phaseNodes: phases, connections: connections});
} else {
// handle erroneous states
if (nextNodes.length === 0){
throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" doesn't have any outgoing connections`);
} else {
if (nextNodes.length > 1) {
throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" connects to too many targets`);
} else {
if (nextNodes[0].type === "end"){
connections.set(currentPhase.id, "end");
// returns the final output of the function
return { phaseNodes: phases, connections: connections};
} else {
throw new Error(`| INVALID PROGRAM | the node "${nextNodes[0].id}" that "${currentPhase.id}" connects to is not a phase or end node`);
}
}
}
}
}
// initializes the Map describing the connections between phase nodes
// we need this Map to make sure we preserve this information,
// so we don't need to do checks on the entire set of edges in further stages of the reduction algorithm
const connections : Map<string, string> = new Map();
// returns an empty list if no phase nodes are present, otherwise returns an ordered list of phaseNodes
if (firstPhaseNode.length > 0) {
return nextPhase(0, {phaseNodes: [firstPhaseNode[0] as PhaseNode], connections: connections})
} else { return {phaseNodes: [], connections: connections} }
}

View File

@@ -1,106 +0,0 @@
import type {Edge} from "@xyflow/react";
import type {AppNode, GoalNode, NormNode, PhaseNode} from "./VisProgTypes.tsx";
/**
* defines how a norm is represented in the simplified behavior program
*/
export type NormData = {
id: string;
name: string;
value: string;
};
/**
* defines how a goal is represented in the simplified behavior program
*/
export type GoalData = {
id: string;
name: string;
value: string;
};
/**
* definition of a PhaseData object, it contains all phaseData that is relevant
* for further processing and execution of a phase.
*/
export type PhaseData = {
norms: NormData[];
goals: GoalData[];
};
/**
* Describes a single phase within the simplified representation of a behavior program,
*
* Contains:
* - the id of the described phase,
* - the name of the described phase,
* - the id of the next phase in the user defined behavior program
* - the data property of the described phase node
*
* @NOTE at the moment the type definitions do not support branching programs,
* if branching of phases is to be supported in the future, the type definition for Phase has to be updated
*/
export type Phase = {
id: string;
name: string;
nextPhaseId: string;
phaseData: PhaseData;
};
/**
* Describes a simplified behavior program as a list of Phase objects
*/
export type BehaviorProgram = Phase[];
export type NormReducer = (node: NormNode) => NormData;
export type GoalReducer = (node: GoalNode) => GoalData;
export type PhaseReducer = (
preparedPhase: PreparedPhase,
normReducer: NormReducer,
goalReducer: GoalReducer
) => Phase;
/**
* contains:
*
* - list of phases, sorted based on position in chain between the start and end node
* - a dictionary containing all outgoing connections,
* to other phase or end nodes, for each phase node uses the id of the source node as key
* and the id of the target node as value
*
*/
export type OrderedPhases = {
phaseNodes: PhaseNode[];
connections: Map<string, string>;
};
/**
* A single prepared phase,
* contains:
* - the described phaseNode,
* - the id of the next phaseNode or "end" for the end node
* - a list of the normNodes that are connected to the described phase
* - a list of the goalNodes that are connected to the described phase
*/
export type PreparedPhase = {
phaseNode: PhaseNode;
nextPhaseId: string;
connectedNorms: NormNode[];
connectedGoals: GoalNode[];
};
/**
* a list of PreparedPhase objects,
* describes the preprocessed state of a program,
* before the contents of the node
*/
export type PreparedGraph = PreparedPhase[];
export type GraphPreprocessor = (nodes: AppNode[], edges: Edge[]) => PreparedGraph;

View File

@@ -0,0 +1,94 @@
import StartNode, { StartConnects, StartReduce } from "./nodes/StartNode";
import EndNode, { EndConnects, EndReduce } from "./nodes/EndNode";
import PhaseNode, { PhaseConnects, PhaseReduce } from "./nodes/PhaseNode";
import NormNode, { NormConnects, NormReduce } from "./nodes/NormNode";
import { EndNodeDefaults } from "./nodes/EndNode.default";
import { StartNodeDefaults } from "./nodes/StartNode.default";
import { PhaseNodeDefaults } from "./nodes/PhaseNode.default";
import { NormNodeDefaults } from "./nodes/NormNode.default";
import GoalNode, { GoalConnects, GoalReduce } from "./nodes/GoalNode";
import { GoalNodeDefaults } from "./nodes/GoalNode.default";
import TriggerNode, { TriggerConnects, TriggerReduce } from "./nodes/TriggerNode";
import { TriggerNodeDefaults } from "./nodes/TriggerNode.default";
/**
* Registered node types in the visual programming system.
*
* Key: the node type string used in the flow graph.
* Value: the corresponding React component for rendering the node.
*/
export const NodeTypes = {
start: StartNode,
end: EndNode,
phase: PhaseNode,
norm: NormNode,
goal: GoalNode,
trigger: TriggerNode,
};
/**
* Default data and settings for each node type.
* These are defined in the <node>.default.ts files.
* These defaults are used when a new node is created to initialize its properties.
*/
export const NodeDefaults = {
start: StartNodeDefaults,
end: EndNodeDefaults,
phase: PhaseNodeDefaults,
norm: NormNodeDefaults,
goal: GoalNodeDefaults,
trigger: TriggerNodeDefaults,
};
/**
* Reduce functions for each node type.
*
* A reduce function extracts the relevant data from a node and its children.
* Used during graph evaluation or export.
*/
export const NodeReduces = {
start: StartReduce,
end: EndReduce,
phase: PhaseReduce,
norm: NormReduce,
goal: GoalReduce,
trigger: TriggerReduce,
}
/**
* Connection functions for each node type.
*
* These functions define how nodes of a particular type can connect to other nodes.
*/
export const NodeConnects = {
start: StartConnects,
end: EndConnects,
phase: PhaseConnects,
norm: NormConnects,
goal: GoalConnects,
trigger: TriggerConnects,
}
/**
* Defines whether a node type can be deleted.
*
* Returns a function per node type. Nodes not explicitly listed are deletable by default.
*/
export const NodeDeletes = {
start: () => false,
end: () => false,
}
/**
* Defines which node types are considered variables in a phase node.
*
* Any node type not listed here is automatically treated as part of a phase.
* This allows the system to dynamically group nodes under a phase node.
*/
export const NodesInPhase = {
start: () => false,
end: () => false,
phase: () => false,
}

View File

@@ -1,116 +1,172 @@
import {create} from 'zustand';
import { create } from 'zustand';
import {
applyNodeChanges,
applyEdgeChanges,
addEdge,
reconnectEdge, type Edge, type Connection
reconnectEdge,
type Node,
type Edge,
type XYPosition,
} from '@xyflow/react';
import type { FlowState } from './VisProgTypes';
import { NodeDefaults, NodeConnects, NodeDeletes } from './NodeRegistry';
import {type FlowState} from './VisProgTypes.tsx';
/**
* contains the nodes that are created when the editor is loaded,
* should contain at least a start and an end node
* A Function to create a new node with the correct default data and properties.
*
* @param id - The unique ID of the node.
* @param type - The type of node to create (must exist in NodeDefaults).
* @param position - The XY position of the node in the flow canvas.
* @param data - The data object to initialize the node with.
* @param deletable - Optional flag to indicate if the node can be deleted (can be deleted by default).
* @returns A fully initialized Node object ready to be added to the flow.
*/
const initialNodes = [
{
id: 'start',
type: 'start',
position: {x: 0, y: 0},
data: {label: 'start'}
},
{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
{
id: 'end',
type: 'end',
position: {x: 0, y: 300},
data: {label: 'End'}
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable? : boolean) {
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
const newData = {
id: id,
type: type,
position: position,
data: data,
deletable: deletable,
}
return {...defaultData, ...newData}
}
//* Initial nodes to populate the flow at startup.
const initialNodes : Node[] = [
createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false),
createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false),
createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children : []}),
createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}),
];
/**
* contains the initial edges that are created when the editor is loaded
*/
const initialEdges = [
{
id: 'start-phase-1',
source: 'start',
target: 'phase-1',
},
{
id: 'phase-1-end',
source: 'phase-1',
target: 'end',
}
//* Initial edges to connect the startup nodes.
const initialEdges: Edge[] = [
{ id: 'start-phase-1', source: 'start', target: 'phase-1' },
{ id: 'phase-1-end', source: 'phase-1', target: 'end' },
];
/**
* The useFlowStore hook contains the implementation for editor functionality and state
* we can use this inside our editor component to access the current state
* and use any implemented functionality
* How we have defined the functions for our FlowState.
* We have the normal functionality of a default FlowState with some exceptions to account for extra functionality.
* The biggest changes are in onConnect and onDelete, which we have given extra functionality based on the nodes defined functions.
*
* * Provides:
* - Node and edge state management
* - Node creation, deletion, and updates
* - Custom connection handling via NodeConnects
* - Edge reconnection handling
*/
const useFlowStore = create<FlowState>((set, get) => ({
nodes: initialNodes,
edges: initialEdges,
edgeReconnectSuccessful: true,
onNodesChange: (changes) => {
set({
nodes: applyNodeChanges(changes, get().nodes)
});
},
onEdgesChange: (changes) => {
set({
edges: applyEdgeChanges(changes, get().edges)
});
},
// handles connection of newly created edges
onConnect: (connection) => {
set({
edges: addEdge(connection, get().edges)
});
},
// handles attempted reconnections of a previously disconnected edge
onReconnect: (oldEdge: Edge, newConnection: Connection) => {
get().edgeReconnectSuccessful = true;
set({
edges: reconnectEdge(oldEdge, newConnection, get().edges)
});
},
// Handles initiation of reconnection of edges that are manually disconnected from a node
onReconnectStart: () => {
set({
edgeReconnectSuccessful: false
});
},
// Drops the edge from the set of edges, removing it from the flow, if no successful reconnection occurred
onReconnectEnd: (_: unknown, edge: { id: string; }) => {
if (!get().edgeReconnectSuccessful) {
set({
edges: get().edges.filter((e) => e.id !== edge.id),
});
}
set({
edgeReconnectSuccessful: true
});
},
deleteNode: (nodeId: string) => {
set({
nodes: get().nodes.filter((n) => n.id !== nodeId),
edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId)
});
},
setNodes: (nodes) => {
set({nodes});
},
setEdges: (edges) => {
set({edges});
},
}),
);
nodes: initialNodes,
edges: initialEdges,
edgeReconnectSuccessful: true,
export default useFlowStore;
/**
* Handles changes to nodes triggered by ReactFlow.
*/
onNodesChange: (changes) =>
set({nodes: applyNodeChanges(changes, get().nodes)}),
/**
* Handles changes to edges triggered by ReactFlow.
*/
onEdgesChange: (changes) => set({ edges: applyEdgeChanges(changes, get().edges) }),
/**
* Handles creating a new connection between nodes.
* Updates edges and calls the node-specific connection functions.
*/
onConnect: (connection) => {
const edges = addEdge(connection, get().edges);
const nodes = get().nodes;
// connection has: { source, sourceHandle, target, targetHandle }
// Let's find the source and target ID's.
const sourceNode = nodes.find((n) => n.id == connection.source);
const targetNode = nodes.find((n) => n.id == connection.target);
// In case the nodes weren't found, return basic functionality.
if (sourceNode == undefined || targetNode == undefined || sourceNode.type == undefined || targetNode.type == undefined) {
set({ nodes, edges });
return;
}
// We should find out how their data changes by calling their respective functions.
const sourceConnectFunction = NodeConnects[sourceNode.type as keyof typeof NodeConnects]
const targetConnectFunction = NodeConnects[targetNode.type as keyof typeof NodeConnects]
// We're going to have to update their data based on how they want to update it.
sourceConnectFunction(sourceNode, targetNode, true)
targetConnectFunction(targetNode, sourceNode, false)
set({ nodes, edges });
},
/**
* Handles reconnecting an edge between nodes.
*/
onReconnect: (oldEdge, newConnection) => {
get().edgeReconnectSuccessful = true;
set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) });
},
onReconnectStart: () => set({ edgeReconnectSuccessful: false }),
onReconnectEnd: (_evt, edge) => {
if (!get().edgeReconnectSuccessful) {
set({ edges: get().edges.filter((e) => e.id !== edge.id) });
}
set({ edgeReconnectSuccessful: true });
},
/**
* Deletes a node by ID, respecting NodeDeletes rules.
* Also removes all edges connected to that node.
*/
deleteNode: (nodeId) => {
// Let's find our node to check if they have a special deletion function
const ourNode = get().nodes.find((n)=>n.id==nodeId);
const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1]
// If there's no function, OR, our function tells us we can delete it, let's do so...
if (ourFunction == undefined || ourFunction()) {
set({
nodes: get().nodes.filter((n) => n.id !== nodeId),
edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
})}
},
/**
* Replaces the entire nodes array in the store.
*/
setNodes: (nodes) => set({ nodes }),
/**
* Replaces the entire edges array in the store.
*/
setEdges: (edges) => set({ edges }),
/**
* Updates the data of a node by merging new data with existing data.
*/
updateNodeData: (nodeId, data) => {
set({
nodes: get().nodes.map((node) => {
if (node.id === nodeId) {
node = { ...node, data: { ...node.data, ...data }};
}
return node;
}),
});
},
/**
* Adds a new node to the flow store.
*/
addNode: (node: Node) => {
set({ nodes: [...get().nodes, node] });
},
}));
export default useFlowStore;

View File

@@ -1,46 +1,77 @@
import {
type Edge,
type Node,
type OnNodesChange,
type OnEdgesChange,
type OnConnect,
type OnReconnect,
} from '@xyflow/react';
type defaultNodeData = {
label: string;
};
export type StartNode = Node<defaultNodeData, 'start'>;
export type EndNode = Node<defaultNodeData, 'end'>;
export type GoalNode = Node<defaultNodeData & { value: string; }, 'goal'>;
export type NormNode = Node<defaultNodeData & { value: string; }, 'norm'>;
export type PhaseNode = Node<defaultNodeData & { number: number; }, 'phase'>;
// VisProgTypes.ts
import type { Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node } from '@xyflow/react';
import type { NodeTypes } from './NodeRegistry';
/**
* a type meant to house different node types, currently not used
* but will allow us to more clearly define nodeTypes when we implement
* computation of the Graph inside the ReactFlow editor
* Type representing all registered node types.
* This corresponds to the keys of NodeTypes in NodeRegistry.
*/
export type AppNode = Node | StartNode | EndNode | NormNode | GoalNode | PhaseNode;
export type AppNode = typeof NodeTypes;
/**
* The type for the Zustand store object used to manage the state of the ReactFlow editor
* The FlowState type defines the shape of the Zustand store used for managing the visual programming flow.
*
* Includes:
* - Nodes and edges currently in the flow
* - Callbacks for node and edge changes
* - Node deletion and updates
* - Edge reconnection handling
*/
export type FlowState = {
nodes: AppNode[];
nodes: Node[];
edges: Edge[];
edgeReconnectSuccessful: boolean;
/** Handler for changes to nodes triggered by ReactFlow */
onNodesChange: OnNodesChange;
/** Handler for changes to edges triggered by ReactFlow */
onEdgesChange: OnEdgesChange;
/** Handler for creating a new connection between nodes */
onConnect: OnConnect;
/** Handler for reconnecting an existing edge */
onReconnect: OnReconnect;
/** Called when an edge reconnect process starts */
onReconnectStart: () => void;
/**
* Called when an edge reconnect process ends.
* @param _ - event or unused parameter
* @param edge - the edge that finished reconnecting
*/
onReconnectEnd: (_: unknown, edge: { id: string }) => void;
/**
* Deletes a node and any connected edges.
* @param nodeId - the ID of the node to delete
*/
deleteNode: (nodeId: string) => void;
setNodes: (nodes: AppNode[]) => void;
/**
* Replaces the current nodes array in the store.
* @param nodes - new array of nodes
*/
setNodes: (nodes: Node[]) => void;
/**
* Replaces the current edges array in the store.
* @param edges - new array of edges
*/
setEdges: (edges: Edge[]) => void;
};
/**
* Updates the data of a node by merging new data with existing node data.
* @param nodeId - the ID of the node to update
* @param data - object containing new data fields to merge
*/
updateNodeData: (nodeId: string, data: object) => void;
/**
* Adds a new node to the flow.
* @param node - the Node object to add
*/
addNode: (node: Node) => void;
};

View File

@@ -1,61 +1,48 @@
import {useDraggable} from '@neodrag/react';
import {
useReactFlow,
type XYPosition
} from '@xyflow/react';
import {
type ReactNode,
useCallback,
useRef,
useState
} from 'react';
import useFlowStore from "../VisProgStores.tsx";
import styles from "../../VisProg.module.css"
import type {AppNode, PhaseNode, NormNode} from "../VisProgTypes.tsx";
import { useDraggable } from '@neodrag/react';
import { useReactFlow, type XYPosition } from '@xyflow/react';
import { type ReactNode, useCallback, useRef, useState } from 'react';
import useFlowStore from '../VisProgStores';
import styles from '../../VisProg.module.css';
import { NodeDefaults, type NodeTypes } from '../NodeRegistry'
/**
* DraggableNodeProps dictates the type properties of a DraggableNode
* Props for a draggable node within the drag-and-drop toolbar.
*
* @property className - Optional custom CSS classes for styling.
* @property children - The visual content or label rendered inside the draggable node.
* @property nodeType - The type of node represented (key from `NodeTypes`).
* @property onDrop - Function called when the node is dropped on the flow pane.
*/
interface DraggableNodeProps {
className?: string;
children: ReactNode;
nodeType: string;
onDrop: (nodeType: string, position: XYPosition) => void;
nodeType: keyof typeof NodeTypes;
onDrop: (nodeType: keyof typeof NodeTypes, position: XYPosition) => void;
}
/**
* Definition of a node inside the drag and drop toolbar,
* these nodes require an onDrop function to be supplied
* that dictates how the node is created in the graph.
* A draggable node element used in the drag-and-drop toolbar.
*
* @param className
* @param children
* @param nodeType
* @param onDrop
* @constructor
* Integrates with the NeoDrag library to handle drag events.
* On drop, it calls the provided `onDrop` function with the node type and drop position.
*
* @param props - The draggable node configuration.
* @returns A React element representing a draggable node.
*/
function DraggableNode({className, children, nodeType, onDrop}: DraggableNodeProps) {
function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeProps) {
const draggableRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<XYPosition>({x: 0, y: 0});
const [position, setPosition] = useState<XYPosition>({ x: 0, y: 0 });
// @ts-expect-error comes from a package and doesn't appear to play nicely with strict typescript typing
// The NeoDrag hook enables smooth drag functionality for this element.
// @ts-expect-error: NeoDrag typing incompatibility — safe to ignore.
useDraggable(draggableRef, {
position: position,
onDrag: ({offsetX, offsetY}) => {
// Calculate position relative to the viewport
setPosition({
x: offsetX,
y: offsetY,
});
position,
onDrag: ({ offsetX, offsetY }) => {
setPosition({ x: offsetX, y: offsetY });
},
onDragEnd: ({event}) => {
setPosition({x: 0, y: 0});
onDrop(nodeType, {
x: event.clientX,
y: event.clientY,
});
onDragEnd: ({ event }) => {
setPosition({ x: 0, y: 0 });
onDrop(nodeType, { x: event.clientX, y: event.clientY });
},
});
@@ -66,73 +53,69 @@ function DraggableNode({className, children, nodeType, onDrop}: DraggableNodePro
);
}
/**
* Adds a new node to the flow graph.
*
* Handles:
* - Automatic node ID generation based on existing nodes of the same type.
* - Loading of default data from the `NodeDefaults` registry.
* - Integration with the flow store to update global node state.
*
* @param nodeType - The type of node to create (from `NodeTypes`).
* @param position - The XY position in the flow canvas where the node will appear.
*/
function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) {
const { nodes, setNodes } = useFlowStore.getState();
// eslint-disable-next-line react-refresh/only-export-components
export function addNode(nodeType: string, position: XYPosition) {
const {setNodes} = useFlowStore.getState();
const nds : AppNode[] = useFlowStore.getState().nodes;
const newNode = () => {
switch (nodeType) {
case "phase":
{
const phaseNodes= nds.filter((node) => node.type === 'phase');
let phaseNumber;
if (phaseNodes.length > 0) {
const finalPhaseId : number = +(phaseNodes[phaseNodes.length - 1].id.split('-')[1]);
phaseNumber = finalPhaseId + 1;
} else {
phaseNumber = 1;
}
const phaseNode : PhaseNode = {
id: `phase-${phaseNumber}`,
type: nodeType,
position,
data: {label: 'new', number: phaseNumber},
}
return phaseNode;
}
case "norm":
{
const normNodes= nds.filter((node) => node.type === 'norm');
let normNumber
if (normNodes.length > 0) {
const finalNormId : number = +(normNodes[normNodes.length - 1].id.split('-')[1]);
normNumber = finalNormId + 1;
} else {
normNumber = 1;
}
const normNode : NormNode = {
id: `norm-${normNumber}`,
type: nodeType,
position,
data: {label: `new norm node`, value: "Pepper should be formal"},
}
return normNode;
}
default: {
throw new Error(`Node ${nodeType} not found`);
}
}
// Load any predefined data for this node type.
const defaultData = NodeDefaults[nodeType] ?? {}
// Currently, we find out what the Id is by checking the last node and adding one.
const sameTypeNodes = nodes.filter((node) => node.type === nodeType);
const nextNumber =
sameTypeNodes.length > 0
? (() => {
const lastNode = sameTypeNodes[sameTypeNodes.length - 1];
const parts = lastNode.id.split('-');
const lastNum = Number(parts[1]);
return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1;
})()
: 1;
const id = `${nodeType}-${nextNumber}`;
// Create new node
const newNode = {
id: id,
type: nodeType,
position,
data: {...defaultData}
}
setNodes(nds.concat(newNode()));
setNodes([...nodes, newNode]);
}
/**
* the DndToolbar defines how the drag and drop toolbar component works
* and includes the default onDrop behavior through handleNodeDrop
* @constructor
* The drag-and-drop toolbar component for the visual programming interface.
*
* Displays draggable node templates based on entries in `NodeDefaults`.
* Each droppable node can be dragged into the flow pane to instantiate it.
*
* Automatically filters nodes whose `droppable` flag is set to `true`.
*
* @returns A React element representing the drag-and-drop toolbar.
*/
export function DndToolbar() {
const {screenToFlowPosition} = useReactFlow();
const { screenToFlowPosition } = useReactFlow();
/**
* handleNodeDrop implements the default onDrop behavior
* Handles dropping a node onto the flow pane.
* Translates screen coordinates into flow coordinates using React Flow utilities.
*/
const handleNodeDrop = useCallback(
(nodeType: string, screenPosition: XYPosition) => {
(nodeType: keyof typeof NodeTypes, screenPosition: XYPosition) => {
const flow = document.querySelector('.react-flow');
const flowRect = flow?.getBoundingClientRect();
// Only add the node if it is inside the flow canvas area.
const isInFlow =
flowRect &&
screenPosition.x >= flowRect.left &&
@@ -140,7 +123,6 @@ export function DndToolbar() {
screenPosition.y >= flowRect.top &&
screenPosition.y <= flowRect.bottom;
// Create a new node and add it to the flow
if (isInFlow) {
const position = screenToFlowPosition(screenPosition);
addNode(nodeType, position);
@@ -149,19 +131,32 @@ export function DndToolbar() {
[screenToFlowPosition],
);
// Map over the default nodes to get all nodes that can be dropped from the toolbar.
const droppableNodes = Object.entries(NodeDefaults)
.filter(([, data]) => data.droppable)
.map(([type, data]) => ({
type: type as DraggableNodeProps['nodeType'],
data
}));
return (
<div className={`flex-col gap-lg padding-md ${styles.innerDndPanel}`}>
<div className="description">
You can drag these nodes to the pane to create new nodes.
</div>
<div className={`flex-row gap-lg ${styles.dndNodeContainer}`}>
<DraggableNode className={styles.draggableNodePhase} nodeType="phase" onDrop={handleNodeDrop}>
phase Node
</DraggableNode>
<DraggableNode className={styles.draggableNodeNorm} nodeType="norm" onDrop={handleNodeDrop}>
norm Node
</DraggableNode>
{/* Maps over all the nodes that are droppable, and puts them in the panel */}
{droppableNodes.map(({type, data}) => (
<DraggableNode
className={styles[`draggable-node-${type}`]} // Our current style signature for nodes
nodeType={type}
onDrop={handleNodeDrop}
>
{data.label}
</DraggableNode>
))}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,37 @@
import { NodeToolbar } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import useFlowStore from "../VisProgStores.tsx";
/**
* Props for the Toolbar component.
*
* @property nodeId - The ID of the node this toolbar is attached to.
* @property allowDelete - If `true`, the delete button is enabled; otherwise disabled.
*/
type ToolbarProps = {
nodeId: string;
allowDelete: boolean;
};
/**
* Node Toolbar definition:
* Handles: node deleting functionality
* Can be integrated to any custom node component as a React component
*
* @param {string} nodeId - The ID of the node for which the toolbar is rendered.
* @param {boolean} allowDelete - Enables or disables the delete functionality.
* @returns {React.JSX.Element} A JSX element representing the toolbar.
* @constructor
*/
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
const {deleteNode} = useFlowStore();
const deleteParentNode = ()=> {
deleteNode(nodeId);
}
return (
<NodeToolbar>
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
</NodeToolbar>);
}

View File

@@ -1,128 +0,0 @@
import {Handle, type NodeProps, NodeToolbar, Position} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import styles from '../../VisProg.module.css';
import useFlowStore from "../VisProgStores.tsx";
import type {
StartNode,
EndNode,
PhaseNode,
NormNode
} from "../VisProgTypes.tsx";
//
type ToolbarProps = {
nodeId: string;
allowDelete: boolean;
};
/**
* Node Toolbar definition:
* handles: node deleting functionality
* can be added to any custom node component as a React component
*
* @param {string} nodeId
* @param {boolean} allowDelete
* @returns {React.JSX.Element}
* @constructor
*/
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
const {deleteNode} = useFlowStore();
const deleteParentNode = ()=> {
deleteNode(nodeId);
}
return (
<NodeToolbar>
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
</NodeToolbar>);
}
// Definitions of Nodes
/**
* Start Node definition:
*
* @param {string} id
* @param {defaultNodeData} data
* @returns {React.JSX.Element}
* @constructor
*/
export const StartNodeComponent = ({id, data}: NodeProps<StartNode>) => {
return (
<>
<Toolbar nodeId={id} allowDelete={false}/>
<div className={styles.defaultNodeStart}>
<div> data test {data.label} </div>
<Handle type="source" position={Position.Right} id="start"/>
</div>
</>
);
};
/**
* End node definition:
*
* @param {string} id
* @param {defaultNodeData} data
* @returns {React.JSX.Element}
* @constructor
*/
export const EndNodeComponent = ({id, data}: NodeProps<EndNode>) => {
return (
<>
<Toolbar nodeId={id} allowDelete={false}/>
<div className={styles.defaultNodeEnd}>
<div> {data.label} </div>
<Handle type="target" position={Position.Left} id="end"/>
</div>
</>
);
};
/**
* Phase node definition:
*
* @param {string} id
* @param {defaultNodeData & {number: number}} data
* @returns {React.JSX.Element}
* @constructor
*/
export const PhaseNodeComponent = ({id, data}: NodeProps<PhaseNode>) => {
return (
<>
<Toolbar nodeId={id} allowDelete={true}/>
<div className={styles.defaultNodePhase}>
<div> phase {data.number} {data.label} </div>
<Handle type="target" position={Position.Left} id="target"/>
<Handle type="target" position={Position.Bottom} id="norms"/>
<Handle type="source" position={Position.Right} id="source"/>
</div>
</>
);
};
/**
* Norm node definition:
*
* @param {string} id
* @param {defaultNodeData & {value: string}} data
* @returns {React.JSX.Element}
* @constructor
*/
export const NormNodeComponent = ({id, data}: NodeProps<NormNode>) => {
return (
<>
<Toolbar nodeId={id} allowDelete={true}/>
<div className={styles.defaultNodeNorm}>
<div> Norm {data.label} </div>
<Handle type="source" position={Position.Right} id="NormSource"/>
</div>
</>
);
};

View File

@@ -0,0 +1,30 @@
.save-load-panel {
border-radius: 0 0 5pt 5pt;
background-color: canvas;
}
label.file-input-button {
cursor: pointer;
outline: forestgreen solid 2pt;
filter: drop-shadow(0 0 0.25rem forestgreen);
transition: filter 200ms;
input[type="file"] {
display: none;
}
&:hover {
filter: drop-shadow(0 0 0.5rem forestgreen);
}
}
.save-button {
text-decoration: none;
outline: dodgerblue solid 2pt;
filter: drop-shadow(0 0 0.25rem dodgerblue);
transition: filter 200ms;
&:hover {
filter: drop-shadow(0 0 0.5rem dodgerblue);
}
}

View File

@@ -0,0 +1,69 @@
import {type ChangeEvent, useRef, useState} from "react";
import useFlowStore from "../VisProgStores";
import visProgStyles from "../../VisProg.module.css";
import styles from "./SaveLoadPanel.module.css";
import { makeProjectBlob, type SavedProject } from "../../../../utils/SaveLoad";
export default function SaveLoadPanel() {
const nodes = useFlowStore((s) => s.nodes);
const edges = useFlowStore((s) => s.edges);
const setNodes = useFlowStore((s) => s.setNodes);
const setEdges = useFlowStore((s) => s.setEdges);
const [saveUrl, setSaveUrl] = useState<string | null>(null);
// ref to the file input
const inputRef = useRef<HTMLInputElement | null>(null);
const onSave = async (nameGuess = "visual-program") => {
const blob = makeProjectBlob(nameGuess, nodes, edges);
const url = URL.createObjectURL(blob);
setSaveUrl(url);
};
// input change handler updates the graph with a parsed JSON file
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const parsed = JSON.parse(text) as SavedProject;
if (!parsed.nodes || !parsed.edges) throw new Error("Invalid file format");
setNodes(parsed.nodes);
setEdges(parsed.edges);
} catch (e) {
console.error(e);
alert("Loading failed. See console.");
} finally {
// allow re-selecting same file next time
if (inputRef.current) inputRef.current.value = "";
}
};
const defaultName = "visual-program";
return (
<div className={`flex-col gap-lg padding-md border-lg ${styles.saveLoadPanel}`}>
<div className="description">You can save and load your graph here.</div>
<div className={`flex-row gap-lg justify-center`}>
<a
href={saveUrl ?? "#"}
onClick={() => onSave(defaultName)}
download={`${defaultName}.json`}
className={`${visProgStyles.draggableNode} ${styles.saveButton}`}
>
Save Graph
</a>
<label className={`${visProgStyles.draggableNode} ${styles.fileInputButton}`}>
<input
ref={inputRef}
type="file"
accept=".visprog.json,.json,.txt,application/json,text/plain"
onChange={handleFileChange}
/>
Load Graph
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import type { EndNodeData } from "./EndNode";
/**
* Default data for this node.
*/
export const EndNodeDefaults: EndNodeData = {
label: "End Node",
droppable: false,
hasReduce: true
};

View File

@@ -0,0 +1,67 @@
import {
Handle,
type NodeProps,
Position,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
/**
* The typing of this node's data
*/
export type EndNodeData = {
label: string;
droppable: boolean;
hasReduce: boolean;
};
export type EndNode = Node<EndNodeData>
/**
* Default function to render an end node given its properties
* @param props the node's properties
* @returns React.JSX.Element
*/
export default function EndNode(props: NodeProps<EndNode>) {
return (
<>
<Toolbar nodeId={props.id} allowDelete={false}/>
<div className={`${styles.defaultNode} ${styles.nodeEnd}`}>
<div className={"flex-row gap-sm"}>
End
</div>
<Handle type="target" position={Position.Left} id="target"/>
</div>
</>
);
}
/**
* Functionality for reducing this node into its more compact json program
* @param node the node to reduce
* @param nodes all nodes present
* @returns Dictionary, {id: node.id}
*/
export function EndReduce(node: Node, nodes: Node[]) {
// Replace this for nodes functionality
if (nodes.length <= -1) {
console.warn("Impossible nodes length in EndReduce")
}
return {
id: node.id
}
}
/**
* Any connection functionality that should get called when a connection is made to this node type (end)
* @param thisNode the node of which the functionality gets called
* @param otherNode the other node which has connected
* @param isThisSource whether this node is the one that is the source of the connection
*/
export function EndConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
// Replace this for connection logic
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
console.warn("Impossible node connection called in EndConnects")
}
}

View File

@@ -0,0 +1,12 @@
import type { GoalNodeData } from "./GoalNode";
/**
* Default data for this node
*/
export const GoalNodeDefaults: GoalNodeData = {
label: "Goal Node",
droppable: true,
description: "The robot will strive towards this goal",
achieved: false,
hasReduce: true,
};

View File

@@ -0,0 +1,107 @@
import {
Handle,
type NodeProps,
Position,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import { TextField } from '../../../../components/TextField';
import useFlowStore from '../VisProgStores';
/**
* The default data dot a phase node
* @param label: the label of this phase
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
* @param desciption: description of the goal
* @param hasReduce: whether this node has reducing functionality (true by default)
*/
export type GoalNodeData = {
label: string;
description: string;
droppable: boolean;
achieved: boolean;
hasReduce: boolean;
};
export type GoalNode = Node<GoalNodeData>
/**
* Defines how a Goal node should be rendered
* @param props NodeProps, like id, label, children
* @returns React.JSX.Element
*/
export default function GoalNode(props: NodeProps<GoalNode>) {
const data = props.data
const {updateNodeData} = useFlowStore();
const text_input_id = `goal_${props.id}_text_input`;
const checkbox_id = `goal_${props.id}_checkbox`;
const setDescription = (value: string) => {
updateNodeData(props.id, {...data, description: value});
}
const setAchieved = (value: boolean) => {
updateNodeData(props.id, {...data, achieved: value});
}
return <>
<Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeGoal} flex-col gap-sm`}>
<div className={"flex-row gap-md"}>
<label htmlFor={text_input_id}>Goal:</label>
<TextField
id={text_input_id}
value={data.description}
setValue={(val) => setDescription(val)}
placeholder={"To ..."}
/>
</div>
<div className={"flex-row gap-md align-center"}>
<label htmlFor={checkbox_id}>Achieved:</label>
<input
id={checkbox_id}
type={"checkbox"}
value={data.achieved ? "checked" : ""}
onChange={(e) => setAchieved(e.target.checked)}
/>
</div>
<Handle type="source" position={Position.Right} id="GoalSource"/>
</div>
</>;
}
/**
* Reduces each Goal, including its children down into its relevant data.
* @param node: The Node Properties of this node.
* @param nodes: all the nodes in the graph
*/
export function GoalReduce(node: Node, nodes: Node[]) {
// Replace this for nodes functionality
if (nodes.length <= -1) {
console.warn("Impossible nodes length in GoalReduce")
}
const data = node.data as GoalNodeData;
return {
id: node.id,
label: data.label,
description: data.description,
achieved: data.achieved,
}
}
/**
* This function is called whenever a connection is made with this node type (Goal)
* @param thisNode the node of this node type which function is called
* @param otherNode the other node which was part of the connection
* @param isThisSource whether this instance of the node was the source in the connection, true = yes.
*/
export function GoalConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
// Replace this for connection logic
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
console.warn("Impossible node connection called in EndConnects")
}
}

View File

@@ -0,0 +1,11 @@
import type { NormNodeData } from "./NormNode";
/**
* Default data for this node
*/
export const NormNodeDefaults: NormNodeData = {
label: "Norm Node",
droppable: true,
norm: "",
hasReduce: true,
};

View File

@@ -0,0 +1,90 @@
import {
Handle,
type NodeProps,
Position,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import { TextField } from '../../../../components/TextField';
import useFlowStore from '../VisProgStores';
/**
* The default data dot a phase node
* @param label: the label of this phase
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
* @param norm: list of strings of norms for this node
* @param hasReduce: whether this node has reducing functionality (true by default)
*/
export type NormNodeData = {
label: string;
droppable: boolean;
norm: string;
hasReduce: boolean;
};
export type NormNode = Node<NormNodeData>
/**
* Defines how a Norm node should be rendered
* @param props NodeProps, like id, label, children
* @returns React.JSX.Element
*/
export default function NormNode(props: NodeProps<NormNode>) {
const data = props.data;
const {updateNodeData} = useFlowStore();
const text_input_id = `norm_${props.id}_text_input`;
const setValue = (value: string) => {
updateNodeData(props.id, {norm: value});
}
return <>
<Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
<div className={"flex-row gap-sm"}>
<label htmlFor={text_input_id}>Norm :</label>
<TextField
id={text_input_id}
value={data.norm}
setValue={(val) => setValue(val)}
placeholder={"Pepper should ..."}
/>
</div>
<Handle type="source" position={Position.Right} id="norms"/>
</div>
</>;
};
/**
* Reduces each Norm, including its children down into its relevant data.
* @param node: The Node Properties of this node.
* @param nodes: all the nodes in the graph
*/
export function NormReduce(node: Node, nodes: Node[]) {
// Replace this for nodes functionality
if (nodes.length <= -1) {
console.warn("Impossible nodes length in NormReduce")
}
const data = node.data as NormNodeData;
return {
id: node.id,
label: data.label,
norm: data.norm,
}
}
/**
* This function is called whenever a connection is made with this node type (Norm)
* @param thisNode the node of this node type which function is called
* @param otherNode the other node which was part of the connection
* @param isThisSource whether this instance of the node was the source in the connection, true = yes.
*/
export function NormConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
// Replace this for connection logic
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
console.warn("Impossible node connection called in EndConnects")
}
}

View File

@@ -0,0 +1,11 @@
import type { PhaseNodeData } from "./PhaseNode";
/**
* Default data for this node
*/
export const PhaseNodeDefaults: PhaseNodeData = {
label: "Phase Node",
droppable: true,
children: [],
hasReduce: true,
};

View File

@@ -0,0 +1,116 @@
import {
Handle,
type NodeProps,
Position,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import { NodeReduces, NodesInPhase, NodeTypes } from '../NodeRegistry';
import useFlowStore from '../VisProgStores';
import { TextField } from '../../../../components/TextField';
/**
* The default data dot a phase node
* @param label: the label of this phase
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
* @param children: ID's of children of this node
* @param hasReduce: whether this node has reducing functionality (true by default)
*/
export type PhaseNodeData = {
label: string;
droppable: boolean;
children: string[];
hasReduce: boolean;
};
export type PhaseNode = Node<PhaseNodeData>
/**
* Defines how a phase node should be rendered
* @param props NodeProps, like id, label, children
* @returns React.JSX.Element
*/
export default function PhaseNode(props: NodeProps<PhaseNode>) {
const data = props.data;
const {updateNodeData} = useFlowStore();
const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value});
const label_input_id = `phase_${props.id}_label_input`;
return (
<>
<Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodePhase}`}>
<div className={"flex-row gap-sm"}>
<label htmlFor={label_input_id}>Name:</label>
<TextField
id={label_input_id}
value={data.label}
setValue={updateLabel}
placeholder={"Phase ..."}
/>
</div>
<Handle type="target" position={Position.Left} id="target"/>
<Handle type="target" position={Position.Bottom} id="norms"/>
<Handle type="source" position={Position.Right} id="source"/>
</div>
</>
);
};
/**
* Reduces each phase, including its children down into its relevant data.
* @param node the node which is being reduced
* @param nodes all the nodes currently in the flow.
* @returns A collection of all reduced nodes in this phase, starting with this phases' reduced data.
*/
export function PhaseReduce(node: Node, nodes: Node[]) {
const thisnode = node as PhaseNode;
const data = thisnode.data as PhaseNodeData;
// node typings that are not in phase
const nodesNotInPhase: string[] = Object.entries(NodesInPhase)
.filter(([, f]) => !f())
.map(([t]) => t);
// node typings that then are in phase
const nodesInPhase: string[] = Object.entries(NodeTypes)
.filter(([t]) => !nodesNotInPhase.includes(t))
.map(([t]) => t);
// children nodes
const childrenNodes = nodes.filter((node) => data.children.includes(node.id));
// Build the result object
const result: Record<string, unknown> = {
id: thisnode.id,
label: data.label,
};
nodesInPhase.forEach((type) => {
const typedChildren = childrenNodes.filter((child) => child.type == type);
const reducer = NodeReduces[type as keyof typeof NodeReduces];
if (!reducer) {
console.warn(`No reducer found for node type ${type}`);
result[type + "s"] = [];
} else {
result[type + "s"] = typedChildren.map((child) => reducer(child, nodes));
}
});
return result;
}
/**
* This function is called whenever a connection is made with this node type (phase)
* @param thisNode the node of this node type which function is called
* @param otherNode the other node which was part of the connection
* @param isThisSource whether this instance of the node was the source in the connection, true = yes.
*/
export function PhaseConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
console.log("Connect functionality called.")
const node = thisNode as PhaseNode
const data = node.data as PhaseNodeData
if (!isThisSource)
data.children.push(otherNode.id)
}

View File

@@ -0,0 +1,10 @@
import type { StartNodeData } from "./StartNode";
/**
* Default data for this node.
*/
export const StartNodeDefaults: StartNodeData = {
label: "Start Node",
droppable: false,
hasReduce: true
};

View File

@@ -0,0 +1,67 @@
import {
Handle,
type NodeProps,
Position,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
export type StartNodeData = {
label: string;
droppable: boolean;
hasReduce: boolean;
};
export type StartNode = Node<StartNodeData>
/**
* Defines how a Norm node should be rendered
* @param props NodeProps, like id, label, children
* @returns React.JSX.Element
*/
export default function StartNode(props: NodeProps<StartNode>) {
return (
<>
<Toolbar nodeId={props.id} allowDelete={false}/>
<div className={`${styles.defaultNode} ${styles.nodeStart}`}>
<div className={"flex-row gap-sm"}>
Start
</div>
<Handle type="source" position={Position.Right} id="source"/>
</div>
</>
);
}
/**
* The reduce function for this node type.
* @param node this node
* @param nodes all the nodes in the graph
* @returns a reduced structure of this node
*/
export function StartReduce(node: Node, nodes: Node[]) {
// Replace this for nodes functionality
if (nodes.length <= -1) {
console.warn("Impossible nodes length in StartReduce")
}
return {
id: node.id
}
}
/**
* This function is called whenever a connection is made with this node type (start)
* @param thisNode the node of this node type which function is called
* @param otherNode the other node which was part of the connection
* @param isThisSource whether this instance of the node was the source in the connection, true = yes.
*/
export function StartConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
// Replace this for connection logic
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
console.warn("Impossible node connection called in EndConnects")
}
}

View File

@@ -0,0 +1,12 @@
import type { TriggerNodeData } from "./TriggerNode";
/**
* Default data for this node
*/
export const TriggerNodeDefaults: TriggerNodeData = {
label: "Trigger Node",
droppable: true,
triggers: [],
triggerType: "keywords",
hasReduce: true,
};

View File

@@ -0,0 +1,228 @@
import {
Handle,
type NodeProps,
Position,
type Connection,
type Edge,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import useFlowStore from '../VisProgStores';
import { useState } from 'react';
import { RealtimeTextField, TextField } from '../../../../components/TextField';
import duplicateIndices from '../../../../utils/duplicateIndices';
/**
* The default data structure for a Trigger node
*
* Represents configuration for a node that activates when a specific condition is met,
* such as keywords being spoken or emotions detected.
*
* @property label: the display label of this Trigger node.
* @property droppable: Whether this node can be dropped from the toolbar (default: true).
* @property triggerType - The type of trigger ("keywords" or a custom string).
* @property triggers - The list of keyword triggers (if applicable).
* @property hasReduce - Whether this node supports reduction logic.
*/
export type TriggerNodeData = {
label: string;
droppable: boolean;
triggerType: "keywords" | string;
triggers: Keyword[] | never;
hasReduce: boolean;
};
export type TriggerNode = Node<TriggerNodeData>
/**
* Determines whether a Trigger node can connect to another node or edge.
*
* @param connection - The connection or edge being attempted to connect towards.
* @returns `true` if the connection is defined; otherwise, `false`.
*/
export function TriggerNodeCanConnect(connection: Connection | Edge): boolean {
return (connection != undefined);
}
/**
* Defines how a Trigger node should be rendered
* @param props - Node properties provided by React Flow, including `id` and `data`.
* @returns The rendered TriggerNode React element (React.JSX.Element).
*/
export default function TriggerNode(props: NodeProps<TriggerNode>) {
const data = props.data;
const {updateNodeData} = useFlowStore();
const setKeywords = (keywords: Keyword[]) => {
updateNodeData(props.id, {...data, triggers: keywords});
}
return <>
<Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
{data.triggerType === "emotion" && (
<div className={"flex-row gap-md"}>Emotion?</div>
)}
{data.triggerType === "keywords" && (
<Keywords
keywords={data.triggers}
setKeywords={setKeywords}
/>
)}
<Handle type="source" position={Position.Right} id="TriggerSource"/>
</div>
</>;
}
/**
* Reduces each Trigger, including its children down into its core data.
* @param node - The Trigger node to reduce.
* @param nodes - The list of all nodes in the current flow graph.
* @returns A simplified object containing the node label and its list of triggers.
*/
export function TriggerReduce(node: Node, nodes: Node[]) {
// Replace this for nodes functionality
if (nodes.length <= -1) {
console.warn("Impossible nodes length in TriggerReduce")
}
const data = node.data;
switch (data.triggerType) {
case "keywords":
return {
id: node.id,
type: "keywords",
label: data.label,
keywords: data.triggers,
};
default:
return {
...data,
id: node.id,
};
}
}
/**
* Handles logic that occurs when a connection is made involving a Trigger node.
*
* @param thisNode - The current Trigger node being connected.
* @param otherNode - The other node involved in the connection.
* @param isThisSource - Whether this node was the source of the connection.
*/
export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
// Replace this for connection logic
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
console.warn("Impossible node connection called in EndConnects")
}
}
// Definitions for the possible triggers, being keywords and emotions
/** Represents a single keyword trigger entry. */
type Keyword = { id: string, keyword: string };
/** Properties for an emotion-type trigger node. */
export type EmotionTriggerNodeProps = {
type: "emotion";
value: string;
}
/** Props for a keyword-type trigger node. */
export type KeywordTriggerNodeProps = {
type: "keywords";
value: Keyword[];
}
/** Union type for all possible Trigger node configurations. */
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;
/**
* Renders an input element that allows users to add new keyword triggers.
*
* When the input is committed, the `addKeyword` callback is called with the new keyword.
*
* @param param0 - An object containing the `addKeyword` function.
* @returns A React element(React.JSX.Element) providing an input for adding keywords.
*/
function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) {
const [input, setInput] = useState("");
const text_input_id = "keyword_adder_input";
return <div className={"flex-row gap-md"}>
<label htmlFor={text_input_id}>New Keyword:</label>
<RealtimeTextField
id={text_input_id}
value={input}
setValue={setInput}
onCommit={() => {
if (!input) return;
addKeyword(input);
setInput("");
}}
placeholder={"..."}
className={"flex-1"}
/>
</div>;
}
/**
* Displays and manages a list of keyword triggers for a Trigger node.
* Handles adding, editing, and removing keywords, as well as detecting duplicate entries.
*
* @param keywords - The current list of keyword triggers.
* @param setKeywords - A callback to update the keyword list in the parent node.
* @returns A React element(React.JSX.Element) for editing keyword triggers.
*/
function Keywords({
keywords,
setKeywords,
}: {
keywords: Keyword[];
setKeywords: (keywords: Keyword[]) => void;
}) {
type Interpolatable = string | number | boolean | bigint | null | undefined;
const inputElementId = (id: Interpolatable) => `keyword_${id}_input`;
/** Indices of duplicates in the keyword array. */
const [duplicates, setDuplicates] = useState<number[]>([]);
function replace(id: string, value: string) {
value = value.trim();
const newKeywords = value === ""
? keywords.filter((kw) => kw.id != id)
: keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw);
setKeywords(newKeywords);
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
}
function add(value: string) {
value = value.trim();
if (value === "") return;
const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}];
setKeywords(newKeywords);
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
}
return <>
<span>Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken.</span>
{[...keywords].map(({id, keyword}, index) => {
return <div key={id} className={"flex-row gap-md"}>
<label htmlFor={inputElementId(id)}>Keyword:</label>
<TextField
id={inputElementId(id)}
value={keyword}
setValue={(val) => replace(id, val)}
placeholder={"..."}
className={"flex-1"}
invalid={duplicates.includes(index)}
/>
</div>;
})}
<KeywordAdder addKeyword={add} />
</>;
}

19
src/utils/SaveLoad.ts Normal file
View File

@@ -0,0 +1,19 @@
import {type Edge, type Node } from "@xyflow/react";
export type SavedProject = {
name: string;
savedASavedProject: string; // ISO timestamp
nodes: Node[];
edges: Edge[];
};
// Creates a JSON Blob containing the current visual program (nodes + edges)
export function makeProjectBlob(name: string, nodes: Node[], edges: Edge[]): Blob {
const payload = {
name,
savedAt: new Date().toISOString(),
nodes,
edges,
};
return new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
}

96
src/utils/cellStore.ts Normal file
View File

@@ -0,0 +1,96 @@
import {useSyncExternalStore} from "react";
type Unsub = () => void;
/**
* A simple reactive state container that holds a value of type `T` that provides methods to get, set, and subscribe.
*/
export type Cell<T> = {
/**
* Returns the current value stored in the cell.
*/
get: () => T;
/**
* Updates the cell's value, pass either a direct value or an updater function.
*
* @example
* ```ts
* count.set(5);
* count.set(prev => prev + 1);
* ```
*/
set: (next: T | ((prev: T) => T)) => void;
/**
* Subscribe to changes in the cell's value, meaning the provided callback is called whenever the value changes.
* Returns an unsubscribe function.
*
* @example
* ```ts
* const unsubscribe = count.subscribe(() => console.log(count.get()));
* // later:
* unsubscribe();
* ```
*/
subscribe: (callback: () => void) => Unsub;
};
/**
* Creates a new reactive state container (`Cell`) with an initial value.
*
* This function allows you to store and mutate state outside of React,
* while still supporting subscriptions for reactivity.
*
* @param initial - The initial value for the cell.
* @returns A Cell object with `get`, `set`, and `subscribe` methods.
*
* @example
* ```ts
* const count = cell(0);
* count.set(10);
* console.log(count.get()); // 10
* ```
*/
export function cell<T>(initial: T): Cell<T> {
let value = initial;
const listeners = new Set<() => void>();
return {
get: () => value,
set: (next) => {
value = typeof next === "function" ? (next as (v: T) => T)(value) : next;
for (const l of listeners) l();
},
subscribe: (callback) => {
listeners.add(callback);
return () => listeners.delete(callback);
},
};
}
/**
* React hook that subscribes a component to a Cell.
*
* Automatically re-renders the component whenever the Cell's value changes.
* Uses Reacts built-in `useSyncExternalStore` for correct subscription behavior.
*
* @param c - The cell to subscribe to.
* @returns The current value of the cell.
*
* @example
* ```tsx
* const count = cell(0);
*
* function Counter() {
* const value = useCell(count);
* return (
* <button onClick={() => count.set(v => v + 1)}>
* Count: {value}
* </button>
* );
* }
* ```
*/
export function useCell<T>(c: Cell<T>) {
return useSyncExternalStore(c.subscribe, c.get, c.get);
}

View File

@@ -0,0 +1,19 @@
/**
* Find the indices of all elements that occur more than once.
*
* @param array The array to search for duplicates.
* @returns An array of indices where an element occurs more than once, in no particular order.
*/
export default function duplicateIndices<T>(array: T[]): number[] {
const positions = new Map<T, number[]>();
array.forEach((value, i) => {
if (!positions.has(value)) positions.set(value, []);
positions.get(value)!.push(i);
});
// flatten all index lists with more than one element
return Array.from(positions.values())
.filter(idxs => idxs.length > 1)
.flat();
}

View File

@@ -0,0 +1,21 @@
/**
* Format a time duration like `HH:MM:SS.mmm`.
*
* @param durationMs time duration in milliseconds.
* @return formatted time string.
*/
export default function formatDuration(durationMs: number): string {
const isNegative = durationMs < 0;
if (isNegative) durationMs = -durationMs;
const hours = Math.floor(durationMs / 3600000);
const minutes = Math.floor((durationMs % 3600000) / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
const milliseconds = Math.floor(durationMs % 1000);
return (isNegative ? '-' : '') +
`${hours.toString().padStart(2, '0')}:` +
`${minutes.toString().padStart(2, '0')}:` +
`${seconds.toString().padStart(2, '0')}.` +
`${milliseconds.toString().padStart(3, '0')}`;
}

View File

@@ -0,0 +1,24 @@
export type PriorityFilterPredicate<T> = {
priority: number;
predicate: (element: T) => boolean | null; // The predicate and its priority are ignored if it returns null.
}
/**
* Applies a list of priority predicates to an element. For all predicates that don't return null, if the ones with the highest level return true, then this function returns true.
* @param element The element to apply the predicates to.
* @param predicates The list of predicates to apply.
*/
export function applyPriorityPredicates<T>(element: T, predicates: PriorityFilterPredicate<T>[]): boolean {
let highestPriority = -1;
let highestKeep = true;
for (const predicate of predicates) {
if (predicate.priority >= highestPriority) {
const predicateKeep = predicate.predicate(element);
if (predicateKeep === null) continue; // This predicate doesn't care about the element, so skip it
if (predicate.priority > highestPriority) highestKeep = true;
highestPriority = predicate.priority;
highestKeep = highestKeep && predicateKeep;
}
}
return highestKeep;
}

View File

@@ -0,0 +1,328 @@
import {render, screen, waitFor, fireEvent} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as React from "react";
type ControlledUseState = typeof React.useState & {
__forceNextReturn?: (value: any) => jest.Mock;
__resetMockState?: () => void;
};
jest.mock("react", () => {
const actual = jest.requireActual("react");
const queue: Array<{value: any; setter: jest.Mock}> = [];
const mockUseState = ((initial: any) => {
if (queue.length) {
const {value, setter} = queue.shift()!;
return [value, setter];
}
return actual.useState(initial);
}) as ControlledUseState;
mockUseState.__forceNextReturn = (value: any) => {
const setter = jest.fn();
queue.push({value, setter});
return setter;
};
mockUseState.__resetMockState = () => {
queue.length = 0;
};
return {
__esModule: true,
...actual,
useState: mockUseState,
};
});
import Filters from "../../../src/components/Logging/Filters.tsx";
import type {LogFilterPredicate, LogRecord} from "../../../src/components/Logging/useLogs.ts";
const GLOBAL = "global_log_level";
const AGENT_PREFIX = "agent_log_level_";
const optionMapping = new Map([
["ALL", 0],
["DEBUG", 10],
["INFO", 20],
["WARNING", 30],
["ERROR", 40],
["CRITICAL", 50],
["NONE", 999_999_999_999],
]);
const controlledUseState = React.useState as ControlledUseState;
afterEach(() => {
controlledUseState.__resetMockState?.();
});
function getCallArg<T>(mock: jest.Mock, index = 0): T {
return mock.mock.calls[index][0] as T;
}
function sampleRecord(levelno: number, name = "any.logger"): LogRecord {
return {
levelname: "UNKNOWN",
levelno,
name,
message: "Whatever",
created: 0,
relativeCreated: 0,
firstCreated: 0,
firstRelativeCreated: 0,
};
}
// --------------------------------------------------------------------------
describe("Filters", () => {
describe("Global level filter", () => {
it("initializes to INFO when missing", async () => {
const setFilterPredicates = jest.fn();
const filterPredicates = new Map<string, LogFilterPredicate>();
const view = render(
<Filters
filterPredicates={filterPredicates}
setFilterPredicates={setFilterPredicates}
agentNames={new Set<string>()}
/>
);
// Effect sets default to INFO
await waitFor(() => {
expect(setFilterPredicates).toHaveBeenCalled();
});
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
const newMap = updater(filterPredicates);
const global = newMap.get(GLOBAL)!;
expect(global.value).toBe("INFO");
expect(global.priority).toBe(0);
// Predicate gate at INFO (>= 20)
expect(global.predicate(sampleRecord(10))).toBe(false);
expect(global.predicate(sampleRecord(20))).toBe(true);
// UI shows INFO selected after parent state updates
view.rerender(
<Filters
filterPredicates={newMap}
setFilterPredicates={setFilterPredicates}
agentNames={new Set<string>()}
/>
);
const globalSelect = screen.getByLabelText("Global:");
expect((globalSelect as HTMLSelectElement).value).toBe("INFO");
});
it("updates predicate when selecting a higher level", async () => {
// Start with INFO already present
const existing = new Map<string, LogFilterPredicate>([
[
GLOBAL,
{
value: "INFO",
priority: 0,
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
}
]
]);
const setFilterPredicates = jest.fn();
const user = userEvent.setup();
render(
<Filters
filterPredicates={existing}
setFilterPredicates={setFilterPredicates}
agentNames={new Set<string>()}
/>
);
const select = screen.getByLabelText("Global:");
await user.selectOptions(select, "ERROR");
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
const updated = updater(existing);
const global = updated.get(GLOBAL)!;
expect(global.value).toBe("ERROR");
expect(global.priority).toBe(0);
expect(global.predicate(sampleRecord(30))).toBe(false);
expect(global.predicate(sampleRecord(40))).toBe(true);
});
});
describe("Agent level filters", () => {
it("adds an agent using the current global level when none specified", async () => {
// Global set to WARNING
const existing = new Map<string, LogFilterPredicate>([
[
GLOBAL,
{
value: "WARNING",
priority: 0,
predicate: (r: any) => r.levelno >= optionMapping.get("WARNING")!
}
]
]);
const setFilterPredicates = jest.fn();
const user = userEvent.setup();
render(
<Filters
filterPredicates={existing}
setFilterPredicates={setFilterPredicates}
agentNames={new Set<string>(["pepper.speech", "vision.agent"])}
/>
);
const addSelect = screen.getByLabelText("Add:");
await user.selectOptions(addSelect, "pepper.speech");
// Agent setter is functional: prev => next
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
const next = updater(existing);
const key = AGENT_PREFIX + "pepper.speech";
const agentPred = next.get(key)!;
expect(agentPred.priority).toBe(1);
expect(agentPred.value).toEqual({agentName: "pepper.speech", level: "WARNING"});
// When agentName matches, enforce WARNING (>= 30)
expect(agentPred.predicate(sampleRecord(20, "pepper.speech"))).toBe(false);
expect(agentPred.predicate(sampleRecord(30, "pepper.speech"))).toBe(true);
// Other agents -> null
expect(agentPred.predicate(sampleRecord(999, "other"))).toBeNull();
});
it("changes an agent's level when its select is updated", async () => {
// Prepopulate agent predicate at WARNING
const key = AGENT_PREFIX + "pepper.speech";
const existing = new Map<string, LogFilterPredicate>([
[
GLOBAL,
{
value: "INFO",
priority: 0,
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
}
],
[
key,
{
value: {agentName: "pepper.speech", level: "WARNING"},
priority: 1,
predicate: (r: any) => (r.name === "pepper.speech" ? r.levelno >= optionMapping.get("WARNING")! : null)
}
]
]);
const setFilterPredicates = jest.fn();
const user = userEvent.setup();
const element = render(
<Filters
filterPredicates={existing}
setFilterPredicates={setFilterPredicates}
agentNames={new Set(["pepper.speech"])}
/>
);
const agentSelect = element.container.querySelector("select#log_level_pepper\\.speech")!;
await user.selectOptions(agentSelect, "ERROR");
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
const next = updater(existing);
const updated = next.get(key)!;
expect(updated.value).toEqual({agentName: "pepper.speech", level: "ERROR"});
// Threshold moved to ERROR (>= 40)
expect(updated.predicate(sampleRecord(30, "pepper.speech"))).toBe(false);
expect(updated.predicate(sampleRecord(40, "pepper.speech"))).toBe(true);
});
it("deletes an agent predicate when clicking its name button", async () => {
const key = AGENT_PREFIX + "pepper.speech";
const existing = new Map<string, LogFilterPredicate>([
[
GLOBAL,
{
value: "INFO",
priority: 0,
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
}
],
[
key,
{
value: {agentName: "pepper.speech", level: "INFO"},
priority: 1,
predicate: (r: any) => (r.name === "pepper.speech" ? r.levelno >= optionMapping.get("INFO")! : null)
}
]
]);
const setFilterPredicates = jest.fn();
const user = userEvent.setup();
render(
<Filters
filterPredicates={existing}
setFilterPredicates={setFilterPredicates}
agentNames={new Set<string>(["pepper.speech"])}
/>
);
const deleteBtn = screen.getByRole("button", {name: "speech:"});
await user.click(deleteBtn);
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
const next = updater(existing);
expect(next.has(key)).toBe(false);
});
});
describe("Filter popup behavior", () => {
function renderWithPopupOpen() {
const existing = new Map<string, LogFilterPredicate>([
[
GLOBAL,
{
value: "INFO",
priority: 0,
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
}
]
]);
const setFilterPredicates = jest.fn();
const forceNext = controlledUseState.__forceNextReturn;
if (!forceNext) throw new Error("useState mock missing helper");
const setOpen = forceNext(true);
render(
<Filters
filterPredicates={existing}
setFilterPredicates={setFilterPredicates}
agentNames={new Set(["pepper.vision"])}
/>
);
return { setOpen };
}
it("closes the popup when clicking outside", () => {
const { setOpen } = renderWithPopupOpen();
fireEvent.mouseDown(document.body);
expect(setOpen).toHaveBeenCalledWith(false);
});
it("closes the popup when pressing Escape", () => {
const { setOpen } = renderWithPopupOpen();
fireEvent.keyDown(document, { key: "Escape" });
expect(setOpen).toHaveBeenCalledWith(false);
});
});
});

View File

@@ -0,0 +1,239 @@
import {render, screen, fireEvent, act, waitFor} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import type {Cell} from "../../../src/utils/cellStore.ts";
import {cell} from "../../../src/utils/cellStore.ts";
import type {LogFilterPredicate, LogRecord} from "../../../src/components/Logging/useLogs.ts";
const mockFiltersRender = jest.fn();
const loggingStoreRef: { current: null | { setState: (state: Partial<LoggingSettingsState>) => void } } = { current: null };
type LoggingSettingsState = {
showRelativeTime: boolean;
setShowRelativeTime: (show: boolean) => void;
scrollToBottom: boolean;
setScrollToBottom: (scroll: boolean) => void;
};
jest.mock("zustand", () => {
const actual = jest.requireActual("zustand");
const actualCreate = actual.create;
return {
__esModule: true,
...actual,
create: (...args: any[]) => {
const store = actualCreate(...args);
const state = store.getState();
if ("setShowRelativeTime" in state && "setScrollToBottom" in state) {
loggingStoreRef.current = store;
}
return store;
},
};
});
jest.mock("../../../src/components/Logging/Filters.tsx", () => {
const React = jest.requireActual("react");
return {
__esModule: true,
default: (props: any) => {
mockFiltersRender(props);
return React.createElement("div", {"data-testid": "filters-mock"}, "filters");
},
};
});
jest.mock("../../../src/components/Logging/useLogs.ts", () => {
const actual = jest.requireActual("../../../src/components/Logging/useLogs.ts");
return {
__esModule: true,
...actual,
useLogs: jest.fn(),
};
});
import {useLogs} from "../../../src/components/Logging/useLogs.ts";
const mockUseLogs = useLogs as jest.MockedFunction<typeof useLogs>;
type LoggingComponent = typeof import("../../../src/components/Logging/Logging.tsx").default;
let Logging: LoggingComponent;
beforeAll(async () => {
if (!Element.prototype.scrollIntoView) {
Object.defineProperty(Element.prototype, "scrollIntoView", {
configurable: true,
writable: true,
value: function () {},
});
}
({default: Logging} = await import("../../../src/components/Logging/Logging.tsx"));
});
beforeEach(() => {
mockUseLogs.mockReset();
mockFiltersRender.mockReset();
mockUseLogs.mockReturnValue({filteredLogs: [], distinctNames: new Set()});
resetLoggingStore();
});
afterEach(() => {
jest.restoreAllMocks();
});
function resetLoggingStore() {
loggingStoreRef.current?.setState({
showRelativeTime: false,
scrollToBottom: true,
});
}
function makeRecord(overrides: Partial<LogRecord> = {}): LogRecord {
return {
name: "pepper.logger",
message: "default",
levelname: "INFO",
levelno: 20,
created: 1,
relativeCreated: 1,
firstCreated: 1,
firstRelativeCreated: 1,
...overrides,
};
}
function makeCell(overrides: Partial<LogRecord> = {}): Cell<LogRecord> {
return cell(makeRecord(overrides));
}
describe("Logging component", () => {
it("renders log messages and toggles the timestamp between absolute and relative view", async () => {
const logCell = makeCell({
name: "pepper.trace.logging",
message: "Ping",
levelname: "WARNING",
levelno: 30,
created: 1_700_000_000,
relativeCreated: 12_345,
firstCreated: 1_700_000_000,
firstRelativeCreated: 12_345,
});
const names = new Set(["pepper.trace.logging"]);
mockUseLogs.mockReturnValue({filteredLogs: [logCell], distinctNames: names});
jest.spyOn(Date.prototype, "toLocaleTimeString").mockReturnValue("ABS TIME");
const user = userEvent.setup();
render(<Logging/>);
expect(screen.getByText("Logs")).toBeInTheDocument();
expect(screen.getByText("WARNING")).toBeInTheDocument();
expect(screen.getByText("logging")).toBeInTheDocument();
expect(screen.getByText("Ping")).toBeInTheDocument();
let timestamp = screen.queryByText("ABS TIME");
if (!timestamp) {
// if previous test left the store toggled, click once to show absolute time
timestamp = screen.getByText("00:00:12.345");
await user.click(timestamp);
timestamp = screen.getByText("ABS TIME");
}
await user.click(timestamp);
expect(screen.getByText("00:00:12.345")).toBeInTheDocument();
});
it("shows the scroll-to-bottom button after a manual scroll and scrolls when clicked", async () => {
const logs = [
makeCell({message: "first", firstRelativeCreated: 1}),
makeCell({message: "second", firstRelativeCreated: 2}),
];
mockUseLogs.mockReturnValue({filteredLogs: logs, distinctNames: new Set()});
const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {});
const user = userEvent.setup();
const view = render(<Logging/>);
expect(screen.queryByRole("button", {name: "Scroll to bottom"})).toBeNull();
const scrollable = view.container.querySelector(".scroll-y");
expect(scrollable).toBeTruthy();
fireEvent.wheel(scrollable!);
const button = await screen.findByRole("button", {name: "Scroll to bottom"});
await user.click(button);
expect(scrollSpy).toHaveBeenCalled();
await waitFor(() => {
expect(screen.queryByRole("button", {name: "Scroll to bottom"})).toBeNull();
});
});
it("scrolls the last element into view when a log cell updates", async () => {
const logCell = makeCell({message: "Initial", firstRelativeCreated: 42});
mockUseLogs.mockReturnValue({filteredLogs: [logCell], distinctNames: new Set()});
const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {});
render(<Logging/>);
await waitFor(() => {
expect(scrollSpy).toHaveBeenCalledTimes(1);
});
scrollSpy.mockClear();
act(() => {
const current = logCell.get();
logCell.set({...current, message: "Updated"});
});
expect(screen.getByText("Updated")).toBeInTheDocument();
await waitFor(() => {
expect(scrollSpy).toHaveBeenCalledTimes(1);
});
});
it("passes filter state to Filters and re-invokes useLogs when predicates change", async () => {
const distinct = new Set(["pepper.core"]);
mockUseLogs.mockImplementation((_filters: Map<string, LogFilterPredicate>) => ({
filteredLogs: [],
distinctNames: distinct,
}));
render(<Logging/>);
expect(mockFiltersRender).toHaveBeenCalledTimes(1);
const firstProps = mockFiltersRender.mock.calls[0][0];
expect(firstProps.agentNames).toBe(distinct);
const initialMap = firstProps.filterPredicates;
expect(initialMap).toBeInstanceOf(Map);
expect(initialMap.size).toBe(0);
expect(mockUseLogs).toHaveBeenCalledWith(initialMap);
const updatedPredicate: LogFilterPredicate = {
value: "custom",
priority: 0,
predicate: () => true,
};
act(() => {
firstProps.setFilterPredicates((prev: Map<string, LogFilterPredicate>) => {
const next = new Map(prev);
next.set("custom", updatedPredicate);
return next;
});
});
await waitFor(() => {
expect(mockUseLogs).toHaveBeenCalledTimes(2);
});
const nextFilters = mockUseLogs.mock.calls[1][0];
expect(nextFilters.get("custom")).toBe(updatedPredicate);
const secondProps = mockFiltersRender.mock.calls[mockFiltersRender.mock.calls.length - 1][0];
expect(secondProps.filterPredicates).toBe(nextFilters);
});
});

View File

@@ -0,0 +1,246 @@
import { render, screen, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import {type LogRecord, useLogs} from "../../../src/components/Logging/useLogs.ts";
import {type cell, useCell} from "../../../src/utils/cellStore.ts";
import { StrictMode } from "react";
jest.mock("../../../src/utils/priorityFiltering.ts", () => ({
applyPriorityPredicates: jest.fn((_log, preds: any[]) =>
preds.every(() => true) // default: pass all
),
}));
import {applyPriorityPredicates} from "../../../src/utils/priorityFiltering.ts";
class MockEventSource {
url: string;
onmessage: ((event: { data: string }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
close = jest.fn();
constructor(url: string) {
this.url = url;
// expose the latest instance for tests:
(globalThis as any).__es = this;
}
}
beforeAll(() => {
globalThis.EventSource = MockEventSource as any;
});
afterEach(() => {
// reset mock so previous instance not reused accidentally
(globalThis as any).__es = undefined;
jest.clearAllMocks();
});
function LogsProbe({ filters }: { filters: Map<string, any> }) {
const { filteredLogs, distinctNames } = useLogs(filters);
return (
<div>
<div data-testid="names-count">{distinctNames.size}</div>
<ul data-testid="logs">
{filteredLogs.map((c, i) => (
<LogItem key={i} cell={c} index={i} />
))}
</ul>
</div>
);
}
function LogItem({ cell: c, index }: { cell: ReturnType<typeof cell<LogRecord>>; index: number }) {
const value = useCell(c);
return (
<li data-testid={`log-${index}`}>
<span data-testid={`log-${index}-name`}>{value.name}</span>
<span data-testid={`log-${index}-msg`}>{value.message}</span>
<span data-testid={`log-${index}-first`}>{String(value.firstCreated)}</span>
<span data-testid={`log-${index}-created`}>{String(value.created)}</span>
<span data-testid={`log-${index}-ref`}>{value.reference ?? ""}</span>
</li>
);
}
function emit(log: LogRecord) {
const eventSource = (globalThis as any).__es as MockEventSource;
if (!eventSource || !eventSource.onmessage) throw new Error("EventSource not initialized");
act(() => {
eventSource.onmessage!({ data: JSON.stringify(log) });
});
}
describe("useLogs (unit)", () => {
it("creates EventSource once and closes on unmount", () => {
const filters = new Map(); // allow all by default
const { unmount } = render(
<StrictMode>
<LogsProbe filters={filters} />
</StrictMode>
);
const es = (globalThis as any).__es as MockEventSource;
expect(es).toBeTruthy();
expect(es.url).toBe("http://localhost:8000/logs/stream");
unmount();
expect(es.close).toHaveBeenCalledTimes(1);
});
it("appends filtered logs and collects distinct names", () => {
const filters = new Map();
render(
<StrictMode>
<LogsProbe filters={filters} />
</StrictMode>
);
expect(screen.getByTestId("names-count")).toHaveTextContent("0");
emit({
levelname: "DEBUG",
levelno: 10,
name: "alpha",
message: "m1",
created: 1,
relativeCreated: 1,
firstCreated: 1,
firstRelativeCreated: 1,
});
emit({
levelname: "DEBUG",
levelno: 10,
name: "beta",
message: "m2",
created: 2,
relativeCreated: 2,
firstCreated: 2,
firstRelativeCreated: 2,
});
emit({
levelname: "DEBUG",
levelno: 10,
name: "alpha",
message: "m3",
created: 3,
relativeCreated: 3,
firstCreated: 3,
firstRelativeCreated: 3,
});
// 3 messages (no reference), 2 distinct names
expect(screen.getAllByRole("listitem")).toHaveLength(3);
expect(screen.getByTestId("names-count")).toHaveTextContent("2");
expect(screen.getByTestId("log-0-name")).toHaveTextContent("alpha");
expect(screen.getByTestId("log-1-name")).toHaveTextContent("beta");
expect(screen.getByTestId("log-2-name")).toHaveTextContent("alpha");
});
it("updates first message with reference when a second one with that reference comes", () => {
const filters = new Map();
render(<LogsProbe filters={filters} />);
// First message with ref r1
emit({
levelname: "DEBUG",
levelno: 10,
name: "svc",
message: "first",
reference: "r1",
created: 10,
relativeCreated: 10,
firstCreated: 10,
firstRelativeCreated: 10,
});
// Second message with same ref r1, should still be a single item
emit({
levelname: "DEBUG",
levelno: 10,
name: "svc",
message: "second",
reference: "r1",
created: 20,
relativeCreated: 20,
firstCreated: 20,
firstRelativeCreated: 20,
});
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(1);
// Same single item, but message should be "second"
expect(screen.getByTestId("log-0-msg")).toHaveTextContent("second");
// The "firstCreated" should remain the original (10), while "created" is now 20
expect(screen.getByTestId("log-0-first")).toHaveTextContent("10");
expect(screen.getByTestId("log-0-created")).toHaveTextContent("20");
expect(screen.getByTestId("log-0-ref")).toHaveTextContent("r1");
});
it("runs recomputeFiltered when filters change", () => {
const allowAll = new Map<string, any>();
const { rerender } = render(<LogsProbe filters={allowAll} />);
emit({
levelname: "DEBUG",
levelno: 10,
name: "n1",
message: "ok",
created: 1,
relativeCreated: 1,
firstCreated: 1,
firstRelativeCreated: 1,
});
emit({
levelname: "DEBUG",
levelno: 10,
name: "n2",
message: "ok",
created: 2,
relativeCreated: 2,
firstCreated: 2,
firstRelativeCreated: 2,
});
emit({
levelname: "INFO",
levelno: 20,
name: "n3",
message: "ok1",
reference: "r1",
created: 3,
relativeCreated: 3,
firstCreated: 3,
firstRelativeCreated: 3,
});
emit({
levelname: "INFO",
levelno: 20,
name: "n3",
message: "ok2",
reference: "r1",
created: 4,
relativeCreated: 4,
firstCreated: 4,
firstRelativeCreated: 4,
});
expect(screen.getAllByRole("listitem")).toHaveLength(3);
// Now change filters to block all < INFO
(applyPriorityPredicates as jest.Mock).mockImplementation((l) => l.levelno >= 20);
const blockDebug = new Map<string, any>([["dummy", { value: true }]]);
rerender(<LogsProbe filters={blockDebug} />);
// Should recompute with shorter list
expect(screen.queryAllByRole("listitem")).toHaveLength(1);
// Switch back to allow-all
(applyPriorityPredicates as jest.Mock).mockImplementation((_log, preds: any[]) =>
preds.every(() => true)
);
rerender(<LogsProbe filters={allowAll} />);
// recompute should restore all three
expect(screen.getAllByRole("listitem")).toHaveLength(3);
});
});

0
test/eslint.config.js.ts Normal file
View File

View File

@@ -0,0 +1,104 @@
import { render, screen, act, cleanup, waitFor } from '@testing-library/react';
import ConnectedRobots from '../../../src/pages/ConnectedRobots/ConnectedRobots';
// Mock event source
const mockInstances: MockEventSource[] = [];
class MockEventSource {
url: string;
onmessage: ((event: MessageEvent) => void) | null = null;
closed = false;
constructor(url: string) {
this.url = url;
mockInstances.push(this);
}
sendMessage(data: string) {
// Trigger whatever the component listens to
this.onmessage?.({ data } as MessageEvent);
}
close() {
this.closed = true;
}
}
// mock event source generation with fake function that returns our fake mock source
beforeAll(() => {
// Cast globalThis to a type exposing EventSource and assign a mocked constructor.
(globalThis as unknown as { EventSource?: typeof EventSource }).EventSource =
jest.fn((url: string) => new MockEventSource(url)) as unknown as typeof EventSource;
});
// clean after tests
afterEach(() => {
cleanup();
jest.restoreAllMocks();
mockInstances.length = 0;
});
describe('ConnectedRobots', () => {
test('renders initial state correctly', () => {
render(<ConnectedRobots />);
// Check initial texts (before connection)
expect(screen.getByText('Is robot currently connected?')).toBeInTheDocument();
expect(screen.getByText(/Robot is currently:\s*checking/i)).toBeInTheDocument();
expect(
screen.getByText(/If checking continues, make sure CB is properly loaded/i)
).toBeInTheDocument();
});
test('updates to connected when message data is true', async () => {
render(<ConnectedRobots />);
const eventSource = mockInstances[0];
expect(eventSource).toBeDefined();
// Check state after getting 'true' message
await act(async () => {
eventSource.sendMessage('true');
});
await waitFor(() => {
expect(screen.getByText(/connected! 🟢/i)).toBeInTheDocument();
});
});
test('updates to not connected when message data is false', async () => {
render(<ConnectedRobots />);
const eventSource = mockInstances[0];
// Check statew after getting 'false' message
await act(async () => {
eventSource.sendMessage('false');
});
await waitFor(() => {
expect(screen.getByText(/not connected.*🔴/i)).toBeInTheDocument();
});
});
test('handles invalid JSON gracefully', async () => {
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
render(<ConnectedRobots />);
const eventSource = mockInstances[0];
await act(async () => {
eventSource.sendMessage('not-json');
});
expect(logSpy).toHaveBeenCalledWith(
'Ping message not in correct format:',
'not-json'
);
});
test('closes EventSource on unmount', () => {
render(<ConnectedRobots />);
const eventSource = mockInstances[0];
const closeSpy = jest.spyOn(eventSource, 'close');
cleanup();
expect(closeSpy).toHaveBeenCalled();
expect(eventSource.closed).toBe(true);
});
});

View File

@@ -1,986 +1,5 @@
import type {Edge} from "@xyflow/react";
import graphReducer, {
defaultGraphPreprocessor, defaultPhaseReducer,
orderPhases
} from "../../../../src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts";
import type {PreparedPhase} from "../../../../src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts";
import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
import type {AppNode} from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx";
// sets of default values for nodes and edges to be used for test cases
type FlowState = {
name: string;
nodes: AppNode[];
edges: Edge[];
};
// predefined graphs for testing:
const onlyOnePhase : FlowState = {
name: "onlyOnePhase",
nodes: [
{
id: 'start',
type: 'start',
position: {x: 0, y: 0},
data: {label: 'start'}
},
{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
{
id: 'end',
type: 'end',
position: {x: 0, y: 300},
data: {label: 'End'}
}
],
edges:[
{
id: 'start-phase-1',
source: 'start',
target: 'phase-1',
},
{
id: 'phase-1-end',
source: 'phase-1',
target: 'end',
}
]
};
const onlyThreePhases : FlowState = {
name: "onlyThreePhases",
nodes: [
{
id: 'start',
type: 'start',
position: {x: 0, y: 0},
data: {label: 'start'}
},
{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
{
id: 'phase-3',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 3},
},
{
id: 'phase-2',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 2},
},
{
id: 'end',
type: 'end',
position: {x: 0, y: 300},
data: {label: 'End'}
}
],
edges:[
{
id: 'start-phase-1',
source: 'start',
target: 'phase-1',
},
{
id: 'phase-1-phase-2',
source: 'phase-1',
target: 'phase-2',
},
{
id: 'phase-2-phase-3',
source: 'phase-2',
target: 'phase-3',
},
{
id: 'phase-3-end',
source: 'phase-3',
target: 'end',
}
]
};
const onlySingleEdgeNorms : FlowState = {
name: "onlySingleEdgeNorms",
nodes: [
{
id: 'start',
type: 'start',
position: {x: 0, y: 0},
data: {label: 'start'}
},
{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
{
id: 'norm-1',
type: 'norm',
position: {x: 0, y: 150},
data: {label: 'Generic Norm', value: "generic"},
},
{
id: 'norm-2',
type: 'norm',
position: {x: 0, y: 150},
data: {label: 'Generic Norm', value: "generic"},
},
{
id: 'phase-3',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 3},
},
{
id: 'phase-2',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 2},
},
{
id: 'end',
type: 'end',
position: {x: 0, y: 300},
data: {label: 'End'}
}
],
edges:[
{
id: 'start-phase-1',
source: 'start',
target: 'phase-1',
},
{
id: 'norm-1-phase-2',
source: 'norm-1',
target: 'phase-2',
},
{
id: 'phase-1-phase-2',
source: 'phase-1',
target: 'phase-2',
},
{
id: 'phase-2-phase-3',
source: 'phase-2',
target: 'phase-3',
},
{
id: 'norm-2-phase-3',
source: 'norm-2',
target: 'phase-3',
},
{
id: 'phase-3-end',
source: 'phase-3',
target: 'end',
}
]
};
const multiEdgeNorms : FlowState = {
name: "multiEdgeNorms",
nodes: [
{
id: 'start',
type: 'start',
position: {x: 0, y: 0},
data: {label: 'start'}
},
{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
{
id: 'norm-1',
type: 'norm',
position: {x: 0, y: 150},
data: {label: 'Generic Norm', value: "generic"},
},
{
id: 'norm-2',
type: 'norm',
position: {x: 0, y: 150},
data: {label: 'Generic Norm', value: "generic"},
},
{
id: 'phase-3',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 3},
},
{
id: 'norm-3',
type: 'norm',
position: {x: 0, y: 150},
data: {label: 'Generic Norm', value: "generic"},
},
{
id: 'phase-2',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 2},
},
{
id: 'end',
type: 'end',
position: {x: 0, y: 300},
data: {label: 'End'}
}
],
edges:[
{
id: 'start-phase-1',
source: 'start',
target: 'phase-1',
},
{
id: 'norm-1-phase-2',
source: 'norm-1',
target: 'phase-2',
},
{
id: 'norm-1-phase-3',
source: 'norm-1',
target: 'phase-3',
},
{
id: 'phase-1-phase-2',
source: 'phase-1',
target: 'phase-2',
},
{
id: 'norm-3-phase-1',
source: 'norm-3',
target: 'phase-1',
},
{
id: 'phase-2-phase-3',
source: 'phase-2',
target: 'phase-3',
},
{
id: 'norm-2-phase-3',
source: 'norm-2',
target: 'phase-3',
},
{
id: 'norm-2-phase-2',
source: 'norm-2',
target: 'phase-2',
},
{
id: 'phase-3-end',
source: 'phase-3',
target: 'end',
}
]
};
const onlyStartEnd : FlowState = {
name: "onlyStartEnd",
nodes: [
{
id: 'start',
type: 'start',
position: {x: 0, y: 0},
data: {label: 'start'}
},
{
id: 'end',
type: 'end',
position: {x: 0, y: 300},
data: {label: 'End'}
}
],
edges:[
{
id: 'start-end',
source: 'start',
target: 'end',
},
]
};
// states that contain invalid programs for testing if correct errors are thrown:
const phaseConnectsToInvalidNodeType : FlowState = {
name: "phaseConnectsToInvalidNodeType",
nodes: [
{
id: 'start',
type: 'start',
position: {x: 0, y: 0},
data: {label: 'start'}
},
{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
{
id: 'default-1',
type: 'default',
position: {x: 0, y: 150},
data: {label: 'Generic Norm'},
},
{
id: 'end',
type: 'end',
position: {x: 0, y: 300},
data: {label: 'End'}
}
],
edges:[
{
id: 'start-phase-1',
source: 'start',
target: 'phase-1',
},
{
id: 'phase-1-default-1',
source: 'phase-1',
target: 'default-1',
},
]
};
const phaseHasNoOutgoingConnections : FlowState = {
name: "phaseHasNoOutgoingConnections",
nodes: [
{
id: 'start',
type: 'start',
position: {x: 0, y: 0},
data: {label: 'start'}
},
{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
{
id: 'phase-2',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 2},
},
{
id: 'end',
type: 'end',
position: {x: 0, y: 300},
data: {label: 'End'}
}
],
edges:[
{
id: 'start-phase-1',
source: 'start',
target: 'phase-1',
},
]
};
const phaseHasTooManyOutgoingConnections : FlowState = {
name: "phaseHasTooManyOutgoingConnections",
nodes: [
{
id: 'start',
type: 'start',
position: {x: 0, y: 0},
data: {label: 'start'}
},
{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
{
id: 'phase-2',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 2},
},
{
id: 'end',
type: 'end',
position: {x: 0, y: 300},
data: {label: 'End'}
}
],
edges:[
{
id: 'start-phase-1',
source: 'start',
target: 'phase-1',
},
{
id: 'phase-1-phase-2',
source: 'phase-1',
target: 'phase-2',
},
{
id: 'phase-1-end',
source: 'phase-1',
target: 'end',
},
{
id: 'phase-2-end',
source: 'phase-2',
target: 'end',
},
]
};
describe('Graph Reducer Tests', () => {
describe('defaultGraphPreprocessor', () => {
test.each([
{
state: onlyOnePhase,
expected: [
{
phaseNode: {
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
nextPhaseId: 'end',
connectedNorms: [],
connectedGoals: [],
}]
},
{
state: onlyThreePhases,
expected: [
{
phaseNode: {
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
nextPhaseId: 'phase-2',
connectedNorms: [],
connectedGoals: [],
},
{
phaseNode: {
id: 'phase-2',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 2},
},
nextPhaseId: 'phase-3',
connectedNorms: [],
connectedGoals: [],
},
{
phaseNode: {
id: 'phase-3',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 3},
},
nextPhaseId: 'end',
connectedNorms: [],
connectedGoals: [],
}]
},
{
state: onlySingleEdgeNorms,
expected: [
{
phaseNode: {
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
nextPhaseId: 'phase-2',
connectedNorms: [],
connectedGoals: [],
},
{
phaseNode: {
id: 'phase-2',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 2},
},
nextPhaseId: 'phase-3',
connectedNorms: [{
id: 'norm-1',
type: 'norm',
position: {x: 0, y: 150},
data: {label: 'Generic Norm', value: "generic"},
}],
connectedGoals: [],
},
{
phaseNode: {
id: 'phase-3',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 3},
},
nextPhaseId: 'end',
connectedNorms: [{
id: 'norm-2',
type: 'norm',
position: {x: 0, y: 150},
data: {label: 'Generic Norm', value: "generic"},
}],
connectedGoals: [],
}]
},
{
state: multiEdgeNorms,
expected: [
{
phaseNode: {
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
nextPhaseId: 'phase-2',
connectedNorms: [{
id: 'norm-3',
type: 'norm',
position: {x: 0, y: 150},
data: {label: 'Generic Norm', value: "generic"},
}],
connectedGoals: [],
},
{
phaseNode: {
id: 'phase-2',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 2},
},
nextPhaseId: 'phase-3',
connectedNorms: [{
id: 'norm-1',
type: 'norm',
position: {x: 0, y: 150},
data: {label: 'Generic Norm', value: "generic"},
},
{
id: 'norm-2',
type: 'norm',
position: {x: 0, y: 150},
data: {label: 'Generic Norm', value: "generic"},
}],
connectedGoals: [],
},
{
phaseNode: {
id: 'phase-3',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 3},
},
nextPhaseId: 'end',
connectedNorms: [{
id: 'norm-1',
type: 'norm',
position: {x: 0, y: 150},
data: {label: 'Generic Norm', value: "generic"},
},
{
id: 'norm-2',
type: 'norm',
position: {x: 0, y: 150},
data: {label: 'Generic Norm', value: "generic"},
}],
connectedGoals: [],
}]
},
{
state: onlyStartEnd,
expected: [],
}
])(`tests state: $state.name`, ({state, expected}) => {
const output = defaultGraphPreprocessor(state.nodes, state.edges);
expect(output).toEqual(expected);
});
describe('not yet implemented', () => {
test('nothing yet', () => {
expect(true);
});
describe("orderPhases", () => {
test.each([
{
state: onlyOnePhase,
expected: {
phaseNodes: [{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
}],
connections: new Map<string,string>([["phase-1","end"]])
}
},
{
state: onlyThreePhases,
expected: {
phaseNodes: [
{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
{
id: 'phase-2',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 2},
},
{
id: 'phase-3',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 3},
}],
connections: new Map<string,string>([
["phase-1","phase-2"],
["phase-2","phase-3"],
["phase-3","end"]
])
}
},
{
state: onlySingleEdgeNorms,
expected: {
phaseNodes: [
{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
{
id: 'phase-2',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 2},
},
{
id: 'phase-3',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 3},
}],
connections: new Map<string,string>([
["phase-1","phase-2"],
["phase-2","phase-3"],
["phase-3","end"]
])
}
},
{
state: onlyStartEnd,
expected: {
phaseNodes: [],
connections: new Map<string,string>()
}
}
])(`tests state: $state.name`, ({state, expected}) => {
const output = orderPhases(state.nodes, state.edges);
expect(output.phaseNodes).toEqual(expected.phaseNodes);
expect(output.connections).toEqual(expected.connections);
});
test.each([
{
state: phaseConnectsToInvalidNodeType,
expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node')
},
{
state: phaseHasNoOutgoingConnections,
expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections')
},
{
state: phaseHasTooManyOutgoingConnections,
expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets')
}
])(`tests erroneous state: $state.name`, ({state, expected}) => {
const testForError = () => {
orderPhases(state.nodes, state.edges);
};
expect(testForError).toThrow(expected);
})
})
describe("defaultPhaseReducer", () => {
test("phaseReducer handles empty norms and goals without failing", () => {
const input : PreparedPhase = {
phaseNode: {
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
nextPhaseId: 'end',
connectedNorms: [],
connectedGoals: [],
}
const output = defaultPhaseReducer(input);
expect(output).toEqual({
id: 'phase-1',
name: 'Generic Phase',
nextPhaseId: 'end',
phaseData: {
norms: [],
goals: []
}
});
});
test("defaultNormReducer reduces norms correctly", () => {
const input : PreparedPhase = {
phaseNode: {
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
nextPhaseId: 'end',
connectedNorms: [{
id: 'norm-1',
type: 'norm',
position: {x: 0, y: 150},
data: {label: 'Generic Norm', value: "generic"},
}],
connectedGoals: [],
}
const output = defaultPhaseReducer(input);
expect(output).toEqual({
id: 'phase-1',
name: 'Generic Phase',
nextPhaseId: 'end',
phaseData: {
norms: [{
id: 'norm-1',
name: 'Generic Norm',
value: "generic"
}],
goals: []
}
});
});
test("defaultGoalReducer reduces goals correctly", () => {
const input : PreparedPhase = {
phaseNode: {
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
nextPhaseId: 'end',
connectedNorms: [],
connectedGoals: [{
id: 'goal-1',
type: 'goal',
position: {x: 0, y: 150},
data: {label: 'Generic Goal', value: "generic"},
}],
}
const output = defaultPhaseReducer(input);
expect(output).toEqual({
id: 'phase-1',
name: 'Generic Phase',
nextPhaseId: 'end',
phaseData: {
norms: [],
goals: [{
id: 'goal-1',
name: 'Generic Goal',
value: "generic"
}]
}
});
});
})
describe("GraphReducer", () => {
test.each([
{
state: onlyOnePhase,
expected: [
{
id: 'phase-1',
name: 'Generic Phase',
nextPhaseId: 'end',
phaseData: {
norms: [],
goals: []
}
}]
},
{
state: onlyThreePhases,
expected: [
{
id: 'phase-1',
name: 'Generic Phase',
nextPhaseId: 'phase-2',
phaseData: {
norms: [],
goals: []
}
},
{
id: 'phase-2',
name: 'Generic Phase',
nextPhaseId: 'phase-3',
phaseData: {
norms: [],
goals: []
}
},
{
id: 'phase-3',
name: 'Generic Phase',
nextPhaseId: 'end',
phaseData: {
norms: [],
goals: []
}
}]
},
{
state: onlySingleEdgeNorms,
expected: [
{
id: 'phase-1',
name: 'Generic Phase',
nextPhaseId: 'phase-2',
phaseData: {
norms: [],
goals: []
}
},
{
id: 'phase-2',
name: 'Generic Phase',
nextPhaseId: 'phase-3',
phaseData: {
norms: [
{
id: 'norm-1',
name: 'Generic Norm',
value: "generic"
}
],
goals: []
}
},
{
id: 'phase-3',
name: 'Generic Phase',
nextPhaseId: 'end',
phaseData: {
norms: [{
id: 'norm-2',
name: 'Generic Norm',
value: "generic"
}],
goals: []
}
}]
},
{
state: multiEdgeNorms,
expected: [
{
id: 'phase-1',
name: 'Generic Phase',
nextPhaseId: 'phase-2',
phaseData: {
norms: [{
id: 'norm-3',
name: 'Generic Norm',
value: "generic"
}],
goals: []
}
},
{
id: 'phase-2',
name: 'Generic Phase',
nextPhaseId: 'phase-3',
phaseData: {
norms: [
{
id: 'norm-1',
name: 'Generic Norm',
value: "generic"
},
{
id: 'norm-2',
name: 'Generic Norm',
value: "generic"
}
],
goals: []
}
},
{
id: 'phase-3',
name: 'Generic Phase',
nextPhaseId: 'end',
phaseData: {
norms: [{
id: 'norm-1',
name: 'Generic Norm',
value: "generic"
},
{
id: 'norm-2',
name: 'Generic Norm',
value: "generic"
}],
goals: []
}
}]
},
{
state: onlyStartEnd,
expected: [],
}
])("`tests state: $state.name`", ({state, expected}) => {
useFlowStore.setState({nodes: state.nodes, edges: state.edges});
const output = graphReducer(); // uses default reducers
expect(output).toEqual(expected);
})
// we run the test for correct error handling for the entire graph reducer as well,
// to make sure no errors occur before we intend to handle the errors ourselves
test.each([
{
state: phaseConnectsToInvalidNodeType,
expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node')
},
{
state: phaseHasNoOutgoingConnections,
expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections')
},
{
state: phaseHasTooManyOutgoingConnections,
expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets')
}
])(`tests erroneous state: $state.name`, ({state, expected}) => {
useFlowStore.setState({nodes: state.nodes, edges: state.edges});
const testForError = () => {
graphReducer();
};
expect(testForError).toThrow(expected);
})
})
});
});

View File

@@ -221,4 +221,132 @@ describe('FlowStore Functionality', () => {
});
});
});
describe('ReactFlow updateNodeData', () => {
test.each([
{
state: {
name: 'updateName',
nodes: [{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 300},
data: {label: 'name', number: '2'}
}]
},
input: {
id: 'phase-1',
changedData: {label: 'new name'}
},
expected: {
node: {
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 300},
data: {label: 'new name', number: '2'}
}
}
},
{
state: {
name: 'updateNumber',
nodes: [{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 300},
data: {label: 'name', number: '2'}
}]
},
input: {
id: 'phase-1',
changedData: {number: '3'}
},
expected: {
node: {
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 300},
data: {label: 'name', number: '3'}
}
}
},
{
state: {
name: 'updateNameAndNumber',
nodes: [{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 300},
data: {label: 'name', number: '2'}
}]
},
input: {
id: 'phase-1',
changedData: {label: 'new name', number: '3'}
},
expected: {
node: {
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 300},
data: {label: 'new name', number: '3'}
}
}
},
{
state: {
name: 'AddNewEntry',
nodes: [{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 300},
data: {label: 'name', number: '2'}
}]
},
input: {
id: 'phase-1',
changedData: {newEntry: 20}
},
expected: {
node: {
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 300},
data: {label: 'name', number: '2', newEntry: 20}
}
}
},
{
state: {
name: 'AddNewEntryAndUpdateOneValue_UnorderedInput',
nodes: [{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 300},
data: {label: 'name', number: '2'}
}]
},
input: {
id: 'phase-1',
changedData: {newEntry: 20, number: '3'}
},
expected: {
node: {
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 300},
data: {label: 'name', number: '3', newEntry: 20}
}
}
}
])(`tests state: $state.name`, ({state, input,expected}) => {
useFlowStore.setState({ nodes: state.nodes })
const {updateNodeData} = useFlowStore.getState();
act(() => {
updateNodeData(input.id, input.changedData);
})
const updatedState = useFlowStore.getState();
expect(updatedState.nodes).toHaveLength(1);
expect(updatedState.nodes[0]).toMatchObject(expected.node);
})
})
});

View File

@@ -1,33 +1,5 @@
import { mockReactFlow } from '../../../../setupFlowTests.ts';
import {act} from "@testing-library/react";
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
import {addNode} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx";
beforeAll(() => {
mockReactFlow();
describe('Not implemented', () => {
test('nothing yet', () => {
expect(true)
});
});
describe('Drag-and-Drop sidebar', () => {
test.each(['phase', 'phase'])('new nodes get added correctly', (nodeType: string) => {
act(()=> {
addNode(nodeType, {x:100, y:100});
})
const updatedState = useFlowStore.getState();
expect(updatedState.nodes.length).toBe(1);
expect(updatedState.nodes[0].type).toBe(nodeType);
});
test.each(['phase', 'norm'])('new nodes get correct Id', (nodeType) => {
act(()=> {
addNode(nodeType, {x:100, y:100});
addNode(nodeType, {x:100, y:100});
})
const updatedState = useFlowStore.getState();
expect(updatedState.nodes.length).toBe(2);
expect(updatedState.nodes[0].id).toBe(`${nodeType}-1`);
expect(updatedState.nodes[1].id).toBe(`${nodeType}-2`);
});
test('throws error on unexpected node type', () => {
expect(() => addNode('I do not Exist', {x:100, y:100})).toThrow("Node I do not Exist not found");
})
});

View File

@@ -0,0 +1,154 @@
// SaveLoadPanel.all.test.tsx
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
import SaveLoadPanel from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx';
import { makeProjectBlob } from '../../../../../src/utils/SaveLoad.ts';
import { mockReactFlow } from "../../../../setupFlowTests.ts"; // optional helper if present
// helper to read Blob contents in tests (works in Node/Jest env)
async function blobToText(blob: Blob): Promise<string> {
if (typeof (blob as any).text === "function") return await (blob as any).text();
if (typeof (blob as any).arrayBuffer === "function") {
const buf = await (blob as any).arrayBuffer();
return new TextDecoder().decode(buf);
}
return await new Promise<string>((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result));
fr.onerror = () => reject(fr.error);
fr.readAsText(blob);
});
}
beforeAll(() => {
// if you have a mockReactFlow helper used in other tests, call it
if (typeof mockReactFlow === "function") mockReactFlow();
});
beforeEach(() => {
// clear and seed the zustand store to a known empty state
act(() => {
const { setNodes, setEdges } = useFlowStore.getState();
setNodes([]);
setEdges([]);
});
// Ensure URL.createObjectURL exists so jest.spyOn works
if (!URL.createObjectURL) URL.createObjectURL = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("SaveLoadPanel - combined tests", () => {
test("makeProjectBlob creates a valid JSON blob", async () => {
const nodes = [
{
id: "n1",
type: "start",
position: { x: 0, y: 0 },
data: { label: "Start" },
} as any,
];
const edges: any[] = [];
const blob = makeProjectBlob("my-project", nodes, edges);
expect(blob).toBeInstanceOf(Blob);
const text = await blobToText(blob);
const parsed = JSON.parse(text);
expect(parsed.name).toBe("my-project");
expect(typeof parsed.savedAt).toBe("string");
expect(Array.isArray(parsed.nodes)).toBe(true);
expect(Array.isArray(parsed.edges)).toBe(true);
expect(parsed.nodes).toEqual(nodes);
expect(parsed.edges).toEqual(edges);
});
test("onSave creates a blob URL and sets anchor href", async () => {
// Seed the store so onSave has nodes to save
act(() => {
useFlowStore.getState().setNodes([
{ id: "start", type: "start", position: { x: 0, y: 0 }, data: { label: "start" } } as any,
]);
useFlowStore.getState().setEdges([]);
});
// Ensure createObjectURL exists and spy it
if (!URL.createObjectURL) URL.createObjectURL = jest.fn();
const createObjectURLSpy = jest.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-url");
render(<SaveLoadPanel />);
const saveAnchor = screen.getByText(/Save Graph/i) as HTMLAnchorElement;
await act(async () => {
fireEvent.click(saveAnchor);
});
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
const blobArg = createObjectURLSpy.mock.calls[0][0];
expect(blobArg).toBeInstanceOf(Blob);
expect(saveAnchor.getAttribute("href")).toBe("blob:fake-url");
const text = await blobToText(blobArg as Blob);
const parsed = JSON.parse(text);
expect(parsed.name).toBeDefined();
expect(parsed.nodes).toBeDefined();
expect(parsed.edges).toBeDefined();
createObjectURLSpy.mockRestore();
});
test("onLoad with invalid JSON does not update store", async () => {
const file = new File(["not json"], "bad.json", { type: "application/json" });
file.text = jest.fn(() => Promise.resolve(`{"bad json`));
window.alert = jest.fn();
render(<SaveLoadPanel />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input).toBeTruthy();
// Give some input
act(() => {
fireEvent.change(input, { target: { files: [file] } });
});
await waitFor(() => {
expect(window.alert).toHaveBeenCalledTimes(1);
const nodesAfter = useFlowStore.getState().nodes;
expect(nodesAfter).toHaveLength(0);
expect(input.value).toBe("");
});
});
test("onLoad resolves to null when no file is chosen (user cancels) and does not update store", async () => {
render(<SaveLoadPanel />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input).toBeTruthy();
// Click Load to set resolver
const loadButton = screen.getByLabelText(/load graph/i);
await act(async () => {
fireEvent.click(loadButton);
// simulate user cancelling: change with empty files
fireEvent.change(input, { target: { files: [] } });
await Promise.resolve();
});
await waitFor(() => {
const nodesAfter = useFlowStore.getState().nodes;
const edgesAfter = useFlowStore.getState().edges;
expect(nodesAfter).toHaveLength(0);
expect(edgesAfter).toHaveLength(0);
expect(input.value).toBe("");
});
});
});

View File

@@ -0,0 +1,156 @@
import {render, screen, act} from "@testing-library/react";
import "@testing-library/jest-dom";
import {type Cell, cell, useCell} from "../../src/utils/cellStore.ts";
describe("cell store (unit)", () => {
it("returns initial value with get()", () => {
const c = cell(123);
expect(c.get()).toBe(123);
});
it("updates value with set(next)", () => {
const c = cell("a");
c.set("b");
expect(c.get()).toBe("b");
});
it("gives previous value in set(updater)", () => {
const c = cell(1);
c.set((prev) => prev + 2);
expect(c.get()).toBe(3);
});
it("calls subscribe callback on set", () => {
const c = cell(0);
const cb = jest.fn();
const unsub = c.subscribe(cb);
c.set(1);
c.set(2);
expect(cb).toHaveBeenCalledTimes(2);
unsub();
});
it("stops notifications when unsubscribing", () => {
const c = cell(0);
const cb = jest.fn();
const unsub = c.subscribe(cb);
c.set(1);
unsub();
c.set(2);
expect(cb).toHaveBeenCalledTimes(1);
});
it("updates multiple listeners", () => {
const c = cell("x");
const a = jest.fn();
const b = jest.fn();
const ua = c.subscribe(a);
const ub = c.subscribe(b);
c.set("y");
expect(a).toHaveBeenCalledTimes(1);
expect(b).toHaveBeenCalledTimes(1);
ua();
ub();
});
});
describe("cell store (integration)", () => {
function View({c, label}: { c: Cell<any>; label: string }) {
const v = useCell(c);
// count renders to verify re-render behavior
(View as any).__renders = ((View as any).__renders ?? 0) + 1;
return <div data-testid={label}>{String(v)}</div>;
}
it("reads initial value and updates on set", () => {
const c = cell("hello");
render(<View c={c} label="value"/>);
expect(screen.getByTestId("value")).toHaveTextContent("hello");
act(() => {
c.set("world");
});
expect(screen.getByTestId("value")).toHaveTextContent("world");
});
it("triggers one re-render with set", () => {
const c = cell(1);
(View as any).__renders = 0;
render(<View c={c} label="num"/>);
const rendersAfterMount = (View as any).__renders;
act(() => {
c.set((prev: number) => prev + 1);
});
// exactly one extra render from the update
expect((View as any).__renders).toBe(rendersAfterMount + 1);
expect(screen.getByTestId("num")).toHaveTextContent("2");
});
it("unsubscribes on unmount (no errors on later sets)", () => {
const c = cell("a");
const {unmount} = render(<View c={c} label="value"/>);
unmount();
// should not throw even though there was a subscriber
expect(() =>
act(() => {
c.set("b");
})
).not.toThrow();
});
it("only re-renders components that use the cell", () => {
const a = cell("A");
const b = cell("B");
let rendersA = 0;
let rendersB = 0;
function A() {
const v = useCell(a);
rendersA++;
return <div data-testid="A">{v}</div>;
}
function B() {
const v = useCell(b);
rendersB++;
return <div data-testid="B">{v}</div>;
}
render(
<>
<A/>
<B/>
</>
);
const rendersAAfterMount = rendersA;
const rendersBAfterMount = rendersB;
act(() => {
a.set("A2"); // only A should update
});
expect(screen.getByTestId("A")).toHaveTextContent("A2");
expect(screen.getByTestId("B")).toHaveTextContent("B");
expect(rendersA).toBe(rendersAAfterMount + 1);
expect(rendersB).toBe(rendersBAfterMount); // unchanged
});
});

View File

@@ -0,0 +1,22 @@
import duplicateIndices from "../../src/utils/duplicateIndices.ts";
describe("duplicateIndices (unit)", () => {
it("returns an empty array for empty input", () => {
expect(duplicateIndices<number>([])).toEqual([]);
});
it("returns an empty array when no duplicates exist", () => {
expect(duplicateIndices([1, 2, 3, 4])).toEqual([]);
});
it("returns all positions for every duplicated value", () => {
const result = duplicateIndices(["a", "b", "a", "c", "b", "b"]);
expect(result.sort()).toEqual([0, 1, 2, 4, 5]);
});
it("only treats identical references as duplicate objects", () => {
const shared = { v: 1 };
const result = duplicateIndices([shared, { v: 1 }, shared, shared]);
expect(result.sort()).toEqual([0, 2, 3]);
});
});

View File

@@ -0,0 +1,53 @@
import formatDuration from "../../src/utils/formatDuration.ts";
describe("formatting durations (unit)", () => {
it("does one millisecond", () => {
const result = formatDuration(1);
expect(result).toBe("00:00:00.001");
});
it("does one-hundred twenty-three milliseconds", () => {
const result = formatDuration(123);
expect(result).toBe("00:00:00.123");
});
it("does one second", () => {
const result = formatDuration(1*1000);
expect(result).toBe("00:00:01.000");
});
it("does thirteen seconds", () => {
const result = formatDuration(13*1000);
expect(result).toBe("00:00:13.000");
});
it("does one minute", () => {
const result = formatDuration(60*1000);
expect(result).toBe("00:01:00.000");
});
it("does thirteen minutes", () => {
const result = formatDuration(13*60*1000);
expect(result).toBe("00:13:00.000");
});
it("does one hour", () => {
const result = formatDuration(60*60*1000);
expect(result).toBe("01:00:00.000");
});
it("does thirteen hours", () => {
const result = formatDuration(13*60*60*1000);
expect(result).toBe("13:00:00.000");
});
it("does negative one millisecond", () => {
const result = formatDuration(-1);
expect(result).toBe("-00:00:00.001");
});
it("does large negative durations", () => {
const result = formatDuration(-(123*60*60*1000 + 59*60*1000 + 59*1000 + 123));
expect(result).toBe("-123:59:59.123");
});
});

View File

@@ -0,0 +1,81 @@
import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../src/utils/priorityFiltering";
const makePred = <T>(priority: number, fn: (el: T) => boolean | null): PriorityFilterPredicate<T> => ({
priority,
predicate: jest.fn(fn),
});
describe("applyPriorityPredicates (unit)", () => {
beforeEach(() => jest.clearAllMocks());
it("returns true when there are no predicates", () => {
expect(applyPriorityPredicates(123, [])).toBe(true);
});
it("behaves like a normal predicate with only one predicate", () => {
const even = makePred<number>(1, (n) => n % 2 === 0);
expect(applyPriorityPredicates(2, [even])).toBe(true);
expect(applyPriorityPredicates(3, [even])).toBe(false);
});
it("determines the result only listening to the highest priority predicates", () => {
const lowFail = makePred<number>(1, (_) => false);
const lowPass = makePred<number>(1, (_) => true);
const highPass = makePred<number>(10, (n) => n > 0);
const highFail = makePred<number>(10, (n) => n < 0);
expect(applyPriorityPredicates(5, [lowFail, highPass])).toBe(true);
expect(applyPriorityPredicates(5, [lowPass, highFail])).toBe(false);
});
it("uses all predicates at the highest priority", () => {
const high1 = makePred<number>(5, (n) => n % 2 === 0);
const high2 = makePred<number>(5, (n) => n > 2);
expect(applyPriorityPredicates(4, [high1, high2])).toBe(true);
expect(applyPriorityPredicates(2, [high1, high2])).toBe(false);
});
it("is order independent (later higher positive clears earlier lower negative)", () => {
const lowFalse = makePred<number>(1, (_) => false);
const highTrue = makePred<number>(9, (n) => n === 7);
// Higher priority appears later → should reset and decide by highest only
expect(applyPriorityPredicates(7, [lowFalse, highTrue])).toBe(true);
// Same set, different order → same result
expect(applyPriorityPredicates(7, [highTrue, lowFalse])).toBe(true);
});
it("handles many priorities: only max matters", () => {
const p1 = makePred<number>(1, (_) => false);
const p3 = makePred<number>(3, (_) => false);
const p5 = makePred<number>(5, (n) => n > 0);
expect(applyPriorityPredicates(1, [p1, p3, p5])).toBe(true);
});
it("skips predicates that return null", () => {
const high = makePred<number>(10, (n) => n === 0 ? true : null);
const low = makePred<number>(1, (_) => false);
expect(applyPriorityPredicates(0, [high, low])).toBe(true);
expect(applyPriorityPredicates(1, [high, low])).toBe(false);
});
});
describe("(integration) filter with applyPriorityPredicates", () => {
it("filters an array using only highest-priority predicates", () => {
const elems = [1, 2, 3, 4, 5];
const low = makePred<number>(0, (_) => false);
const high1 = makePred<number>(5, (n) => n % 2 === 0);
const high2 = makePred<number>(5, (n) => n > 2);
const result = elems.filter((e) => applyPriorityPredicates(e, [low, high1, high2]));
expect(result).toEqual([4]);
});
it("filters an array using only highest-priority predicates", () => {
const elems = [1, 2, 3, 4, 5];
const low = makePred<number>(0, (_) => false);
const high = makePred<number>(5, (n) => n === 3 ? true : null);
const result = elems.filter((e) => applyPriorityPredicates(e, [low, high]));
expect(result).toEqual([3]);
});
});