Compare commits

1 Commits

Author SHA1 Message Date
c05c74e412 build: add Docker deployment
Created a Dockerfile and nginx configuration and added environment
variables into the codebase to support Docker compose.

ref: N25B-282
2025-11-14 14:06:12 +01:00
128 changed files with 1981 additions and 15807 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
dist
Dockerfile
.dockerignore
.git/
.githooks/
__mocks__/
test/
eslint.config.js
jest.config.js
README.md

View File

@@ -1,77 +0,0 @@
#!/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

View File

@@ -1,138 +0,0 @@
#!/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

16
.githooks/commit-msg Executable file
View File

@@ -0,0 +1,16 @@
#!/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

17
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,17 @@
#!/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

9
.githooks/prepare-commit-msg Executable file
View File

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

5
.gitignore vendored
View File

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

View File

@@ -1,53 +0,0 @@
# ---------- 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

View File

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

View File

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

46
Dockerfile Normal file
View File

@@ -0,0 +1,46 @@
# --- Building static files ---
FROM node:23-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# --- Serving ---
FROM nginx:alpine
RUN mkdir -p /app/www
COPY --from=build /app/dist /app/www
COPY nginx.conf /etc/nginx/templates/default.conf.template
RUN adduser -D -H -u 1001 -s /sbin/nologin webuser
RUN chown -R webuser:webuser /app/www && \
chmod -R 755 /app/www && \
chown -R webuser:webuser /var/cache/nginx && \
chown -R webuser:webuser /var/log/nginx && \
chown -R webuser:webuser /etc/nginx/conf.d && \
touch /var/run/nginx.pid && \
chown -R webuser:webuser /var/run/nginx.pid && \
chmod -R 777 /etc/nginx/conf.d
ENV PORT=80
ENV NGINX_ENVSUBST_TEMPLATE_DIR=/etc/nginx/templates
ENV NGINX_ENVSUBST_TEMPLATE_SUFFIX=.template
ENV NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx/conf.d
# Default value, potentially overwritten in compose file
ENV BACKEND_ADDRESS="http://localhost:8000"
EXPOSE ${PORT}
USER webuser
CMD [ "nginx", "-g", "daemon off;" ]

View File

@@ -28,26 +28,16 @@ npm run dev
It should automatically reload when you save changes.
## Git Hooks
## GitHooks
To activate automatic linting, branch name checks and commit message checks, run:
```bash
npm run prepare
```
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:
To activate automatic commits/branch name checks run:
```shell
npx typedoc --entryPointStrategy Expand src
git config --local core.hooksPath .githooks
```
If your commit fails its either:
branch name != <type>/description-of-branch ,
commit name != <type>: description of the commit.
<ref>: N25B-Num's

View File

@@ -1,38 +1,23 @@
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",
},
},
])

26
nginx.conf Normal file
View File

@@ -0,0 +1,26 @@
server {
listen ${PORT};
server_name localhost;
root /app/www;
# Security headers
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
# Compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location / {
try_files $uri $uri/ /index.html;
expires -1;
}
# Cache static assets
location /assets {
expires 1y;
add_header Cache-Control "public, no-transform";
}
}

278
package-lock.json generated
View File

@@ -24,17 +24,14 @@
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"baseline-browser-mapping": "^2.9.11",
"eslint": "^9.36.0",
"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"
@@ -1351,20 +1348,6 @@
"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",
@@ -1477,9 +1460,9 @@
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"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==",
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2368,55 +2351,6 @@
"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",
@@ -2703,16 +2637,6 @@
"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",
@@ -2814,13 +2738,6 @@
"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",
@@ -3407,12 +3324,12 @@
}
},
"node_modules/@xyflow/react": {
"version": "12.9.1",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.1.tgz",
"integrity": "sha512-JRPCT5p7NnPdVSIh15AFvUSSm+8GUyz2I6iuBEC1LG2lKgig/L48AM/ImMHCc3ZUCg+AgTOJDaX2fcRyPA9BTA==",
"version": "12.8.6",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz",
"integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.72",
"@xyflow/system": "0.0.70",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
@@ -3450,9 +3367,9 @@
}
},
"node_modules/@xyflow/system": {
"version": "0.0.72",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz",
"integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==",
"version": "0.0.70",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz",
"integrity": "sha512-PpC//u9zxdjj0tfTSmZrg3+sRbTz6kop/Amky44U2Dl51sxzDTIUfXMwETOYpmr2dqICWXBIJwXL2a9QWtX2XA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
@@ -3699,9 +3616,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz",
"integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -4870,9 +4787,9 @@
}
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -5053,22 +4970,6 @@
"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",
@@ -6007,9 +5908,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6154,16 +6055,6 @@
"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",
@@ -6204,13 +6095,6 @@
"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",
@@ -6268,44 +6152,6 @@
"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",
@@ -6858,16 +6704,6 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
@@ -7766,56 +7602,6 @@
"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",
@@ -7854,13 +7640,6 @@
"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",
@@ -7959,9 +7738,9 @@
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -8363,19 +8142,6 @@
"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,10 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint src test",
"preview": "vite preview",
"test": "jest",
"prepare": "husky"
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@neodrag/react": "^2.3.1",
@@ -28,17 +26,14 @@
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"baseline-browser-mapping": "^2.9.11",
"eslint": "^9.36.0",
"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,10 +82,6 @@ button.movePage:hover{
}
#root {
display: flex;
flex-direction: column;
}
header {
position: sticky;
@@ -100,7 +96,6 @@ 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?? */
}
@@ -109,10 +104,6 @@ main {
padding: 1rem 0;
}
input[type="checkbox"] {
cursor: pointer;
}
.flex-row {
display: flex;
flex-direction: row;
@@ -130,14 +121,6 @@ input[type="checkbox"] {
flex-wrap: wrap;
}
.min-height-0 {
min-height: 0;
}
.scroll-y {
overflow-y: scroll;
}
.align-center {
align-items: center;
}
@@ -158,10 +141,6 @@ input[type="checkbox"] {
gap: 1rem;
}
.margin-0 {
margin: 0;
}
.padding-sm {
padding: .25rem;
}
@@ -171,19 +150,7 @@ input[type="checkbox"] {
.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;
}
@@ -192,67 +159,4 @@ input[type="checkbox"] {
}
.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;
}
}
.flex-center-x {
display: flex;
justify-content: center; /* horizontal centering */
text-align: center; /* center multi-line text */
width: 100%; /* allow it to stretch */
flex-wrap: wrap; /* optional: let text wrap naturally */
}

View File

@@ -3,35 +3,24 @@ 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>
<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 />} />
<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 />} />
</Routes>
</main>
{showLogs && <Logging />}
</div>
</>
);
</main>
</div>
)
}
export default App

View File

@@ -1,34 +0,0 @@
.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

@@ -1,263 +0,0 @@
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],
["LLM", 9],
["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

@@ -1,39 +0,0 @@
.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

@@ -1,186 +0,0 @@
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

@@ -1,212 +0,0 @@
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: 'LLM' | '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

@@ -1,75 +0,0 @@
import { useEffect, useRef, useState } from "react";
import styles from "./TextField.module.css";
export function MultilineTextField({
value = "",
setValue,
placeholder,
className,
id,
ariaLabel,
invalid = false,
minRows = 3,
}: {
value: string;
setValue: (value: string) => void;
placeholder?: string;
className?: string;
id?: string;
ariaLabel?: string;
invalid?: boolean;
minRows?: number;
}) {
const [readOnly, setReadOnly] = useState(true);
const [inputValue, setInputValue] = useState(value);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setInputValue(value);
}, [value]);
// Auto-grow logic
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [inputValue]);
const onCommit = () => {
setReadOnly(true);
setValue(inputValue);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
(e.target as HTMLTextAreaElement).blur();
}
};
return (
<textarea
ref={textareaRef}
rows={minRows}
placeholder={placeholder}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onFocus={() => setReadOnly(false)}
onBlur={onCommit}
onKeyDown={onKeyDown}
readOnly={readOnly}
id={id}
aria-label={ariaLabel}
className={`
${readOnly ? "drag" : "nodrag"}
flex-1
${styles.textField}
${styles.multiline}
${invalid ? styles.invalid : ""}
${className ?? ""}
`}
/>
);
}

View File

@@ -1,22 +0,0 @@
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

@@ -1,39 +0,0 @@
.text-field {
border: 1px solid transparent;
border-radius: 5pt;
padding: 4px 8px;
max-width: 50vw;
min-width: 10vw;
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%);
}
.multiline {
resize: none; /* no manual resizing */
line-height: 1.4;
white-space: pre-wrap;
overflow: hidden; /* needed for auto-grow */
max-width: 100%;
width: 95%;
min-width: 95%;
}

View File

@@ -1,124 +0,0 @@
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"} flex-1 ${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);
useEffect(() => {
setInputValue(value);
}, [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,14 +1,6 @@
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,26 +7,13 @@
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
--accent-color: #008080;
--panel-shadow:
0 1px 2px white,
0 8px 24px rgba(190, 186, 186, 0.253);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
--panel-shadow:
0 1px 2px rgba(221, 221, 221, 0.178),
0 8px 24px rgba(27, 27, 27, 0.507);
}
}
html, body, #root {
html, body {
margin: 0;
padding: 0;
@@ -37,7 +24,12 @@ html, body, #root {
}
a {
color: canvastext;
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
@@ -57,7 +49,7 @@ button {
transition: border-color 0.25s;
}
button:hover {
border-color: var(--accent-color);
border-color: #646cff;
}
button:focus,
button:focus-visible {
@@ -68,23 +60,11 @@ button:focus-visible {
:root {
color: #213547;
background-color: #ffffff;
--accent-color: #00AAAA;
--select-color: rgba(gray);
--dropdown-menu-background-color: rgb(247, 247, 247);
--dropdown-menu-border: rgba(207, 207, 207, 0.986);
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
@media (prefers-color-scheme: dark) {
:root {
color: #ffffff;
--select-color: rgba(gray);
--dropdown-menu-background-color: rgba(39, 39, 39, 0.986);
--dropdown-menu-border: rgba(65, 65, 65, 0.986);
}
}

View File

@@ -1,60 +0,0 @@
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,14 +2,6 @@ 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}`}>
@@ -22,7 +14,6 @@ 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,276 +0,0 @@
.dashboardContainer {
display: grid;
grid-template-columns: 2fr 1fr; /* Left = content, Right = logs */
grid-template-rows: auto 1fr auto; /* Header, Main, Footer */
grid-template-areas:
"header logs"
"main logs"
"footer footer";
gap: 1rem;
padding: 1rem;
background-color: var(--bg-main);
color: var(--text-main);
font-family: Arial, sans-serif;
}
/* HEADER */
.experimentOverview {
grid-area: header;
display: flex;
color: color;
justify-content: space-between;
align-items: flex-start;
background: var(--bg-surface);
color: var(--text-main);
box-shadow: var(--shadow);
padding: 1rem;
box-shadow: var(--panel-shadow);
position: static; /* ensures it scrolls away */
}
.phaseProgress {
margin-top: 0.5rem;
}
.phase {
display: inline-block;
width: 25px;
height: 25px;
margin: 0 3px;
text-align: center;
line-height: 25px;
background: gray;
}
.completed {
background-color: green;
color: white;
}
.current {
background-color: rgb(255, 123, 0);
color: white;
}
.connected {
color: green;
font-weight: bold;
}
.disconnected {
color: red;
font-weight: bold;
}
.pausePlayInactive{
background-color: gray;
color: white;
}
.pausePlayActive{
background-color: green;
color: white;
}
.next {
background-color: #6c757d;
color: white;
}
.restartExperiment{
background-color: red;
color: white;
}
/* MAIN GRID */
.phaseOverview {
grid-area: main;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, auto);
gap: 1rem;
background: var(--bg-surface);
color: var(--text-main);
padding: 1rem;
box-shadow: var(--panel-shadow);
}
.phaseBox {
background: var(--bg-surface);
border: 1px solid var(--border-color);
box-shadow: var(--panel-shadow);
padding: 1rem;
display: flex;
flex-direction: column;
box-shadow: inset 0 0 3px rgba(0, 0, 0, 0.05);
height: 250px;
}
.phaseBox ul {
list-style: none;
padding: 0;
margin: 0;
overflow-y: auto;
flex-grow: 1;
}
.phaseBox ul::-webkit-scrollbar {
width: 6px;
}
.phaseBox ul::-webkit-scrollbar-thumb {
background-color: #ccc;
border-radius: 10px;
}
.phaseOverviewText {
grid-column: 1 / -1; /* make the title span across both columns */
font-size: 1.4rem;
font-weight: 600;
margin: 0; /* remove default section margin */
padding: 0.25rem 0; /* smaller internal space */
}
.phaseOverviewText h3{
margin: 0; /* removes top/bottom whitespace */
padding: 0; /* keeps spacing tight */
}
.phaseBox h3 {
margin-top: 0;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.4rem;
}
.checked::before {
content: '✔️ ';
}
.statusIndicator {
display: inline-block;
margin-right: 10px;
user-select: none;
transition: transform 0.1s ease;
font-size: 1.1rem;
}
.statusIndicator.clickable {
cursor: pointer;
}
.statusIndicator.clickable:hover {
transform: scale(1.2);
}
.clickable {
cursor: pointer;
}
.clickable:hover {
transform: scale(1.2);
}
.active {
opacity: 1;
}
.statusItem {
display: flex;
align-items: center;
margin-bottom: 0.4rem;
}
.itemDescription {
line-height: 1.4;
}
/* LOGS */
.logs {
grid-area: logs;
background: var(--bg-surface);
color: var(--text-main);
box-shadow: var(--panel-shadow);
padding: 1rem;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
.logs textarea {
width: 100%;
height: 83%;
margin-top: 0.5rem;
background-color: Canvas;
color: CanvasText;
border: 1px solid var(--border-color);
}
.logs button {
background: var(--bg-surface);
box-shadow: var(--panel-shadow);
margin-top: 0.5rem;
margin-left: 0.5rem;
}
/* FOOTER */
.controlsSection {
grid-area: footer;
display: flex;
justify-content: space-between;
gap: 1rem;
background: var(--bg-surface);
color: var(--text-main);
box-shadow: var(--panel-shadow);
padding: 1rem;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
.controlsSection button {
background: var(--bg-surface);
box-shadow: var(--panel-shadow);
margin-top: 0.5rem;
margin-left: 0.5rem;
}
.gestures,
.speech,
.directSpeech {
flex: 1;
}
.speechInput {
display: flex;
margin-top: 0.5rem;
}
.speechInput input {
flex: 1;
padding: 0.5rem;
background-color: Canvas;
color: CanvasText;
border: 1px solid var(--border-color);
}
.speechInput button {
color: white;
border: none;
padding: 0.5rem 1rem;
cursor: pointer;
background-color: Canvas;
color: CanvasText;
border: 1px solid var(--border-color);
}
/* RESPONSIVE */
@media (max-width: 900px) {
.phaseOverview {
grid-template-columns: 1fr;
}
.controlsSection {
flex-direction: column;
}
}

View File

@@ -1,406 +0,0 @@
import React, { useCallback, useState } from 'react';
import styles from './MonitoringPage.module.css';
// Store & API
import useProgramStore from "../../utils/programStore";
import {
nextPhase,
useExperimentLogger,
useStatusLogger,
pauseExperiment,
playExperiment,
type ExperimentStreamData,
type GoalUpdate,
type TriggerUpdate,
type CondNormsStateUpdate,
type PhaseUpdate
} from "./MonitoringPageAPI";
import { graphReducer, runProgramm } from '../VisProgPage/VisProgLogic.ts';
// Types
import type { NormNodeData } from '../VisProgPage/visualProgrammingUI/nodes/NormNode';
import type { GoalNode } from '../VisProgPage/visualProgrammingUI/nodes/GoalNode';
import type { TriggerNode } from '../VisProgPage/visualProgrammingUI/nodes/TriggerNode';
// Sub-components
import {
GestureControls,
SpeechPresets,
DirectSpeechInput,
StatusList,
RobotConnected
} from './MonitoringPageComponents';
// ----------------------------------------------------------------------
// 1. State management
// ----------------------------------------------------------------------
/**
* Manages the state of the active experiment, including phase progression,
* goal tracking, and stream event listeners.
*/
function useExperimentLogic() {
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
const getPhaseNames = useProgramStore((s) => s.getPhaseNames);
const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase);
const setProgramState = useProgramStore((state) => state.setProgramState);
const [loading, setLoading] = useState(false);
const [activeIds, setActiveIds] = useState<Record<string, boolean>>({});
const [goalIndex, setGoalIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [phaseIndex, setPhaseIndex] = useState(0);
const [isFinished, setIsFinished] = useState(false);
const phaseIds = getPhaseIds();
const phaseNames = getPhaseNames();
// --- Stream Handlers ---
const handleStreamUpdate = useCallback((data: ExperimentStreamData) => {
if (data.type === 'phase_update' && data.id) {
const payload = data as PhaseUpdate;
console.log(`${data.type} received, id : ${data.id}`);
if (payload.id === "end") {
setIsFinished(true);
} else {
setIsFinished(false);
const newIndex = getPhaseIds().indexOf(payload.id);
if (newIndex !== -1) {
setPhaseIndex(newIndex);
setGoalIndex(0);
}
}
}
else if (data.type === 'goal_update') {
const payload = data as GoalUpdate;
const currentPhaseGoals = getGoalsInPhase(phaseIds[phaseIndex]) as GoalNode[];
const gIndex = currentPhaseGoals.findIndex((g) => g.id === payload.id);
console.log(`${data.type} received, id : ${data.id}`);
if (gIndex === -1) {
console.warn(`Goal ${payload.id} not found in phase ${phaseNames[phaseIndex]}`);
} else {
setGoalIndex(gIndex);
// Mark all previous goals as achieved
setActiveIds((prev) => {
const nextState = { ...prev };
for (let i = 0; i < gIndex; i++) {
nextState[currentPhaseGoals[i].id] = true;
}
return nextState;
});
}
}
else if (data.type === 'trigger_update') {
const payload = data as TriggerUpdate;
setActiveIds((prev) => ({ ...prev, [payload.id]: payload.achieved }));
}
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex, phaseNames]);
const handleStatusUpdate = useCallback((data: unknown) => {
const payload = data as CondNormsStateUpdate;
if (payload.type !== 'cond_norms_state_update') return;
setActiveIds((prev) => {
const hasChanges = payload.norms.some((u) => prev[u.id] !== u.active);
if (!hasChanges) return prev;
const nextState = { ...prev };
payload.norms.forEach((u) => { nextState[u.id] = u.active; });
return nextState;
});
}, []);
// Connect listeners
useExperimentLogger(handleStreamUpdate);
useStatusLogger(handleStatusUpdate);
// --- Actions ---
const resetExperiment = useCallback(async () => {
try {
setLoading(true);
const phases = graphReducer();
setProgramState({ phases });
setActiveIds({});
setPhaseIndex(0);
setGoalIndex(0);
setIsFinished(false);
await runProgramm();
console.log("Experiment & UI successfully reset.");
} catch (err) {
console.error("Failed to reset program:", err);
} finally {
setLoading(false);
}
}, [setProgramState]);
const handleControlAction = async (action: "pause" | "play" | "nextPhase") => {
try {
setLoading(true);
switch (action) {
case "pause":
setIsPlaying(false);
await pauseExperiment();
break;
case "play":
setIsPlaying(true);
await playExperiment();
break;
case "nextPhase":
await nextPhase();
break;
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
return {
loading,
isPlaying,
isFinished,
phaseIds,
phaseNames,
phaseIndex,
goalIndex,
activeIds,
setActiveIds,
resetExperiment,
handleControlAction,
};
}
// ----------------------------------------------------------------------
// 2. Smaller Presentation Components
// ----------------------------------------------------------------------
/**
* Visual indicator of progress through experiment phases.
*/
function PhaseProgressBar({
phaseIds,
phaseIndex,
isFinished
}: {
phaseIds: string[],
phaseIndex: number,
isFinished: boolean
}) {
return (
<div className={styles.phaseProgress}>
{phaseIds.map((id, index) => {
let statusClass = "";
if (isFinished || index < phaseIndex) statusClass = styles.completed;
else if (index === phaseIndex) statusClass = styles.current;
return (
<span key={id} className={`${styles.phase} ${statusClass}`}>
{index + 1}
</span>
);
})}
</div>
);
}
/**
* Main control buttons (Play, Pause, Next, Reset).
*/
function ControlPanel({
loading,
isPlaying,
onAction,
onReset
}: {
loading: boolean,
isPlaying: boolean,
onAction: (a: "pause" | "play" | "nextPhase") => void,
onReset: () => void
}) {
return (
<div className={styles.experimentControls}>
<h3>Experiment Controls</h3>
<div className={styles.controlsButtons}>
<button
className={!isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}
onClick={() => onAction("pause")}
disabled={loading}
></button>
<button
className={isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}
onClick={() => onAction("play")}
disabled={loading}
></button>
<button
className={styles.next}
onClick={() => onAction("nextPhase")}
disabled={loading}
></button>
<button
className={styles.restartExperiment}
onClick={onReset}
disabled={loading}
></button>
</div>
</div>
);
}
/**
* Displays lists of Goals, Triggers, and Norms for the current phase.
*/
function PhaseDashboard({
phaseId,
activeIds,
setActiveIds,
goalIndex
}: {
phaseId: string,
activeIds: Record<string, boolean>,
setActiveIds: React.Dispatch<React.SetStateAction<Record<string, boolean>>>,
goalIndex: number
}) {
const getGoalsWithDepth = useProgramStore((s) => s.getGoalsWithDepth);
const getTriggers = useProgramStore((s) => s.getTriggersInPhase);
const getNorms = useProgramStore((s) => s.getNormsInPhase);
// Prepare data view models
const goals = getGoalsWithDepth(phaseId).map((g) => ({
...g,
id: g.id as string,
name: g.name as string,
achieved: activeIds[g.id as string] ?? false,
level: g.level, // Pass this new property to the UI
}));
const triggers = (getTriggers(phaseId) as TriggerNode[]).map(t => ({
...t,
achieved: activeIds[t.id] ?? false,
}));
const norms = (getNorms(phaseId) as NormNodeData[])
.filter(n => !n.condition)
.map(n => ({ ...n, label: n.norm }));
const conditionalNorms = (getNorms(phaseId) as (NormNodeData & { id: string })[])
.filter(n => !!n.condition)
.map(n => ({
...n,
achieved: activeIds[n.id] ?? false
}));
return (
<>
<StatusList title="Goals" items={goals} type="goal" activeIds={activeIds} setActiveIds={setActiveIds} currentGoalIndex={goalIndex} />
<StatusList title="Triggers" items={triggers} type="trigger" activeIds={activeIds} />
<StatusList title="Norms" items={norms} type="norm" activeIds={activeIds} />
<StatusList title="Conditional Norms" items={conditionalNorms} type="cond_norm" activeIds={activeIds} />
</>
);
}
// ----------------------------------------------------------------------
// 3. Main Component
// ----------------------------------------------------------------------
const MonitoringPage: React.FC = () => {
const {
loading,
isPlaying,
isFinished,
phaseIds,
phaseNames,
phaseIndex,
goalIndex,
activeIds,
setActiveIds,
resetExperiment,
handleControlAction
} = useExperimentLogic();
if (phaseIds.length === 0) {
return <p className={styles.empty}>No program loaded.</p>;
}
return (
<div className={styles.dashboardContainer}>
{/* HEADER */}
<header className={styles.experimentOverview}>
<div className={styles.phaseName}>
<h2>Experiment Overview</h2>
<p>
{isFinished ? (
<strong>Experiment finished</strong>
) : (
<><strong>Phase {phaseIndex + 1}:</strong> {phaseNames[phaseIndex]}</>
)}
</p>
<PhaseProgressBar phaseIds={phaseIds} phaseIndex={phaseIndex} isFinished={isFinished} />
</div>
<ControlPanel
loading={loading}
isPlaying={isPlaying}
onAction={handleControlAction}
onReset={resetExperiment}
/>
<div className={styles.connectionStatus}>
<RobotConnected />
</div>
</header>
{/* MAIN GRID */}
<main className={styles.phaseOverview}>
<section className={styles.phaseOverviewText}>
<h3>Phase Overview</h3>
</section>
{isFinished ? (
<div className={styles.finishedMessage}>
<p>All phases have been successfully completed.</p>
</div>
) : (
<PhaseDashboard
phaseId={phaseIds[phaseIndex]}
activeIds={activeIds}
setActiveIds={setActiveIds}
goalIndex={goalIndex}
/>
)}
</main>
{/* LOGS TODO: add actual logs */}
<aside className={styles.logs}>
<h3>Logs</h3>
<div className={styles.logHeader}>
<span>Global:</span>
<button>ALL</button>
<button>Add</button>
<button className={styles.live}>Live</button>
</div>
<textarea defaultValue="Example Log: much log"></textarea>
</aside>
{/* FOOTER */}
<footer className={styles.controlsSection}>
<GestureControls />
<SpeechPresets />
<DirectSpeechInput />
</footer>
</div>
);
}
export default MonitoringPage;

View File

@@ -1,121 +0,0 @@
import React, { useEffect } from 'react';
const API_BASE = "http://localhost:8000";
const API_BASE_BP = API_BASE + "/button_pressed"; //UserInterruptAgent endpoint
/**
* HELPER: Unified sender function
*/
export const sendAPICall = async (type: string, context: string, endpoint?: string) => {
try {
const response = await fetch(`${API_BASE_BP}${endpoint ?? ""}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, context }),
});
if (!response.ok) throw new Error("Backend response error");
console.log(`API Call send - Type: ${type}, Context: ${context} ${endpoint ? `, Endpoint: ${endpoint}` : ""}`);
} catch (err) {
console.error(`Failed to send api call:`, err);
}
};
/**
* Sends an API call to the CB for going to the next phase.
* In case we can't go to the next phase, the function will throw an error.
*/
export async function nextPhase(): Promise<void> {
const type = "next_phase"
const context = ""
sendAPICall(type, context)
}
/**
* Sends an API call to the CB for going to pause experiment
*/
export async function pauseExperiment(): Promise<void> {
const type = "pause"
const context = "true"
sendAPICall(type, context)
}
/**
* Sends an API call to the CB for going to resume experiment
*/
export async function playExperiment(): Promise<void> {
const type = "pause"
const context = "false"
sendAPICall(type, context)
}
/**
* Types for the experiment stream messages
*/
export type PhaseUpdate = { type: 'phase_update'; id: string };
export type GoalUpdate = { type: 'goal_update'; id: string };
export type TriggerUpdate = { type: 'trigger_update'; id: string; achieved: boolean };
export type CondNormsStateUpdate = { type: 'cond_norms_state_update'; norms: { id: string; active: boolean }[] };
export type ExperimentStreamData = PhaseUpdate | GoalUpdate | TriggerUpdate | CondNormsStateUpdate | Record<string, unknown>;
/**
* A hook that listens to the experiment stream that updates current state of the program
* via updates sent from the backend
*/
export function useExperimentLogger(onUpdate?: (data: ExperimentStreamData) => void) {
const callbackRef = React.useRef(onUpdate);
// Ref is updated every time with on update
React.useEffect(() => {
callbackRef.current = onUpdate;
}, [onUpdate]);
useEffect(() => {
console.log("Connecting to Experiment Stream...");
const eventSource = new EventSource(`${API_BASE}/experiment_stream`);
eventSource.onmessage = (event) => {
try {
const parsedData = JSON.parse(event.data) as ExperimentStreamData;
//call function using the ref
callbackRef.current?.(parsedData);
} catch (err) {
console.warn("Stream parse error:", err);
}
};
eventSource.onerror = (err) => {
console.error("SSE Connection Error:", err);
eventSource.close();
};
return () => {
console.log("Closing Experiment Stream...");
eventSource.close();
};
}, []);
}
/**
* A hook that listens to the status stream that updates active conditional norms
* via updates sent from the backend
*/
export function useStatusLogger(onUpdate?: (data: ExperimentStreamData) => void) {
const callbackRef = React.useRef(onUpdate);
React.useEffect(() => {
callbackRef.current = onUpdate;
}, [onUpdate]);
useEffect(() => {
const eventSource = new EventSource(`${API_BASE}/status_stream`);
eventSource.onmessage = (event) => {
try {
const parsedData = JSON.parse(event.data);
callbackRef.current?.(parsedData);
} catch (err) { console.warn("Status stream error:", err); }
};
return () => eventSource.close();
}, []);
}

View File

@@ -1,232 +0,0 @@
import React, { useEffect, useState } from 'react';
import styles from './MonitoringPage.module.css';
import { sendAPICall } from './MonitoringPageAPI';
// --- GESTURE COMPONENT ---
export const GestureControls: React.FC = () => {
const [selectedGesture, setSelectedGesture] = useState("animations/Stand/BodyTalk/Speaking/BodyTalk_1");
const gestures = [
{ label: "Wave", value: "animations/Stand/Gestures/Hey_1" },
{ label: "Think", value: "animations/Stand/Emotions/Neutral/Puzzled_1" },
{ label: "Explain", value: "animations/Stand/Gestures/Explain_4" },
{ label: "You", value: "animations/Stand/Gestures/You_1" },
{ label: "Happy", value: "animations/Stand/Emotions/Positive/Happy_1" },
{ label: "Laugh", value: "animations/Stand/Emotions/Positive/Laugh_2" },
{ label: "Lonely", value: "animations/Stand/Emotions/Neutral/Lonely_1" },
{ label: "Suprise", value: "animations/Stand/Emotions/Negative/Surprise_1" },
{ label: "Hurt", value: "animations/Stand/Emotions/Negative/Hurt_2" },
{ label: "Angry", value: "animations/Stand/Emotions/Negative/Angry_4" },
];
return (
<div className={styles.gestures}>
<h4>Gestures</h4>
<div className={styles.gestureInputGroup}>
<select
value={selectedGesture}
onChange={(e) => setSelectedGesture(e.target.value)}
>
{gestures.map(g => <option key={g.value} value={g.value}>{g.label}</option>)}
</select>
<button onClick={() => sendAPICall("gesture", selectedGesture)}>
Actuate
</button>
</div>
</div>
);
};
// --- PRESET SPEECH COMPONENT ---
export const SpeechPresets: React.FC = () => {
const phrases = [
{ label: "Hello, I'm Pepper", text: "Hello, I'm Pepper" },
{ label: "Repeat please", text: "Could you repeat that please" },
{ label: "About yourself", text: "Tell me something about yourself" },
];
return (
<div className={styles.speech}>
<h4>Speech Presets</h4>
<ul>
{phrases.map((phrase, i) => (
<li key={i}>
<button
className={styles.speechBtn}
onClick={() => sendAPICall("speech", phrase.text)}
>
"{phrase.label}"
</button>
</li>
))}
</ul>
</div>
);
};
// --- DIRECT SPEECH (INPUT) COMPONENT ---
export const DirectSpeechInput: React.FC = () => {
const [text, setText] = useState("");
const handleSend = () => {
if (!text.trim()) return;
sendAPICall("speech", text);
setText(""); // Clear after sending
};
return (
<div className={styles.directSpeech}>
<h4>Direct Pepper Speech</h4>
<div className={styles.speechInput}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Type message..."
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
/>
<button onClick={handleSend}>Send</button>
</div>
</div>
);
};
// --- interface for goals/triggers/norms/conditional norms ---
export type StatusItem = {
id?: string | number;
achieved?: boolean;
description?: string;
label?: string;
norm?: string;
name?: string;
level?: number;
};
interface StatusListProps {
title: string;
items: StatusItem[];
type: 'goal' | 'trigger' | 'norm'| 'cond_norm';
activeIds: Record<string, boolean>;
setActiveIds?: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
currentGoalIndex?: number;
}
// --- STATUS LIST COMPONENT ---
export const StatusList: React.FC<StatusListProps> = ({
title,
items,
type,
activeIds,
setActiveIds,
currentGoalIndex // Destructure this prop
}) => {
return (
<section className={styles.phaseBox}>
<h3>{title}</h3>
<ul>
{items.map((item, idx) => {
if (item.id === undefined) return null;
const isActive = !!activeIds[item.id];
const showIndicator = type !== 'norm';
const isCurrentGoal = type === 'goal' && idx === currentGoalIndex;
const canOverride = (showIndicator && !isActive) || (type === 'cond_norm' && isActive);
const indentation = (item.level || 0) * 20;
const handleOverrideClick = () => {
if (!canOverride) return;
if (type === 'cond_norm' && isActive){
{/* Unachieve conditional norm */}
sendAPICall("override_unachieve", String(item.id));
}
else {
if(type === 'goal')
if(setActiveIds)
{setActiveIds(prev => ({ ...prev, [String(item.id)]: true }));}
sendAPICall("override", String(item.id));
}
};
return (
<li key={item.id ?? idx}
className={styles.statusItem}
style={{ paddingLeft: `${indentation}px` }}
>
{showIndicator && (
<span
className={`${styles.statusIndicator} ${isActive ? styles.active : styles.inactive} ${canOverride ? styles.clickable : ''}`}
onClick={handleOverrideClick}
>
{isActive ? "✔️" : "❌"}
</span>
)}
<span
className={styles.itemDescription}
style={{
// Visual Feedback
textDecoration: isCurrentGoal ? 'underline' : 'none',
fontWeight: isCurrentGoal ? 'bold' : 'normal',
color: isCurrentGoal ? '#007bff' : 'inherit',
backgroundColor: isCurrentGoal ? '#e7f3ff' : 'transparent', // Added subtle highlight
padding: isCurrentGoal ? '2px 4px' : '0',
borderRadius: '4px'
}}
>
{item.name || item.norm}
{isCurrentGoal && " (Current)"}
</span>
</li>
);
})}
</ul>
</section>
);
};
// --- Robot Connected ---
export const RobotConnected = () => {
/**
* 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`
//commented out this log as it clutters console logs, but might be useful to debug
//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>
<h3>Connection:</h3>
<p className={connected ? styles.connected : styles.disconnected }>{connected ? "● Robot is connected" : "● Robot is disconnected"}</p>
</div>
)
}

View File

@@ -1,37 +1,16 @@
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);
/** 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 [conversation, setConversation] = useState<{ "role": "user" | "assistant", "content": string }[]>([])
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", {
const response = await fetch(`${process.env.BACKEND_ADDRESS}/message`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -45,26 +24,15 @@ 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");
const eventSource = new EventSource(`${process.env.BACKEND_ADDRESS}/sse`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if ("voice_active" in data) setListening(data.voice_active);
if ("speech" in data) setConversation(conversation => [...conversation, {"role": "user", "content": data.speech}]);
if ("llm_response" in data) setConversation(conversation => [...conversation, {"role": "assistant", "content": data.llm_response}]);
if ("speech" in data) setConversation(conversation => [...conversation, { "role": "user", "content": data.speech }]);
if ("llm_response" in data) setConversation(conversation => [...conversation, { "role": "assistant", "content": data.llm_response }]);
} catch {
console.log("Unparsable SSE message:", event.data);
}
@@ -75,10 +43,6 @@ 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;
@@ -101,7 +65,7 @@ export default function Robot() {
<div className={"flex-col gap-lg align-center"}>
<h2>Conversation</h2>
<p>Listening {listening ? "🟢" : "🔴"}</p>
<div style={{ maxHeight: "200px", maxWidth: "600px", overflowY: "auto"}} ref={conversationRef}>
<div style={{ maxHeight: "200px", maxWidth: "600px", overflowY: "auto" }} ref={conversationRef}>
{conversation.map((item, i) => (
<p key={i}
style={{

View File

@@ -1,167 +0,0 @@
/* ---------- Layout ---------- */
.container {
height: 100%;
display: flex;
flex-direction: column;
background: #1e1e1e;
color: #f5f5f5;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: clamp(0.75rem, 2vw, 1.25rem);
background: #2a2a2a;
border-bottom: 1px solid #3a3a3a;
}
.header h2 {
font-size: clamp(1rem, 2.2vw, 1.4rem);
font-weight: 600;
}
.controls button {
margin-left: 0.5rem;
padding: 0.4rem 0.9rem;
border-radius: 6px;
border: none;
background: #111;
color: white;
cursor: pointer;
}
.controls button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ---------- Content ---------- */
.content {
flex: 1;
padding: 2%;
}
/* ---------- Grid ---------- */
.phaseGrid {
height: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 2%;
}
/* ---------- Box ---------- */
.box {
display: flex;
flex-direction: column;
background: #ffffff;
color: #1e1e1e;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
}
.boxHeader {
padding: 0.6rem 0.9rem;
background: linear-gradient(135deg, #dcdcdc, #e9e9e9);
font-style: italic;
font-weight: 500;
font-size: clamp(0.9rem, 1.5vw, 1.05rem);
border-bottom: 1px solid #cfcfcf;
}
.boxContent {
flex: 1;
padding: 0.8rem 1rem;
overflow-y: auto;
}
/* ---------- Lists ---------- */
.iconList {
list-style: none;
padding: 0;
margin: 0;
}
.iconList li {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.5rem;
font-size: clamp(0.85rem, 1.3vw, 1rem);
}
.bulletList {
margin: 0;
padding-left: 1.2rem;
}
.bulletList li {
margin-bottom: 0.4rem;
}
/* ---------- Icons ---------- */
.successIcon,
.failIcon {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.5rem;
border-radius: 4px;
font-weight: bold;
color: white;
flex-shrink: 0;
}
.successIcon {
background: #3cb371;
}
.failIcon {
background: #e5533d;
}
/* ---------- Empty ---------- */
.empty {
opacity: 0.55;
font-style: italic;
font-size: 0.9rem;
}
/* ---------- Responsive ---------- */
@media (max-width: 900px) {
.phaseGrid {
grid-template-columns: 1fr;
grid-template-rows: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
}
.leftControls {
display: flex;
align-items: center;
gap: 1rem;
}
.backButton {
background: transparent;
border: 1px solid #555;
color: #ddd;
padding: 0.35rem 0.75rem;
border-radius: 6px;
cursor: pointer;
}
.backButton:hover {
background: #333;
}

View File

@@ -1,192 +0,0 @@
import React from "react";
import styles from "./SimpleProgram.module.css";
import useProgramStore from "../../utils/programStore.ts";
/**
* Generic container box with a header and content area.
*/
type BoxProps = {
title: string;
children: React.ReactNode;
};
const Box: React.FC<BoxProps> = ({ title, children }) => (
<div className={styles.box}>
<div className={styles.boxHeader}>{title}</div>
<div className={styles.boxContent}>{children}</div>
</div>
);
/**
* Renders a list of goals for a phase.
* Expects goal-like objects from the program store.
*/
const GoalList: React.FC<{ goals: unknown[] }> = ({ goals }) => {
if (!goals.length) {
return <p className={styles.empty}>No goals defined.</p>;
}
return (
<ul className={styles.iconList}>
{goals.map((g, idx) => {
const goal = g as {
id?: string;
description?: string;
achieved?: boolean;
};
return (
<li key={goal.id ?? idx}>
<span
className={
goal.achieved ? styles.successIcon : styles.failIcon
}
>
{goal.achieved ? "✔" : "✖"}
</span>
{goal.description ?? "Unnamed goal"}
</li>
);
})}
</ul>
);
};
/**
* Renders a list of triggers for a phase.
*/
const TriggerList: React.FC<{ triggers: unknown[] }> = ({ triggers }) => {
if (!triggers.length) {
return <p className={styles.empty}>No triggers defined.</p>;
}
return (
<ul className={styles.iconList}>
{triggers.map((t, idx) => {
const trigger = t as {
id?: string;
label?: string;
};
return (
<li key={trigger.id ?? idx}>
<span className={styles.failIcon}></span>
{trigger.label ?? "Unnamed trigger"}
</li>
);
})}
</ul>
);
};
/**
* Renders a list of norms for a phase.
*/
const NormList: React.FC<{ norms: unknown[] }> = ({ norms }) => {
if (!norms.length) {
return <p className={styles.empty}>No norms defined.</p>;
}
return (
<ul className={styles.bulletList}>
{norms.map((n, idx) => {
const norm = n as {
id?: string;
norm?: string;
};
return <li key={norm.id ?? idx}>{norm.norm ?? "Unnamed norm"}</li>;
})}
</ul>
);
};
/**
* Displays all phase-related information in a grid layout.
*/
type PhaseGridProps = {
norms: unknown[];
goals: unknown[];
triggers: unknown[];
};
const PhaseGrid: React.FC<PhaseGridProps> = ({
norms,
goals,
triggers,
}) => (
<div className={styles.phaseGrid}>
<Box title="Norms">
<NormList norms={norms} />
</Box>
<Box title="Triggers">
<TriggerList triggers={triggers} />
</Box>
<Box title="Goals">
<GoalList goals={goals} />
</Box>
<Box title="Conditional Norms">
<p className={styles.empty}>No conditional norms defined.</p>
</Box>
</div>
);
/**
* Main program viewer.
* Reads all data from the program store and allows
* navigating between phases.
*/
const SimpleProgram: React.FC = () => {
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
const getNormsInPhase = useProgramStore((s) => s.getNormsInPhase);
const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase);
const getTriggersInPhase = useProgramStore((s) => s.getTriggersInPhase);
const phaseIds = getPhaseIds();
const [phaseIndex, setPhaseIndex] = React.useState(0);
if (phaseIds.length === 0) {
return <p className={styles.empty}>No program loaded.</p>;
}
const phaseId = phaseIds[phaseIndex];
return (
<div className={styles.container}>
<header className={styles.header}>
<h2>
Phase {phaseIndex + 1} / {phaseIds.length}
</h2>
<div className={styles.controls}>
<button
disabled={phaseIndex === 0}
onClick={() => setPhaseIndex((i) => i - 1)}
>
Prev
</button>
<button
disabled={phaseIndex === phaseIds.length - 1}
onClick={() => setPhaseIndex((i) => i + 1)}
>
Next
</button>
</div>
</header>
<div className={styles.content}>
<PhaseGrid
norms={getNormsInPhase(phaseId)}
goals={getGoalsInPhase(phaseId)}
triggers={getTriggersInPhase(phaseId)}
/>
</div>
</div>
);
};
export default SimpleProgram;

View File

@@ -1,12 +1,26 @@
/* 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 {
box-sizing: border-box;
margin: 1rem;
width: calc(100% - 2rem);
outline-style: solid;
border-radius: 10pt;
width: 90%;
height: 100%;
}
.dnd-panel {
margin-inline-start: auto;
margin-inline-end: auto;
@@ -33,12 +47,6 @@
/* Node Styles */
:global(.react-flow__node.selected) {
outline: 1px dashed blue !important;
border-radius: 5pt;
outline-offset: 4px;
}
.default-node {
padding: 10px 15px;
background-color: canvas;
@@ -47,53 +55,42 @@
filter: drop-shadow(0 0 0.75rem black);
}
.node-norm {
outline: rgb(0, 149, 25) solid 2pt;
.default-node-norm {
padding: 10px 15px;
background-color: canvas;
border-radius: 5pt;
outline: forestgreen solid 2pt;
filter: drop-shadow(0 0 0.25rem forestgreen);
}
.node-goal {
outline: yellow solid 2pt;
filter: drop-shadow(0 0 0.25rem yellow);
}
.node-trigger {
outline: teal solid 2pt;
filter: drop-shadow(0 0 0.25rem teal);
}
.node-phase {
.default-node-phase {
padding: 10px 15px;
background-color: canvas;
border-radius: 5pt;
outline: dodgerblue solid 2pt;
filter: drop-shadow(0 0 0.25rem dodgerblue);
}
.node-start {
.default-node-start {
padding: 10px 15px;
background-color: canvas;
border-radius: 5pt;
outline: orange solid 2pt;
filter: drop-shadow(0 0 0.25rem orange);
}
.node-end {
.default-node-end {
padding: 10px 15px;
background-color: canvas;
border-radius: 5pt;
outline: red solid 2pt;
filter: drop-shadow(0 0 0.25rem red);
}
.node-basic_belief {
outline: plum solid 2pt;
filter: drop-shadow(0 0 0.25rem plum);
}
.node-inferred_belief {
outline: mediumpurple solid 2pt;
filter: drop-shadow(0 0 0.25rem mediumpurple);
}
.draggable-node {
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: black solid 2pt;
filter: drop-shadow(0 0 0.75rem black);
}
@@ -102,34 +99,14 @@
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: forestgreen solid 2pt;
filter: drop-shadow(0 0 0.25rem forestgreen);
}
.draggable-node-goal {
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: yellow solid 2pt;
filter: drop-shadow(0 0 0.25rem yellow);
}
.draggable-node-trigger {
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: teal solid 2pt;
filter: drop-shadow(0 0 0.25rem teal);
}
.draggable-node-phase {
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: dodgerblue solid 2pt;
filter: drop-shadow(0 0 0.25rem dodgerblue);
}
@@ -138,7 +115,6 @@
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: orange solid 2pt;
filter: drop-shadow(0 0 0.25rem orange);
}
@@ -147,99 +123,7 @@
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: red solid 2pt;
filter: drop-shadow(0 0 0.25rem red);
}
.draggable-node-basic_belief {
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: plum solid 2pt;
filter: drop-shadow(0 0 0.25rem plum);
}
.draggable-node-inferred_belief {
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
outline: mediumpurple solid 2pt;
filter: drop-shadow(0 0 0.25rem mediumpurple);
}
.planNoIterate {
opacity: 0.5;
font-style: italic;
text-decoration: line-through;
}
.bottomLeftHandle {
left: 40% !important;
}
.bottomRightHandle {
left: 60% !important;
}
.planNoIterate {
opacity: 0.5;
font-style: italic;
text-decoration: line-through;
}
.backButton {
background: var(--bg-surface);
box-shadow: var(--panel-shadow);
margin-top: 0.5rem;
margin-left: 0.5rem;
}
.node-toolbar-tooltip {
background-color: darkgray;
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-family: sans-serif;
font-weight: bold;
cursor: help;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
white-space: nowrap;
}
.custom-tooltip {
pointer-events: none;
background-color: Canvas;
color: CanvasText;
padding: 8px 12px;
border-radius: 0 6px 6px 0;
outline: CanvasText solid 2px;
font-size: 14px;
filter: drop-shadow(0 0 0.25rem CanvasText);
white-space: nowrap;
position: relative;
animation: fadeIn 0.2s ease-out;
}
.custom-tooltip-header {
pointer-events: none;
background-color: CanvasText;
color: Canvas;
padding: 8px 12px;
border-radius: 6px 0 0 6px;
outline: CanvasText solid 2px;
font-size: 14px;
font-weight: bold;
font-variant-caps: small-caps;
filter: drop-shadow(0 0 0.25rem CanvasText);
white-space: nowrap;
position: relative;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}

View File

@@ -7,20 +7,31 @@ import {
MarkerType,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {type CSSProperties, useEffect, useState} from "react";
import {useShallow} from 'zustand/react/shallow';
import useProgramStore from "../../utils/programStore.ts";
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 {NodeTypes} from './visualProgrammingUI/NodeRegistry.ts';
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
import MonitoringPage from '../MonitoringPage/MonitoringPage.tsx';
import { graphReducer, runProgramm } from './VisProgLogic.ts';
// --| 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
@@ -42,17 +53,11 @@ const selector = (state: FlowState) => ({
nodes: state.nodes,
edges: state.edges,
onNodesChange: state.onNodesChange,
onEdgesDelete: state.onEdgesDelete,
onEdgesChange: state.onEdgesChange,
onConnect: state.onConnect,
onReconnectStart: state.onReconnectStart,
onReconnectEnd: state.onReconnectEnd,
onReconnect: state.onReconnect,
undo: state.undo,
redo: state.redo,
beginBatchAction: state.beginBatchAction,
endBatchAction: state.endBatchAction,
scrollable: state.scrollable
onReconnect: state.onReconnect
});
// --| define ReactFlow editor |--
@@ -67,68 +72,43 @@ const VisProgUI = () => {
const {
nodes, edges,
onNodesChange,
onEdgesDelete,
onEdgesChange,
onConnect,
onReconnect,
onReconnectStart,
onReconnectEnd,
undo,
redo,
beginBatchAction,
endBatchAction,
scrollable
onReconnectEnd
} = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore
const [zoom, setZoom] = useState(1);
// adds ctrl+z and ctrl+y support to respectively undo and redo actions
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'z') undo();
if (e.ctrlKey && e.key === 'y') redo();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
return (
<div className={`${styles.innerEditorContainer} round-lg border-lg`} style={({'--flow-zoom': zoom} as CSSProperties)}>
<ReactFlow
nodes={nodes}
edges={edges}
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
nodeTypes={NodeTypes}
onNodesChange={onNodesChange}
onEdgesDelete={onEdgesDelete}
onEdgesChange={onEdgesChange}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
onConnect={onConnect}
onNodeDragStart={beginBatchAction}
onNodeDragStop={endBatchAction}
preventScrolling={scrollable}
onMove={(_, viewport) => setZoom(viewport.zoom)}
snapToGrid
fitView
proOptions={{hideAttribution: true}}
>
<Panel position="top-center" className={styles.dndPanel}>
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
<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 */}
</Panel>
<Panel position = "bottom-left" className={styles.saveLoadPanel}>
<SaveLoadPanel></SaveLoadPanel>
</Panel>
<Panel position="bottom-center">
<button onClick={() => undo()}>Undo</button>
<button onClick={() => redo()}>Redo</button>
</Panel>
<Controls/>
<Background/>
</ReactFlow>
<Controls/>
<Background/>
</ReactFlow>
</div>
</div>
);
};
/**
* Places the VisProgUI component inside a ReactFlowProvider
*
@@ -143,7 +123,12 @@ function VisualProgrammingUI() {
</ReactFlowProvider>
);
}
// currently outputs the prepared program to the console
function runProgram() {
const program = graphReducer();
console.log(program);
}
/**
* houses the entire page, so also UI elements
@@ -151,33 +136,12 @@ function VisualProgrammingUI() {
* @constructor
*/
function VisProgPage() {
const [showSimpleProgram, setShowSimpleProgram] = useState(false);
const setProgramState = useProgramStore((state) => state.setProgramState);
const runProgram = () => {
const phases = graphReducer(); // reduce graph
setProgramState({ phases }); // <-- save to store
setShowSimpleProgram(true); // show SimpleProgram
runProgramm(); // send to backend if needed
};
if (showSimpleProgram) {
return (
<div>
<button className={styles.backButton} onClick={() => setShowSimpleProgram(false)}>
Back to Editor
</button>
<MonitoringPage/>
</div>
);
}
return (
<>
<VisualProgrammingUI/>
<button onClick={runProgram}>Run Program</button>
<button onClick={runProgram}>run program</button>
</>
)
}
export default VisProgPage
export default VisProgPage

View File

@@ -1,43 +0,0 @@
import useProgramStore from "../../utils/programStore";
import orderPhaseNodeArray from "../../utils/orderPhaseNodes";
import useFlowStore from './visualProgrammingUI/VisProgStores';
import { NodeReduces } from './visualProgrammingUI/NodeRegistry';
import type { PhaseNode } from "./visualProgrammingUI/nodes/PhaseNode";
/**
* Reduces the graph into its phases' information and recursively calls their reducing function
*/
export function graphReducer() {
const { nodes } = useFlowStore.getState();
return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode [])
.map((n) => {
const reducer = NodeReduces['phase'];
return reducer(n, nodes)
});
}
/**
* Outputs the prepared program to the console and sends it to the backend
*/
export function runProgramm() {
const phases = graphReducer();
const program = {phases}
console.log(JSON.stringify(program, null, 2));
fetch(
"http://localhost:8000/program",
{
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(program),
}
).then((res) => {
if (!res.ok) throw new Error("Failed communicating with the backend.")
console.log("Successfully sent the program to the backend.");
// store reduced program in global program store for further use in the UI
// when the program was sent to the backend successfully:
useProgramStore.getState().setProgramState(structuredClone(program));
}).catch(() => console.log("Failed to send program to the backend."));
console.log(program);
}

View File

@@ -1,129 +0,0 @@
import type {Edge, Node} from "@xyflow/react";
import type {StateCreator, StoreApi } from 'zustand/vanilla';
import type {FlowState} from "./VisProgTypes.tsx";
export type FlowSnapshot = {
nodes: Node[];
edges: Edge[];
}
/**
* A reduced version of the flowState type,
* This removes the functions that are provided by UndoRedo from the expected input type
*/
type BaseFlowState = Omit<FlowState, 'undo' | 'redo' | 'pushSnapshot' | 'beginBatchAction' | 'endBatchAction'>;
/**
* UndoRedo is implemented as a middleware for the FlowState store,
* this allows us to keep the undo redo logic separate from the flowState,
* and thus from the internal editor logic
*
* Allows users to undo and redo actions in the visual programming editor
*
* @param {(set: StoreApi<FlowState>["setState"], get: () => FlowState, api: StoreApi<FlowState>) => BaseFlowState} config
* @returns {StateCreator<FlowState>}
* @constructor
*/
export const UndoRedo = (
config: (
set: StoreApi<FlowState>['setState'],
get: () => FlowState,
api: StoreApi<FlowState>
) => BaseFlowState ) : StateCreator<FlowState> => (set, get, api) => {
let batchTimeout: number | null = null;
/**
* Captures the current state for
*
* @param {BaseFlowState} state - the current state of the editor
* @returns {FlowSnapshot} - returns a snapshot of the current editor state
*/
const getSnapshot = (state : BaseFlowState) : FlowSnapshot => (structuredClone({
nodes: state.nodes,
edges: state.edges
}));
const initialState = config(set, get, api);
return {
...initialState,
/**
* Adds a snapshot of the current state to the undo history
*/
pushSnapshot: () => {
const state = get();
// we don't add new snapshots during an ongoing batch action
if (!state.isBatchAction) {
set({
past: [...state.past, getSnapshot(state)],
future: []
});
}
},
/**
* Undoes the last action from the editor,
* The state before undoing is added to the future for potential redoing
*/
undo: () => {
const state = get();
if (!state.past.length) return;
const snapshot = state.past.pop()!; // pop last snapshot
const currentSnapshot: FlowSnapshot = getSnapshot(state);
set({
nodes: snapshot.nodes,
edges: snapshot.edges,
});
state.future.push(currentSnapshot); // push current to redo
},
/**
* redoes the last undone action,
* The state before redoing is added to the past for potential undoing
*/
redo: () => {
const state = get();
if (!state.future.length) return;
const snapshot = state.future.pop()!; // pop last redo
const currentSnapshot: FlowSnapshot = getSnapshot(state);
set({
nodes: snapshot.nodes,
edges: snapshot.edges,
});
state.past.push(currentSnapshot); // push current to undo
},
/**
* Begins a batched action
*
* An example of a batched action is dragging a node in the editor,
* where we want the entire action of moving a node to a different position
* to be covered by one undoable snapshot
*/
beginBatchAction: () => {
get().pushSnapshot();
set({ isBatchAction: true });
if (batchTimeout) clearTimeout(batchTimeout);
},
/**
* Ends a batched action,
* a very short timeout is used to prevent new snapshots from being added
* until we are certain that the batch event is finished
*/
endBatchAction: () => {
batchTimeout = window.setTimeout(() => {
set({ isBatchAction: false });
}, 10);
}
}
}

View File

@@ -0,0 +1,188 @@
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

@@ -0,0 +1,106 @@
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

@@ -1,122 +0,0 @@
import {type Connection} from "@xyflow/react";
import {useEffect} from "react";
import useFlowStore from "./VisProgStores.tsx";
export type ConnectionContext = {
connectionCount: number;
source: {
id: string;
handleId: string;
}
target: {
id: string;
handleId: string;
}
}
export type HandleRule = (
connection: Connection,
context: ConnectionContext
) => RuleResult;
/**
* A RuleResult describes the outcome of validating a HandleRule
*
* if a rule is not satisfied, the RuleResult includes a message that is used inside a tooltip
* that tells the user why their attempted connection is not possible
*/
export type RuleResult =
| { isSatisfied: true }
| { isSatisfied: false, message: string };
/**
* default RuleResults, can be used to create more readable handleRule definitions
*/
export const ruleResult = {
satisfied: { isSatisfied: true } as RuleResult,
unknownError: {isSatisfied: false, message: "Unknown Error" } as RuleResult,
notSatisfied: (message: string) : RuleResult => { return {isSatisfied: false, message: message } }
}
const evaluateRules = (
rules: HandleRule[],
connection: Connection,
context: ConnectionContext
) : RuleResult => {
// evaluate the rules and check if there is at least one unsatisfied rule
const failedRule = rules
.map(rule => rule(connection, context))
.find(result => !result.isSatisfied);
return failedRule ? ruleResult.notSatisfied(failedRule.message) : ruleResult.satisfied;
}
/**
* !DOCUMENTATION NOT FINISHED!
*
* - The output is a single RuleResult, meaning we only show one error message.
* Error messages are prioritised by listOrder; Thus, if multiple HandleRules evaluate to false,
* we only send the error message of the first failed rule in the target's registered list of rules.
*
* @param {string} nodeId
* @param {string} handleId
* @param type
* @param {HandleRule[]} rules
* @returns {(c: Connection) => RuleResult} a function that validates an attempted connection
*/
export function useHandleRules(
nodeId: string,
handleId: string,
type: "source" | "target",
rules: HandleRule[],
) : (c: Connection) => RuleResult {
const edges = useFlowStore.getState().edges;
const registerRules = useFlowStore((state) => state.registerRules);
useEffect(() => {
registerRules(nodeId, handleId, rules);
// the following eslint disable is required as it wants us to use all possible dependencies for the useEffect statement,
// however this would result in an infinite loop because it would change one of its own dependencies
// so we only use those dependencies that we don't change ourselves
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleId, nodeId, registerRules]);
return (connection: Connection) => {
// inside this function we consider the target to be the target of the isValidConnection event
// and not the target in the actual connection
const { target, targetHandle } = type === "source"
? connection
: { target: connection.source, targetHandle: connection.sourceHandle };
if (!targetHandle) {throw new Error("No target handle was provided");}
const targetConnections = edges.filter(edge => edge.target === target && edge.targetHandle === targetHandle);
// we construct the connectionContext
const context: ConnectionContext = {
connectionCount: targetConnections.length,
source: {id: nodeId, handleId: handleId},
target: {id: target, handleId: targetHandle},
};
const targetRules = useFlowStore.getState().getTargetRules(target, targetHandle);
// finally we return a function that evaluates all rules using the created context
return evaluateRules(targetRules, connection, context);
};
}
export function validateConnectionWithRules(
connection: Connection,
context: ConnectionContext
): RuleResult {
const rules = useFlowStore.getState().getTargetRules(
connection.target!,
connection.targetHandle!
);
return evaluateRules(rules,connection, context);
}

View File

@@ -1,46 +0,0 @@
import {
type HandleRule,
ruleResult
} from "./HandleRuleLogic.ts";
import useFlowStore from "./VisProgStores.tsx";
/**
* this specifies what types of nodes can make a connection to a handle that uses this rule
*/
export function allowOnlyConnectionsFromType(nodeTypes: string[]) : HandleRule {
return ((_, {source}) => {
const sourceType = useFlowStore.getState().nodes.find(node => node.id === source.id)!.type!;
return nodeTypes.find(type => sourceType === type)
? ruleResult.satisfied
: ruleResult.notSatisfied(`the target doesn't allow connections from nodes with type: ${sourceType}`);
})
}
/**
* similar to allowOnlyConnectionsFromType,
* this is a more specific variant that allows you to restrict connections to specific handles on each nodeType
*/
//
export function allowOnlyConnectionsFromHandle(handles: {nodeType: string, handleId: string}[]) : HandleRule {
return ((_, {source}) => {
const sourceNode = useFlowStore.getState().nodes.find(node => node.id === source.id)!;
return handles.find(handle => sourceNode.type === handle.nodeType && source.handleId === handle.handleId)
? ruleResult.satisfied
: ruleResult.notSatisfied(`the target doesn't allow connections from nodes with type: ${sourceNode.type}`);
})
}
/**
* This rule prevents a node from making a connection between its own handles
*/
export const noSelfConnections : HandleRule =
(connection, _) => {
return connection.source !== connection.target
? ruleResult.satisfied
: ruleResult.notSatisfied("nodes are not allowed to connect to themselves");
}

View File

@@ -1,220 +0,0 @@
import EndNode, {
EndConnectionTarget,
EndConnectionSource,
EndDisconnectionTarget,
EndDisconnectionSource,
EndReduce,
EndTooltip
} from "./nodes/EndNode";
import { EndNodeDefaults } from "./nodes/EndNode.default";
import StartNode, {
StartConnectionTarget,
StartConnectionSource,
StartDisconnectionTarget,
StartDisconnectionSource,
StartReduce,
StartTooltip
} from "./nodes/StartNode";
import { StartNodeDefaults } from "./nodes/StartNode.default";
import PhaseNode, {
PhaseConnectionTarget,
PhaseConnectionSource,
PhaseDisconnectionTarget,
PhaseDisconnectionSource,
PhaseReduce,
PhaseTooltip
} from "./nodes/PhaseNode";
import { PhaseNodeDefaults } from "./nodes/PhaseNode.default";
import NormNode, {
NormConnectionTarget,
NormConnectionSource,
NormDisconnectionTarget,
NormDisconnectionSource,
NormReduce,
NormTooltip
} from "./nodes/NormNode";
import { NormNodeDefaults } from "./nodes/NormNode.default";
import GoalNode, {
GoalConnectionTarget,
GoalConnectionSource,
GoalDisconnectionTarget,
GoalDisconnectionSource,
GoalReduce,
GoalTooltip
} from "./nodes/GoalNode";
import { GoalNodeDefaults } from "./nodes/GoalNode.default";
import TriggerNode, {
TriggerConnectionTarget,
TriggerConnectionSource,
TriggerDisconnectionTarget,
TriggerDisconnectionSource,
TriggerReduce,
TriggerTooltip
} from "./nodes/TriggerNode";
import { TriggerNodeDefaults } from "./nodes/TriggerNode.default";
import InferredBeliefNode, {
InferredBeliefConnectionTarget,
InferredBeliefConnectionSource,
InferredBeliefDisconnectionTarget,
InferredBeliefDisconnectionSource,
InferredBeliefReduce, InferredBeliefTooltip
} from "./nodes/InferredBeliefNode";
import { InferredBeliefNodeDefaults } from "./nodes/InferredBeliefNode.default";
import BasicBeliefNode, {
BasicBeliefConnectionSource,
BasicBeliefConnectionTarget,
BasicBeliefDisconnectionSource,
BasicBeliefDisconnectionTarget,
BasicBeliefReduce
,
BasicBeliefTooltip
} from "./nodes/BasicBeliefNode.tsx";
import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default.ts";
/**
* 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,
basic_belief: BasicBeliefNode,
inferred_belief: InferredBeliefNode,
};
/**
* 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,
basic_belief: BasicBeliefNodeDefaults,
inferred_belief: InferredBeliefNodeDefaults,
};
/**
* 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,
basic_belief: BasicBeliefReduce,
inferred_belief: InferredBeliefReduce,
}
/**
* Connection functions for each node type.
*
* These functions define any additional actions a node may perform
* when a new connection is made
*/
export const NodeConnections = {
Targets: {
start: StartConnectionTarget,
end: EndConnectionTarget,
phase: PhaseConnectionTarget,
norm: NormConnectionTarget,
goal: GoalConnectionTarget,
trigger: TriggerConnectionTarget,
basic_belief: BasicBeliefConnectionTarget,
inferred_belief: InferredBeliefConnectionTarget,
},
Sources: {
start: StartConnectionSource,
end: EndConnectionSource,
phase: PhaseConnectionSource,
norm: NormConnectionSource,
goal: GoalConnectionSource,
trigger: TriggerConnectionSource,
basic_belief: BasicBeliefConnectionSource,
inferred_belief: InferredBeliefConnectionSource,
}
}
/**
* Disconnection functions for each node type.
*
* These functions define any additional actions a node may perform
* when a connection is disconnected
*/
export const NodeDisconnections = {
Targets: {
start: StartDisconnectionTarget,
end: EndDisconnectionTarget,
phase: PhaseDisconnectionTarget,
norm: NormDisconnectionTarget,
goal: GoalDisconnectionTarget,
trigger: TriggerDisconnectionTarget,
basic_belief: BasicBeliefDisconnectionTarget,
inferred_belief: InferredBeliefDisconnectionTarget,
},
Sources: {
start: StartDisconnectionSource,
end: EndDisconnectionSource,
phase: PhaseDisconnectionSource,
norm: NormDisconnectionSource,
goal: GoalDisconnectionSource,
trigger: TriggerDisconnectionSource,
basic_belief: BasicBeliefDisconnectionSource,
inferred_belief: InferredBeliefDisconnectionSource,
},
}
/**
* 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,
basic_belief: () => false,
inferred_belief: () => false,
}
/**
* Collects the tooltips for all nodeTypes so they can be accessed by the tooltip component
*/
export const NodeTooltips = {
start: StartTooltip,
end: EndTooltip,
phase: PhaseTooltip,
norm: NormTooltip,
goal: GoalTooltip,
trigger: TriggerTooltip,
basic_belief: BasicBeliefTooltip,
inferred_belief: InferredBeliefTooltip,
}

View File

@@ -1,348 +1,116 @@
import { create } from 'zustand';
import {create} from 'zustand';
import {
applyNodeChanges,
applyEdgeChanges,
addEdge,
reconnectEdge,
type Node,
type Edge,
type XYPosition,
reconnectEdge, type Edge, type Connection
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {type ConnectionContext, validateConnectionWithRules} from "./HandleRuleLogic.ts";
import type { FlowState } from './VisProgTypes';
import {
NodeDefaults,
NodeConnections as NodeCs,
NodeDisconnections as NodeDs,
NodeDeletes
} from './NodeRegistry';
import { UndoRedo } from "./EditorUndoRedo.ts";
import {type FlowState} from './VisProgTypes.tsx';
/**
* 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.
* contains the nodes that are created when the editor is loaded,
* should contain at least a start and an end node
*/
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable?: boolean) {
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
return {
id,
type,
position,
deletable,
data: {
...JSON.parse(JSON.stringify(defaultData)),
...data,
},
}
}
//* Initial nodes, created by using createNode. */
// Start and End don't need to apply the UUID, since they are technically never compiled into a program.
const startNode = createNode('start', 'start', {x: 110, y: 100}, {label: "Start"}, false)
const endNode = createNode('end', 'end', {x: 590, y: 100}, {label: "End"}, false)
const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:235, y:100}, {label: "Phase 1", children : [], isFirstPhase: false, nextPhaseId: null})
const initialNodes : Node[] = [startNode, endNode, initialPhaseNode,];
// Initial edges, leave empty as setting initial edges...
// ...breaks logic that is dependent on connection events
const initialEdges: Edge[] = [];
/**
* useFlowStore contains the implementation for all editor functionality
* and stores the current state of the visual programming editor
*
* * Provides:
* - Node and edge state management
* - Node creation, deletion, and updates
* - Custom connection handling via NodeConnects
* - Edge reconnection handling
* - Undo Redo functionality through custom middleware
*/
const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
nodes: initialNodes,
edges: initialEdges,
edgeReconnectSuccessful: true,
scrollable: true,
/**
* handles changing the scrollable state of the editor,
* this is used to control if scrolling is captured by the editor
* or if it's available to other components within the reactFlowProvider
* @param {boolean} val - the desired state
*/
setScrollable: (val) => set({scrollable: val}),
/**
* Handles changes to nodes triggered by ReactFlow.
*/
onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}),
onNodesDelete: (nodes) => nodes.forEach(node => get().unregisterNodeRules(node.id)),
onEdgesDelete: (edges) => {
// we make sure any affected nodes get updated to reflect removal of edges
edges.forEach((edge) => {
const nodes = get().nodes;
const sourceNode = nodes.find((n) => n.id == edge.source);
const targetNode = nodes.find((n) => n.id == edge.target);
if (sourceNode) { NodeDs.Sources[sourceNode.type as keyof typeof NodeDs.Sources](sourceNode, edge.target); }
if (targetNode) { NodeDs.Targets[targetNode.type as keyof typeof NodeDs.Targets](targetNode, edge.source); }
});
const initialNodes = [
{
id: 'start',
type: 'start',
position: {x: 0, y: 0},
data: {label: 'start'}
},
/**
* Handles changes to edges triggered by ReactFlow.
*/
onEdgesChange: (changes) => {
set({ edges: applyEdgeChanges(changes, get().edges) })
{
id: 'phase-1',
type: 'phase',
position: {x: 0, y: 150},
data: {label: 'Generic Phase', number: 1},
},
/**
* Handles creating a new connection between nodes.
* Updates edges and calls the node-specific connection functions.
*/
onConnect: (connection) => {
get().pushSnapshot();
set({edges: addEdge(connection, get().edges)});
// We make sure to perform any required data updates on the newly connected nodes
const nodes = get().nodes;
const sourceNode = nodes.find((n) => n.id == connection.source);
const targetNode = nodes.find((n) => n.id == connection.target);
if (sourceNode) { NodeCs.Sources[sourceNode.type as keyof typeof NodeCs.Sources](sourceNode, connection.target); }
if (targetNode) { NodeCs.Targets[targetNode.type as keyof typeof NodeCs.Targets](targetNode, connection.source); }
},
/**
* Handles reconnecting an edge between nodes.
*/
onReconnect: (oldEdge, newConnection) => {
function createContext(
source: {id: string, handleId: string},
target: {id: string, handleId: string}
) : ConnectionContext {
const edges = get().edges;
const targetConnections = edges.filter(edge => edge.target === target.id && edge.targetHandle === target.handleId).length
return {
connectionCount: targetConnections,
source: source,
target: target
}
}
// connection validation
const context: ConnectionContext = oldEdge.source === newConnection.source
? createContext({id: newConnection.source, handleId: newConnection.sourceHandle!}, {id: newConnection.target, handleId: newConnection.targetHandle!})
: createContext({id: newConnection.target, handleId: newConnection.targetHandle!}, {id: newConnection.source, handleId: newConnection.sourceHandle!});
const result = validateConnectionWithRules(
newConnection,
context
);
if (!result.isSatisfied) {
set({
edges: get().edges.map(e =>
e.id === oldEdge.id ? oldEdge : e
),
});
return;
}
// further reconnect logic
set({ edgeReconnectSuccessful: true });
set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) });
// We make sure to perform any required data updates on the newly reconnected nodes
const nodes = get().nodes;
const oldSourceNode = nodes.find((n) => n.id == oldEdge.source)!;
const oldTargetNode = nodes.find((n) => n.id == oldEdge.target)!;
const newSourceNode = nodes.find((n) => n.id == newConnection.source)!;
const newTargetNode = nodes.find((n) => n.id == newConnection.target)!;
if (oldSourceNode === newSourceNode && oldTargetNode === newTargetNode) return;
NodeCs.Sources[newSourceNode.type as keyof typeof NodeCs.Sources](newSourceNode, newConnection.target);
NodeCs.Targets[newTargetNode.type as keyof typeof NodeCs.Targets](newTargetNode, newConnection.source);
NodeDs.Sources[oldSourceNode.type as keyof typeof NodeDs.Sources](oldSourceNode, oldEdge.target);
NodeDs.Targets[oldTargetNode.type as keyof typeof NodeDs.Targets](oldTargetNode, oldEdge.source);
},
onReconnectStart: () => {
get().pushSnapshot();
set({ edgeReconnectSuccessful: false })
},
/**
* handles potential dropping (deleting) of an edge
* if it is not reconnected to a node after detaching it
*
* @param _evt - the event
* @param edge - the described edge
*/
onReconnectEnd: (_evt, edge) => {
if (!get().edgeReconnectSuccessful) {
// delete the edge from the flowState
set({ edges: get().edges.filter((e) => e.id !== edge.id) });
// update node data to reflect the dropped edge
const nodes = get().nodes;
const sourceNode = nodes.find((n) => n.id == edge.source)!;
const targetNode = nodes.find((n) => n.id == edge.target)!;
NodeDs.Sources[sourceNode.type as keyof typeof NodeDs.Sources](sourceNode, edge.target);
NodeDs.Targets[targetNode.type as keyof typeof NodeDs.Targets](targetNode, edge.source);
}
set({ edgeReconnectSuccessful: true });
},
/**
* Deletes a node by ID, respecting NodeDeletes rules.
* Also removes all edges connected to that node.
*/
deleteNode: (nodeId) => {
get().pushSnapshot();
// Let's find our node to check if they have a special deletion function
const ourNode = get().nodes.find((n)=>n.id==nodeId);
const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1]
// If there's no function, OR, our function tells us we can delete it, let's do so...
if (ourFunction == undefined || ourFunction()) {
set({
nodes: get().nodes.filter((n) => n.id !== nodeId),
edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
})}
},
/**
* 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) => {
get().pushSnapshot();
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) => {
get().pushSnapshot();
set({ nodes: [...get().nodes, node] });
},
// undo redo default values
past: [],
future: [],
isBatchAction: false,
// handleRuleRegistry definitions
/**
* stores registered rules for handle connection validation
*/
ruleRegistry: new Map(),
/**
* gets the rules registered by that handle described by the given node and handle ids
*
* @param {string} targetNodeId
* @param {string} targetHandleId
* @returns {HandleRule[]}
*/
getTargetRules: (targetNodeId, targetHandleId) => {
const key = `${targetNodeId}:${targetHandleId}`;
const rules = get().ruleRegistry.get(key);
// helper function that handles a situation where no rules were registered
const missingRulesResponse = () => {
console.warn(
`No rules were registered for the following handle "${key}"!
returning and empty handleRule[] to avoid crashing`);
return []
}
return rules
? rules
: missingRulesResponse()
},
/**
* registers a handle's connection rules
*
* @param {string} nodeId
* @param {string} handleId
* @param {HandleRule[]} rules
*/
registerRules: (nodeId, handleId, rules) => {
const registry = get().ruleRegistry;
registry.set(`${nodeId}:${handleId}`, rules);
set({ ruleRegistry: registry }) ;
},
/**
* unregisters a handles connection rules
*
* @param {string} nodeId
* @param {string} handleId
*/
unregisterHandleRules: (nodeId, handleId) => {
set( () => {
const registry = get().ruleRegistry;
registry.delete(`${nodeId}:${handleId}`);
return { ruleRegistry: registry };
})
},
/**
* unregisters connection rules for all handles on the given node
* used for cleaning up rules on node deletion
*
* @param {string} nodeId
*/
unregisterNodeRules: (nodeId) => {
set(() => {
const registry = get().ruleRegistry;
registry.forEach((_,key) => {
if (key.startsWith(`${nodeId}:`)) registry.delete(key)
})
return { ruleRegistry: registry };
})
{
id: 'end',
type: 'end',
position: {x: 0, y: 300},
data: {label: 'End'}
}
}))
];
/**
* 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',
}
];
/**
* 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
*/
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});
},
}),
);
export default useFlowStore;
export default useFlowStore;

View File

@@ -1,132 +1,46 @@
// VisProgTypes.ts
import type {
Edge,
OnNodesChange,
OnEdgesChange,
OnConnect,
OnReconnect,
Node,
OnEdgesDelete,
OnNodesDelete
import {
type Edge,
type Node,
type OnNodesChange,
type OnEdgesChange,
type OnConnect,
type OnReconnect,
} from '@xyflow/react';
import type {HandleRule} from "./HandleRuleLogic.ts";
import type { NodeTypes } from './NodeRegistry';
import type {FlowSnapshot} from "./EditorUndoRedo.ts";
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'>;
/**
* Type representing all registered node types.
* This corresponds to the keys of NodeTypes in 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
*/
export type AppNode = typeof NodeTypes;
export type AppNode = Node | StartNode | EndNode | NormNode | GoalNode | PhaseNode;
/**
* 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
* The type for the Zustand store object used to manage the state of the ReactFlow editor
*/
export type FlowState = {
nodes: Node[];
nodes: AppNode[];
edges: Edge[];
edgeReconnectSuccessful: boolean;
scrollable: boolean;
/** Handler for managing scrollable state */
setScrollable: (value: boolean) => void;
/** Handler for changes to nodes triggered by ReactFlow */
onNodesChange: OnNodesChange;
onNodesDelete: OnNodesDelete;
onEdgesDelete: OnEdgesDelete;
/** 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: Edge) => void;
/**
* Deletes a node and any connected edges.
* @param nodeId - the ID of the node to delete
*/
onReconnectEnd: (_: unknown, edge: { id: string }) => void;
deleteNode: (nodeId: string) => 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
*/
setNodes: (nodes: AppNode[]) => void;
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;
} & UndoRedoState & HandleRuleRegistry;
export type UndoRedoState = {
// UndoRedo Types
past: FlowSnapshot[];
future: FlowSnapshot[];
pushSnapshot: () => void;
isBatchAction: boolean;
beginBatchAction: () => void;
endBatchAction: () => void;
undo: () => void;
redo: () => void;
}
export type HandleRuleRegistry = {
ruleRegistry: Map<string, HandleRule[]>;
getTargetRules: (
targetNodeId: string,
targetHandleId: string
) => HandleRule[];
registerRules: (
nodeId: string,
handleId: string,
rules: HandleRule[]
) => void;
unregisterHandleRules: (
nodeId: string,
handleId: string
) => void;
// cleans up all registered rules of all handles of the provided node
unregisterNodeRules: (nodeId: string) => void
}
};

View File

@@ -1,117 +1,138 @@
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'
import {Tooltip} from "./NodeComponents.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.tsx";
import styles from "../../VisProg.module.css"
import type {AppNode, PhaseNode, NormNode} from "../VisProgTypes.tsx";
/**
* 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.
* DraggableNodeProps dictates the type properties of a DraggableNode
*/
interface DraggableNodeProps {
className?: string;
children: ReactNode;
nodeType: keyof typeof NodeTypes;
onDrop: (nodeType: keyof typeof NodeTypes, position: XYPosition) => void;
nodeType: string;
onDrop: (nodeType: string, position: XYPosition) => void;
}
/**
* A draggable node element used in the drag-and-drop toolbar.
* 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.
*
* 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.
* @param className
* @param children
* @param nodeType
* @param onDrop
* @constructor
*/
function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeProps) {
function DraggableNode({className, children, nodeType, onDrop}: DraggableNodeProps) {
const draggableRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<XYPosition>({ x: 0, y: 0 });
const [position, setPosition] = useState<XYPosition>({x: 0, y: 0});
// The NeoDrag hook enables smooth drag functionality for this element.
// @ts-expect-error: NeoDrag typing incompatibility — safe to ignore.
// @ts-expect-error comes from a package and doesn't appear to play nicely with strict typescript typing
useDraggable(draggableRef, {
position,
onDrag: ({ offsetX, offsetY }) => {
setPosition({ x: offsetX, y: offsetY });
position: position,
onDrag: ({offsetX, offsetY}) => {
// Calculate position relative to the viewport
setPosition({
x: offsetX,
y: offsetY,
});
},
onDragEnd: ({ event }) => {
setPosition({ x: 0, y: 0 });
onDrop(nodeType, { x: event.clientX, y: event.clientY });
onDragEnd: ({event}) => {
setPosition({x: 0, y: 0});
onDrop(nodeType, {
x: event.clientX,
y: event.clientY,
});
},
});
return (
<Tooltip nodeType={nodeType}>
<div>
<div className={className}
ref={draggableRef}
id={`draggable-${nodeType}`}
data-testid={`draggable-${nodeType}`}
>
{children}
</div>
</div>
</Tooltip>)
<div className={className} ref={draggableRef}>
{children}
</div>
);
}
/**
* 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 addNodeToFlow(nodeType: keyof typeof NodeTypes, position: XYPosition) {
const { addNode } = useFlowStore.getState();
// Load any predefined data for this node type.
const defaultData = NodeDefaults[nodeType] ?? {}
const id = crypto.randomUUID();
// Create new node
const newNode = {
id: id,
type: nodeType,
position,
data: JSON.parse(JSON.stringify(defaultData))
// 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`);
}
}
}
addNode(newNode);
setNodes(nds.concat(newNode()));
}
/**
* 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.
* the DndToolbar defines how the drag and drop toolbar component works
* and includes the default onDrop behavior through handleNodeDrop
* @constructor
*/
export function DndToolbar() {
const { screenToFlowPosition } = useReactFlow();
const {screenToFlowPosition} = useReactFlow();
/**
* Handles dropping a node onto the flow pane.
* Translates screen coordinates into flow coordinates using React Flow utilities.
* handleNodeDrop implements the default onDrop behavior
*/
const handleNodeDrop = useCallback(
(nodeType: keyof typeof NodeTypes, screenPosition: XYPosition) => {
(nodeType: string, 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 &&
@@ -119,41 +140,28 @@ 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);
addNodeToFlow(nodeType, position);
addNode(nodeType, position);
}
},
[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}`} id={"draggable-sidebar"}>
<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}`}>
{/* Maps over all the nodes that are droppable, and puts them in the panel */}
{droppableNodes.map(({type, data}) => (
<DraggableNode
key={type}
className={styles[`draggable-node-${type}`]} // Our current style signature for nodes
nodeType={type}
onDrop={handleNodeDrop}
>
{data.label}
</DraggableNode>
))}
<DraggableNode className={styles.draggableNodePhase} nodeType="phase" onDrop={handleNodeDrop}>
phase Node
</DraggableNode>
<DraggableNode className={styles.draggableNodeNorm} nodeType="norm" onDrop={handleNodeDrop}>
norm Node
</DraggableNode>
</div>
</div>
);
}
}

View File

@@ -1,164 +0,0 @@
.gestureEditor {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.modeSelector {
display: flex;
align-items: center;
gap: 12px;
}
.modeLabel {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
}
.toggleContainer {
display: flex;
background: rgba(78, 78, 78, 0.411);
border-radius: 6px;
padding: 2px;
border: 1px solid var(--border-color);
}
.toggleButton {
padding: 6px 12px;
background: none;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-secondary);
}
.toggleButton:hover {
background: none;
}
.toggleButton.active {
box-shadow: 0 0 1px 0 rgba(9, 255, 0, 0.733);
}
.valueEditor {
width: 100%;
}
.textInput {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 14px;
transition: border-color 0.2s ease;
}
.textInput:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.1);
}
.tagSelector {
display: flex;
flex-direction: column;
gap: 8px;
}
.tagSelect {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 14px;
background-color: rgba(135, 135, 135, 0.296);
cursor: pointer;
}
.tagSelect:focus {
outline: none;
border-color: rgb(0, 149, 25);
}
.tagList {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 120px;
overflow-y: auto;
padding: 4px;
border: 1px solid rgba(var(--primary-rgb), 0.1);
border-radius: 4px;
background: var(--primary-color);
}
.tagButton {
padding: 4px 8px;
border: 1px solid gray;
border-radius: 4px;
background: var(--primary-rgb);
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.tagButton:hover {
background: gray;
border-color: gray;
}
.tagButton.selected {
background: rgba(var(--primary-rgb), 0.5);
color: var(--primary-rgb);
border-color: rgb(27, 223, 60);
}
.suggestionsDropdownLeft {
position: absolute;
left: -220px;
top: 120px;
width: 200px;
max-height: 20vh;
overflow-y: auto;
background: var(--dropdown-menu-background-color);
border-radius: 12px;
box-shadow: 0 8px 24px var(--dropdown-menu-border);
}
.suggestionsDropdownLeft::before {
content: "Gesture Suggestions";
display: block;
padding: 8px 12px;
font-weight: 600;
border-bottom: 1px solid var(--border-light);
}
.suggestionItem {
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s ease;
font-size: 14px;
border-bottom: 1px solid var(--border-light);
}
.suggestionItem:last-child {
border-bottom: none;
}
.suggestionItem:hover {
background-color: var(--background-hover);
}
.suggestionItem:active {
background-color: var(--primary-color-light);
}

View File

@@ -1,611 +0,0 @@
import { useState, useRef } from "react";
import styles from './GestureValueEditor.module.css'
/**
* Props for the GestureValueEditor component.
* - value: current gesture value (controlled by parent)
* - setValue: callback to update the gesture value in parent state
* - placeholder: optional placeholder text for the input field
*/
type GestureValueEditorProps = {
value: string;
setValue: (value: string) => void;
setType: (value: boolean) => void;
placeholder?: string;
};
/**
* List of high-level gesture "tags".
* These are human-readable categories or semantic labels.
* In a real app, these would likely be loaded from an external source.
*/
const GESTURE_TAGS = ["above", "affirmative", "afford", "agitated", "all", "allright", "alright", "any",
"assuage", "attemper", "back", "bashful", "beg", "beseech", "blank",
"body language", "bored", "bow", "but", "call", "calm", "choose", "choice", "cloud",
"cogitate", "cool", "crazy", "disappointed", "down", "earth", "empty", "embarrassed",
"enthusiastic", "entire", "estimate", "except", "exalted", "excited", "explain", "far",
"field", "floor", "forlorn", "friendly", "front", "frustrated", "gentle", "gift",
"give", "ground", "happy", "hello", "her", "here", "hey", "hi", "him", "hopeless",
"hysterical", "I", "implore", "indicate", "joyful", "me", "meditate", "modest",
"negative", "nervous", "no", "not know", "nothing", "offer", "ok", "once upon a time",
"oppose", "or", "pacify", "pick", "placate", "please", "present", "proffer", "quiet",
"reason", "refute", "reject", "rousing", "sad", "select", "shamefaced", "show",
"show sky", "sky", "soothe", "sun", "supplicate", "tablet", "tall", "them", "there",
"think", "timid", "top", "unless", "up", "upstairs", "void", "warm", "winner", "yeah",
"yes", "yoo-hoo", "you", "your", "zero", "zestful"];
/**
* List of concrete gesture animation paths.
* These represent specific animation assets and are used in "single" mode
* with autocomplete-style selection, also would be loaded from an external source.
*/
const GESTURE_SINGLES = [
"animations/Stand/BodyTalk/Listening/Listening_1",
"animations/Stand/BodyTalk/Listening/Listening_2",
"animations/Stand/BodyTalk/Listening/Listening_3",
"animations/Stand/BodyTalk/Listening/Listening_4",
"animations/Stand/BodyTalk/Listening/Listening_5",
"animations/Stand/BodyTalk/Listening/Listening_6",
"animations/Stand/BodyTalk/Listening/Listening_7",
"animations/Stand/BodyTalk/Speaking/BodyTalk_1",
"animations/Stand/BodyTalk/Speaking/BodyTalk_10",
"animations/Stand/BodyTalk/Speaking/BodyTalk_11",
"animations/Stand/BodyTalk/Speaking/BodyTalk_12",
"animations/Stand/BodyTalk/Speaking/BodyTalk_13",
"animations/Stand/BodyTalk/Speaking/BodyTalk_14",
"animations/Stand/BodyTalk/Speaking/BodyTalk_15",
"animations/Stand/BodyTalk/Speaking/BodyTalk_16",
"animations/Stand/BodyTalk/Speaking/BodyTalk_2",
"animations/Stand/BodyTalk/Speaking/BodyTalk_3",
"animations/Stand/BodyTalk/Speaking/BodyTalk_4",
"animations/Stand/BodyTalk/Speaking/BodyTalk_5",
"animations/Stand/BodyTalk/Speaking/BodyTalk_6",
"animations/Stand/BodyTalk/Speaking/BodyTalk_7",
"animations/Stand/BodyTalk/Speaking/BodyTalk_8",
"animations/Stand/BodyTalk/Speaking/BodyTalk_9",
"animations/Stand/BodyTalk/Thinking/Remember_1",
"animations/Stand/BodyTalk/Thinking/Remember_2",
"animations/Stand/BodyTalk/Thinking/Remember_3",
"animations/Stand/BodyTalk/Thinking/ThinkingLoop_1",
"animations/Stand/BodyTalk/Thinking/ThinkingLoop_2",
"animations/Stand/Emotions/Negative/Angry_1",
"animations/Stand/Emotions/Negative/Angry_2",
"animations/Stand/Emotions/Negative/Angry_3",
"animations/Stand/Emotions/Negative/Angry_4",
"animations/Stand/Emotions/Negative/Anxious_1",
"animations/Stand/Emotions/Negative/Bored_1",
"animations/Stand/Emotions/Negative/Bored_2",
"animations/Stand/Emotions/Negative/Disappointed_1",
"animations/Stand/Emotions/Negative/Exhausted_1",
"animations/Stand/Emotions/Negative/Exhausted_2",
"animations/Stand/Emotions/Negative/Fear_1",
"animations/Stand/Emotions/Negative/Fear_2",
"animations/Stand/Emotions/Negative/Fearful_1",
"animations/Stand/Emotions/Negative/Frustrated_1",
"animations/Stand/Emotions/Negative/Humiliated_1",
"animations/Stand/Emotions/Negative/Hurt_1",
"animations/Stand/Emotions/Negative/Hurt_2",
"animations/Stand/Emotions/Negative/Late_1",
"animations/Stand/Emotions/Negative/Sad_1",
"animations/Stand/Emotions/Negative/Sad_2",
"animations/Stand/Emotions/Negative/Shocked_1",
"animations/Stand/Emotions/Negative/Sorry_1",
"animations/Stand/Emotions/Negative/Surprise_1",
"animations/Stand/Emotions/Negative/Surprise_2",
"animations/Stand/Emotions/Negative/Surprise_3",
"animations/Stand/Emotions/Neutral/Alienated_1",
"animations/Stand/Emotions/Neutral/AskForAttention_1",
"animations/Stand/Emotions/Neutral/AskForAttention_2",
"animations/Stand/Emotions/Neutral/AskForAttention_3",
"animations/Stand/Emotions/Neutral/Cautious_1",
"animations/Stand/Emotions/Neutral/Confused_1",
"animations/Stand/Emotions/Neutral/Determined_1",
"animations/Stand/Emotions/Neutral/Embarrassed_1",
"animations/Stand/Emotions/Neutral/Hesitation_1",
"animations/Stand/Emotions/Neutral/Innocent_1",
"animations/Stand/Emotions/Neutral/Lonely_1",
"animations/Stand/Emotions/Neutral/Mischievous_1",
"animations/Stand/Emotions/Neutral/Puzzled_1",
"animations/Stand/Emotions/Neutral/Sneeze",
"animations/Stand/Emotions/Neutral/Stubborn_1",
"animations/Stand/Emotions/Neutral/Suspicious_1",
"animations/Stand/Emotions/Positive/Amused_1",
"animations/Stand/Emotions/Positive/Confident_1",
"animations/Stand/Emotions/Positive/Ecstatic_1",
"animations/Stand/Emotions/Positive/Enthusiastic_1",
"animations/Stand/Emotions/Positive/Excited_1",
"animations/Stand/Emotions/Positive/Excited_2",
"animations/Stand/Emotions/Positive/Excited_3",
"animations/Stand/Emotions/Positive/Happy_1",
"animations/Stand/Emotions/Positive/Happy_2",
"animations/Stand/Emotions/Positive/Happy_3",
"animations/Stand/Emotions/Positive/Happy_4",
"animations/Stand/Emotions/Positive/Hungry_1",
"animations/Stand/Emotions/Positive/Hysterical_1",
"animations/Stand/Emotions/Positive/Interested_1",
"animations/Stand/Emotions/Positive/Interested_2",
"animations/Stand/Emotions/Positive/Laugh_1",
"animations/Stand/Emotions/Positive/Laugh_2",
"animations/Stand/Emotions/Positive/Laugh_3",
"animations/Stand/Emotions/Positive/Mocker_1",
"animations/Stand/Emotions/Positive/Optimistic_1",
"animations/Stand/Emotions/Positive/Peaceful_1",
"animations/Stand/Emotions/Positive/Proud_1",
"animations/Stand/Emotions/Positive/Proud_2",
"animations/Stand/Emotions/Positive/Proud_3",
"animations/Stand/Emotions/Positive/Relieved_1",
"animations/Stand/Emotions/Positive/Shy_1",
"animations/Stand/Emotions/Positive/Shy_2",
"animations/Stand/Emotions/Positive/Sure_1",
"animations/Stand/Emotions/Positive/Winner_1",
"animations/Stand/Emotions/Positive/Winner_2",
"animations/Stand/Gestures/Angry_1",
"animations/Stand/Gestures/Angry_2",
"animations/Stand/Gestures/Angry_3",
"animations/Stand/Gestures/BowShort_1",
"animations/Stand/Gestures/BowShort_2",
"animations/Stand/Gestures/BowShort_3",
"animations/Stand/Gestures/But_1",
"animations/Stand/Gestures/CalmDown_1",
"animations/Stand/Gestures/CalmDown_2",
"animations/Stand/Gestures/CalmDown_3",
"animations/Stand/Gestures/CalmDown_4",
"animations/Stand/Gestures/CalmDown_5",
"animations/Stand/Gestures/CalmDown_6",
"animations/Stand/Gestures/Choice_1",
"animations/Stand/Gestures/ComeOn_1",
"animations/Stand/Gestures/Confused_1",
"animations/Stand/Gestures/Confused_2",
"animations/Stand/Gestures/CountFive_1",
"animations/Stand/Gestures/CountFour_1",
"animations/Stand/Gestures/CountMore_1",
"animations/Stand/Gestures/CountOne_1",
"animations/Stand/Gestures/CountThree_1",
"animations/Stand/Gestures/CountTwo_1",
"animations/Stand/Gestures/Desperate_1",
"animations/Stand/Gestures/Desperate_2",
"animations/Stand/Gestures/Desperate_3",
"animations/Stand/Gestures/Desperate_4",
"animations/Stand/Gestures/Desperate_5",
"animations/Stand/Gestures/DontUnderstand_1",
"animations/Stand/Gestures/Enthusiastic_3",
"animations/Stand/Gestures/Enthusiastic_4",
"animations/Stand/Gestures/Enthusiastic_5",
"animations/Stand/Gestures/Everything_1",
"animations/Stand/Gestures/Everything_2",
"animations/Stand/Gestures/Everything_3",
"animations/Stand/Gestures/Everything_4",
"animations/Stand/Gestures/Everything_6",
"animations/Stand/Gestures/Excited_1",
"animations/Stand/Gestures/Explain_1",
"animations/Stand/Gestures/Explain_10",
"animations/Stand/Gestures/Explain_11",
"animations/Stand/Gestures/Explain_2",
"animations/Stand/Gestures/Explain_3",
"animations/Stand/Gestures/Explain_4",
"animations/Stand/Gestures/Explain_5",
"animations/Stand/Gestures/Explain_6",
"animations/Stand/Gestures/Explain_7",
"animations/Stand/Gestures/Explain_8",
"animations/Stand/Gestures/Far_1",
"animations/Stand/Gestures/Far_2",
"animations/Stand/Gestures/Far_3",
"animations/Stand/Gestures/Follow_1",
"animations/Stand/Gestures/Give_1",
"animations/Stand/Gestures/Give_2",
"animations/Stand/Gestures/Give_3",
"animations/Stand/Gestures/Give_4",
"animations/Stand/Gestures/Give_5",
"animations/Stand/Gestures/Give_6",
"animations/Stand/Gestures/Great_1",
"animations/Stand/Gestures/HeSays_1",
"animations/Stand/Gestures/HeSays_2",
"animations/Stand/Gestures/HeSays_3",
"animations/Stand/Gestures/Hey_1",
"animations/Stand/Gestures/Hey_10",
"animations/Stand/Gestures/Hey_2",
"animations/Stand/Gestures/Hey_3",
"animations/Stand/Gestures/Hey_4",
"animations/Stand/Gestures/Hey_6",
"animations/Stand/Gestures/Hey_7",
"animations/Stand/Gestures/Hey_8",
"animations/Stand/Gestures/Hey_9",
"animations/Stand/Gestures/Hide_1",
"animations/Stand/Gestures/Hot_1",
"animations/Stand/Gestures/Hot_2",
"animations/Stand/Gestures/IDontKnow_1",
"animations/Stand/Gestures/IDontKnow_2",
"animations/Stand/Gestures/IDontKnow_3",
"animations/Stand/Gestures/IDontKnow_4",
"animations/Stand/Gestures/IDontKnow_5",
"animations/Stand/Gestures/IDontKnow_6",
"animations/Stand/Gestures/Joy_1",
"animations/Stand/Gestures/Kisses_1",
"animations/Stand/Gestures/Look_1",
"animations/Stand/Gestures/Look_2",
"animations/Stand/Gestures/Maybe_1",
"animations/Stand/Gestures/Me_1",
"animations/Stand/Gestures/Me_2",
"animations/Stand/Gestures/Me_4",
"animations/Stand/Gestures/Me_7",
"animations/Stand/Gestures/Me_8",
"animations/Stand/Gestures/Mime_1",
"animations/Stand/Gestures/Mime_2",
"animations/Stand/Gestures/Next_1",
"animations/Stand/Gestures/No_1",
"animations/Stand/Gestures/No_2",
"animations/Stand/Gestures/No_3",
"animations/Stand/Gestures/No_4",
"animations/Stand/Gestures/No_5",
"animations/Stand/Gestures/No_6",
"animations/Stand/Gestures/No_7",
"animations/Stand/Gestures/No_8",
"animations/Stand/Gestures/No_9",
"animations/Stand/Gestures/Nothing_1",
"animations/Stand/Gestures/Nothing_2",
"animations/Stand/Gestures/OnTheEvening_1",
"animations/Stand/Gestures/OnTheEvening_2",
"animations/Stand/Gestures/OnTheEvening_3",
"animations/Stand/Gestures/OnTheEvening_4",
"animations/Stand/Gestures/OnTheEvening_5",
"animations/Stand/Gestures/Please_1",
"animations/Stand/Gestures/Please_2",
"animations/Stand/Gestures/Please_3",
"animations/Stand/Gestures/Reject_1",
"animations/Stand/Gestures/Reject_2",
"animations/Stand/Gestures/Reject_3",
"animations/Stand/Gestures/Reject_4",
"animations/Stand/Gestures/Reject_5",
"animations/Stand/Gestures/Reject_6",
"animations/Stand/Gestures/Salute_1",
"animations/Stand/Gestures/Salute_2",
"animations/Stand/Gestures/Salute_3",
"animations/Stand/Gestures/ShowFloor_1",
"animations/Stand/Gestures/ShowFloor_2",
"animations/Stand/Gestures/ShowFloor_3",
"animations/Stand/Gestures/ShowFloor_4",
"animations/Stand/Gestures/ShowFloor_5",
"animations/Stand/Gestures/ShowSky_1",
"animations/Stand/Gestures/ShowSky_10",
"animations/Stand/Gestures/ShowSky_11",
"animations/Stand/Gestures/ShowSky_12",
"animations/Stand/Gestures/ShowSky_2",
"animations/Stand/Gestures/ShowSky_3",
"animations/Stand/Gestures/ShowSky_4",
"animations/Stand/Gestures/ShowSky_5",
"animations/Stand/Gestures/ShowSky_6",
"animations/Stand/Gestures/ShowSky_7",
"animations/Stand/Gestures/ShowSky_8",
"animations/Stand/Gestures/ShowSky_9",
"animations/Stand/Gestures/ShowTablet_1",
"animations/Stand/Gestures/ShowTablet_2",
"animations/Stand/Gestures/ShowTablet_3",
"animations/Stand/Gestures/Shy_1",
"animations/Stand/Gestures/Stretch_1",
"animations/Stand/Gestures/Stretch_2",
"animations/Stand/Gestures/Surprised_1",
"animations/Stand/Gestures/TakePlace_1",
"animations/Stand/Gestures/TakePlace_2",
"animations/Stand/Gestures/Take_1",
"animations/Stand/Gestures/Thinking_1",
"animations/Stand/Gestures/Thinking_2",
"animations/Stand/Gestures/Thinking_3",
"animations/Stand/Gestures/Thinking_4",
"animations/Stand/Gestures/Thinking_5",
"animations/Stand/Gestures/Thinking_6",
"animations/Stand/Gestures/Thinking_7",
"animations/Stand/Gestures/Thinking_8",
"animations/Stand/Gestures/This_1",
"animations/Stand/Gestures/This_10",
"animations/Stand/Gestures/This_11",
"animations/Stand/Gestures/This_12",
"animations/Stand/Gestures/This_13",
"animations/Stand/Gestures/This_14",
"animations/Stand/Gestures/This_15",
"animations/Stand/Gestures/This_2",
"animations/Stand/Gestures/This_3",
"animations/Stand/Gestures/This_4",
"animations/Stand/Gestures/This_5",
"animations/Stand/Gestures/This_6",
"animations/Stand/Gestures/This_7",
"animations/Stand/Gestures/This_8",
"animations/Stand/Gestures/This_9",
"animations/Stand/Gestures/WhatSThis_1",
"animations/Stand/Gestures/WhatSThis_10",
"animations/Stand/Gestures/WhatSThis_11",
"animations/Stand/Gestures/WhatSThis_12",
"animations/Stand/Gestures/WhatSThis_13",
"animations/Stand/Gestures/WhatSThis_14",
"animations/Stand/Gestures/WhatSThis_15",
"animations/Stand/Gestures/WhatSThis_16",
"animations/Stand/Gestures/WhatSThis_2",
"animations/Stand/Gestures/WhatSThis_3",
"animations/Stand/Gestures/WhatSThis_4",
"animations/Stand/Gestures/WhatSThis_5",
"animations/Stand/Gestures/WhatSThis_6",
"animations/Stand/Gestures/WhatSThis_7",
"animations/Stand/Gestures/WhatSThis_8",
"animations/Stand/Gestures/WhatSThis_9",
"animations/Stand/Gestures/Whisper_1",
"animations/Stand/Gestures/Wings_1",
"animations/Stand/Gestures/Wings_2",
"animations/Stand/Gestures/Wings_3",
"animations/Stand/Gestures/Wings_4",
"animations/Stand/Gestures/Wings_5",
"animations/Stand/Gestures/Yes_1",
"animations/Stand/Gestures/Yes_2",
"animations/Stand/Gestures/Yes_3",
"animations/Stand/Gestures/YouKnowWhat_1",
"animations/Stand/Gestures/YouKnowWhat_2",
"animations/Stand/Gestures/YouKnowWhat_3",
"animations/Stand/Gestures/YouKnowWhat_4",
"animations/Stand/Gestures/YouKnowWhat_5",
"animations/Stand/Gestures/YouKnowWhat_6",
"animations/Stand/Gestures/You_1",
"animations/Stand/Gestures/You_2",
"animations/Stand/Gestures/You_3",
"animations/Stand/Gestures/You_4",
"animations/Stand/Gestures/You_5",
"animations/Stand/Gestures/Yum_1",
"animations/Stand/Reactions/EthernetOff_1",
"animations/Stand/Reactions/EthernetOn_1",
"animations/Stand/Reactions/Heat_1",
"animations/Stand/Reactions/Heat_2",
"animations/Stand/Reactions/LightShine_1",
"animations/Stand/Reactions/LightShine_2",
"animations/Stand/Reactions/LightShine_3",
"animations/Stand/Reactions/LightShine_4",
"animations/Stand/Reactions/SeeColor_1",
"animations/Stand/Reactions/SeeColor_2",
"animations/Stand/Reactions/SeeColor_3",
"animations/Stand/Reactions/SeeSomething_1",
"animations/Stand/Reactions/SeeSomething_3",
"animations/Stand/Reactions/SeeSomething_4",
"animations/Stand/Reactions/SeeSomething_5",
"animations/Stand/Reactions/SeeSomething_6",
"animations/Stand/Reactions/SeeSomething_7",
"animations/Stand/Reactions/SeeSomething_8",
"animations/Stand/Reactions/ShakeBody_1",
"animations/Stand/Reactions/ShakeBody_2",
"animations/Stand/Reactions/ShakeBody_3",
"animations/Stand/Reactions/TouchHead_1",
"animations/Stand/Reactions/TouchHead_2",
"animations/Stand/Reactions/TouchHead_3",
"animations/Stand/Reactions/TouchHead_4",
"animations/Stand/Waiting/AirGuitar_1",
"animations/Stand/Waiting/BackRubs_1",
"animations/Stand/Waiting/Bandmaster_1",
"animations/Stand/Waiting/Binoculars_1",
"animations/Stand/Waiting/BreathLoop_1",
"animations/Stand/Waiting/BreathLoop_2",
"animations/Stand/Waiting/BreathLoop_3",
"animations/Stand/Waiting/CallSomeone_1",
"animations/Stand/Waiting/Drink_1",
"animations/Stand/Waiting/DriveCar_1",
"animations/Stand/Waiting/Fitness_1",
"animations/Stand/Waiting/Fitness_2",
"animations/Stand/Waiting/Fitness_3",
"animations/Stand/Waiting/FunnyDancer_1",
"animations/Stand/Waiting/HappyBirthday_1",
"animations/Stand/Waiting/Helicopter_1",
"animations/Stand/Waiting/HideEyes_1",
"animations/Stand/Waiting/HideHands_1",
"animations/Stand/Waiting/Innocent_1",
"animations/Stand/Waiting/Knight_1",
"animations/Stand/Waiting/KnockEye_1",
"animations/Stand/Waiting/KungFu_1",
"animations/Stand/Waiting/LookHand_1",
"animations/Stand/Waiting/LookHand_2",
"animations/Stand/Waiting/LoveYou_1",
"animations/Stand/Waiting/Monster_1",
"animations/Stand/Waiting/MysticalPower_1",
"animations/Stand/Waiting/PlayHands_1",
"animations/Stand/Waiting/PlayHands_2",
"animations/Stand/Waiting/PlayHands_3",
"animations/Stand/Waiting/Relaxation_1",
"animations/Stand/Waiting/Relaxation_2",
"animations/Stand/Waiting/Relaxation_3",
"animations/Stand/Waiting/Relaxation_4",
"animations/Stand/Waiting/Rest_1",
"animations/Stand/Waiting/Robot_1",
"animations/Stand/Waiting/ScratchBack_1",
"animations/Stand/Waiting/ScratchBottom_1",
"animations/Stand/Waiting/ScratchEye_1",
"animations/Stand/Waiting/ScratchHand_1",
"animations/Stand/Waiting/ScratchHead_1",
"animations/Stand/Waiting/ScratchLeg_1",
"animations/Stand/Waiting/ScratchTorso_1",
"animations/Stand/Waiting/ShowMuscles_1",
"animations/Stand/Waiting/ShowMuscles_2",
"animations/Stand/Waiting/ShowMuscles_3",
"animations/Stand/Waiting/ShowMuscles_4",
"animations/Stand/Waiting/ShowMuscles_5",
"animations/Stand/Waiting/ShowSky_1",
"animations/Stand/Waiting/ShowSky_2",
"animations/Stand/Waiting/SpaceShuttle_1",
"animations/Stand/Waiting/Stretch_1",
"animations/Stand/Waiting/Stretch_2",
"animations/Stand/Waiting/TakePicture_1",
"animations/Stand/Waiting/Taxi_1",
"animations/Stand/Waiting/Think_1",
"animations/Stand/Waiting/Think_2",
"animations/Stand/Waiting/Think_3",
"animations/Stand/Waiting/Think_4",
"animations/Stand/Waiting/Waddle_1",
"animations/Stand/Waiting/Waddle_2",
"animations/Stand/Waiting/WakeUp_1",
"animations/Stand/Waiting/Zombie_1"]
/**
* Returns a gesture value editor component.
* @returns JSX.Element
*/
export default function GestureValueEditor({
value,
setValue,
setType,
placeholder = "Gesture name",
}: GestureValueEditorProps) {
/** Input mode: semantic tag vs concrete animation path */
const [mode, setMode] = useState<"single" | "tag">("tag");
/** Raw text value for single-gesture input */
const [customValue, setCustomValue] = useState("");
/** Autocomplete dropdown state */
const [showSuggestions, setShowSuggestions] = useState(true);
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
/** Reserved for future click-outside / positioning logic */
const containerRef = useRef<HTMLDivElement>(null);
/** Switch between tag and single input modes */
const handleModeChange = (newMode: "single" | "tag") => {
setMode(newMode);
if (newMode === "single") {
setValue(customValue || value);
setType(false);
setFilteredSuggestions(GESTURE_SINGLES);
setShowSuggestions(true);
} else {
// Clear value if it does not match a valid tag
setType(true);
const isValidTag = GESTURE_TAGS.some(
tag => tag.toLowerCase() === value.toLowerCase()
);
if (!isValidTag) setValue("");
setShowSuggestions(false);
}
};
/** Select a semantic gesture tag */
const handleTagSelect = (tag: string) => {
setValue(tag);
};
/** Update single-gesture input and filter suggestions */
const handleCustomChange = (newValue: string) => {
setCustomValue(newValue);
setValue(newValue);
if (newValue.trim() === "") {
setFilteredSuggestions(GESTURE_SINGLES);
setShowSuggestions(true);
} else {
const filtered = GESTURE_SINGLES.filter(single =>
single.toLowerCase().includes(newValue.toLowerCase())
);
setFilteredSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
}
};
/** Commit autocomplete selection */
const handleSuggestionSelect = (suggestion: string) => {
setCustomValue(suggestion);
setValue(suggestion);
setShowSuggestions(false);
};
/** Refresh suggestions on refocus */
const handleInputFocus = () => {
if (!customValue.trim()) return;
const filtered = GESTURE_SINGLES.filter(single =>
single.toLowerCase().includes(customValue.toLowerCase())
);
setFilteredSuggestions(filtered);
setShowSuggestions(filtered.length > 0);
};
/** Exists to allow delayed blur handling if needed */
const handleInputBlur = (_e: React.FocusEvent) => {};
/** Build the JSX component */
return (
<div className={styles.gestureEditor} ref={containerRef}>
{/* Mode toggle */}
<div className={styles.modeSelector}>
<label className={styles.modeLabel}>Input Mode:</label>
<div className={styles.toggleContainer}>
<button
type="button"
className={`${styles.toggleButton} ${mode === "single" ? styles.active : ""}`}
onClick={() => handleModeChange("single")}
>
Single
</button>
<button
type="button"
className={`${styles.toggleButton} ${mode === "tag" ? styles.active : ""}`}
onClick={() => handleModeChange("tag")}
>
Tag
</button>
</div>
</div>
<div className={styles.valueEditor} data-testid={"valueEditorTestID"}>
{mode === "single" ? (
<div className={styles.autocompleteContainer}>
{showSuggestions && (
<div className={styles.suggestionsDropdownLeft}>
{filteredSuggestions.map((suggestion) => (
<div
key={suggestion}
className={styles.suggestionItem}
onClick={() => handleSuggestionSelect(suggestion)}
onMouseDown={(e) => e.preventDefault()} // prevent blur before click
>
{suggestion}
</div>
))}
</div>
)}
<input
type="text"
value={customValue}
onChange={(e) => handleCustomChange(e.target.value)}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
placeholder={placeholder}
className={`${styles.textInput} ${showSuggestions ? styles.textInputWithSuggestions : ''}`}
autoComplete="off"
/>
</div>
) : (
<div className={styles.tagSelector}>
<select
value={value}
onChange={(e) => handleTagSelect(e.target.value)}
className={styles.tagSelect}
data-testid={"tagSelectorTestID"}
>
<option value="" >Select a gesture tag...</option>
{GESTURE_TAGS.map((tag) => (
<option key={tag} value={tag}>{tag}</option>
))}
</select>
<div className={styles.tagList}>
{GESTURE_TAGS.map((tag) => (
<button
key={tag}
type="button"
className={`${styles.tagButton} ${value === tag ? styles.selected : ""}`}
onClick={() => handleTagSelect(tag)}
>
{tag}
</button>
))}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,122 +0,0 @@
import {NodeToolbar} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {type JSX, useState} from "react";
import {createPortal} from "react-dom";
import styles from "../../VisProg.module.css";
import {NodeTooltips} from "../NodeRegistry.ts";
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 {nodes, deleteNode} = useFlowStore();
const deleteParentNode = () => {
deleteNode(nodeId);
};
const nodeType = nodes.find((node) => node.id === nodeId)?.type as keyof typeof NodeTooltips;
return (
<NodeToolbar className={"flex-row align-center"}>
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
<Tooltip nodeType={nodeType}>
<div className={styles.nodeToolbarTooltip}>i</div>
</Tooltip>
</NodeToolbar>);
}
type TooltipProps = {
nodeType?: keyof typeof NodeTooltips;
children: JSX.Element;
};
/**
* A general tooltip component, that can be used as a wrapper for any component
* that has a nodeType and a corresponding nodeTooltip.
*
* currently used to show tooltips for draggable-nodes and nodes inside the editor
*
* @param {"start" | "end" | "phase" | "norm" | "goal" | "trigger" | "basic_belief" | undefined} nodeType
* @param {React.JSX.Element} children
* @returns {React.JSX.Element}
* @constructor
*/
export function Tooltip({ nodeType, children }: TooltipProps) {
const [showTooltip, setShowTooltip] = useState(false);
const [disabled , setDisabled] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const updateTooltipPos = () => {
const rect = document.getElementById("draggable-sidebar")!.getBoundingClientRect();
setCoords({
// Position exactly below the bottom edge of the draggable sidebar (plus a small gap)
top: rect.bottom + 10,
left: rect.left + rect.width / 2, // Keep it horizontally centered
});
};
return nodeType ?
(<div>
<div
onMouseDown={() => {
updateTooltipPos();
setShowTooltip(false);
setDisabled(true);
}}
onMouseUp={() => {
setDisabled(false);
}}
onMouseOver={() => {
if (!disabled) {
updateTooltipPos();
setShowTooltip(true);
}
}}
onMouseLeave={ () => setShowTooltip(false)}
>
{children}
</div>
{showTooltip && createPortal(
<div
className={"flex-row"}
style={{
pointerEvents: 'none',
position: 'fixed',
top: `${coords.top}px`,
left: `${coords.left}px`,
transform: 'translateX(-50%)', // Center based on the midpoint
}}
>
<span className={styles.customTooltipHeader}>{nodeType}</span>
<span className={styles.customTooltip}>
{NodeTooltips[nodeType] || "Available for drag"}
</span>
</div>,
document.body
)}
</div>
) : children
}

View File

@@ -0,0 +1,128 @@
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

@@ -1,7 +0,0 @@
import type { Plan, PlanElement } from "./Plan";
export const defaultPlan: Plan = {
name: "Default Plan",
id: "-1",
steps: [] as PlanElement[],
}

View File

@@ -1,124 +0,0 @@
import { type Node } from "@xyflow/react"
import { GoalReduce } from "../nodes/GoalNode"
export type Plan = {
name: string,
id: string,
steps: PlanElement[],
}
export type PlanElement = Goal | Action
export type Goal = {
id: string // we let the reducer figure out the rest dynamically
type: "goal"
}
// Actions
export type Action = SpeechAction | GestureAction | LLMAction
export type SpeechAction = { id: string, text: string, type:"speech" }
export type GestureAction = { id: string, gesture: string, isTag: boolean, type:"gesture" }
export type LLMAction = { id: string, goal: string, type:"llm" }
export type ActionTypes = "speech" | "gesture" | "llm";
// Extract the wanted information from a plan within the reducing of nodes
export function PlanReduce(_nodes: Node[], plan?: Plan, ) {
if (!plan) return ""
return {
id: plan.id,
steps: plan.steps.map((x) => StepReduce(x, _nodes))
}
}
// Extract the wanted information from a plan element.
function StepReduce(planElement: PlanElement, _nodes: Node[]) : Record<string, unknown> {
// We have different types of plan elements, requiring differnt types of output
const nodes = _nodes
const thisNode = _nodes.find((x) => x.id === planElement.id)
switch (planElement.type) {
case ("speech"):
return {
id: planElement.id,
text: planElement.text,
}
case ("gesture"):
return {
id: planElement.id,
gesture: {
type: planElement.isTag ? "tag" : "single",
name: planElement.gesture
},
}
case ("llm"):
return {
id: planElement.id,
goal: planElement.goal,
}
case ("goal"):
return thisNode ? GoalReduce(thisNode, nodes) : {}
}
}
/**
* Finds out whether the plan can iterate multiple times, or always stops after one action.
* This comes down to checking if the plan only has speech/ gesture actions, or others as well.
* @param plan: the plan to check
* @returns: a boolean
*/
export function DoesPlanIterate( _nodes: Node[], plan?: Plan,) : boolean {
// TODO: should recursively check plans that have goals (and thus more plans) in them.
if (!plan) return false
return plan.steps.filter((step) => step.type == "llm").length > 0 ||
(
// Find the goal node of this step
plan.steps.filter((step) => step.type == "goal").map((goalStep) => {
const goalId = goalStep.id;
const goalNode = _nodes.find((x) => x.id === goalId);
// In case we don't find any valid plan, this node doesn't iterate
if (!goalNode || !goalNode.data.plan) return false;
// Otherwise, check if this node can fail - if so, we should have the option to iterate
return (goalNode && goalNode.data.plan && goalNode.data.can_fail)
})
).includes(true);
}
/**
* Checks if any of the plan's goal steps has its can_fail value set to true.
* @param plan: plan to check
* @param _nodes: nodes in flow store.
*/
export function HasCheckingSubGoal(plan: Plan, _nodes: Node[]) {
const goalSteps = plan.steps.filter((x) => x.type == "goal");
return goalSteps.map((goalStep) => {
// Find the goal node and check its can_fail data boolean.
const goalId = goalStep.id;
const goalNode = _nodes.find((x) => x.id === goalId);
return (goalNode && goalNode.data.can_fail)
}).includes(true);
}
/**
* Returns the value of the action.
* Since typescript can't polymorphicly access the value field,
* we need to switch over the types and return the correct field.
* @param action: action to retrieve the value from
* @returns string | undefined
*/
export function GetActionValue(action: Action) {
let returnAction;
switch (action.type) {
case "gesture":
returnAction = action as GestureAction
return returnAction.gesture;
case "speech":
returnAction = action as SpeechAction
return returnAction.text;
case "llm":
returnAction = action as LLMAction
return returnAction.goal;
default:
}
}

View File

@@ -1,35 +0,0 @@
// This file is to avoid sharing both functions and components which eslint dislikes. :)
import type { GoalNode } from "../nodes/GoalNode"
import type { Goal, Plan } from "./Plan"
/**
* Inserts a goal into a plan
* @param plan: plan to insert goal into
* @param goalNode: the goal node to insert into the plan.
* @returns: a new plan with the goal inside.
*/
export function insertGoalInPlan(plan: Plan, goalNode: GoalNode): Plan {
const planElement : Goal = {
id: goalNode.id,
type: "goal",
}
return {
...plan,
steps: [...plan.steps, planElement],
}
}
/**
* Deletes a goal from a plan
* @param plan: plan to delete goal from
* @param goalID: the goal node to delete.
* @returns: a new plan with the goal removed.
*/
export function deleteGoalInPlanByID(plan: Plan, goalID: string) {
const updatedPlan = {...plan,
steps: plan.steps.filter((x) => x.id !== goalID)
}
return updatedPlan.steps.length == 0 ? undefined : updatedPlan
}

View File

@@ -1,71 +0,0 @@
.planDialog {
overflow:visible;
width: 80vw;
max-width: 900px;
transition: width 0.25s ease;
overscroll-behavior: contain;
}
.planDialog::backdrop {
background: rgba(0, 0, 0, 0.4);
}
.planEditor {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
min-width: 600px;
}
.planEditorLeft {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.planEditorRight {
display: flex;
flex-direction: column;
gap: 0.5rem;
border-left: 1px solid var(--border-color, #ccc);
padding-left: 1rem;
max-height: 300px;
overflow-y: auto;
}
.planStep {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
transition: text-decoration 0.2s;
}
.planStep:hover {
text-decoration: line-through;
}
.stepType {
opacity: 0.7;
font-size: 0.85em;
}
.stepIndex {
opacity: 0.6;
}
.emptySteps {
opacity: 0.5;
font-style: italic;
}
.stepSuggestion {
opacity: 0.5;
font-style: italic;
}

View File

@@ -1,250 +0,0 @@
import {useRef, useState} from "react";
import useFlowStore from "../VisProgStores.tsx";
import styles from './PlanEditor.module.css';
import { GetActionValue, type Action, type ActionTypes, type Plan } from "../components/Plan";
import { defaultPlan } from "../components/Plan.default";
import { TextField } from "../../../../components/TextField";
import GestureValueEditor from "./GestureValueEditor";
type PlanEditorDialogProps = {
plan?: Plan;
onSave: (plan: Plan | undefined) => void;
description? : string;
};
export default function PlanEditorDialog({
plan,
onSave,
description,
}: PlanEditorDialogProps) {
// UseStates and references
const dialogRef = useRef<HTMLDialogElement | null>(null);
const [draftPlan, setDraftPlan] = useState<Plan | null>(null);
const [newActionType, setNewActionType] = useState<ActionTypes>("speech");
const [newActionGestureType, setNewActionGestureType] = useState<boolean>(true);
const [newActionValue, setNewActionValue] = useState("");
const [hasInteractedWithPlan, setHasInteractedWithPlan] = useState<boolean>(false)
const { setScrollable } = useFlowStore();
const nodes = useFlowStore().nodes;
//Button Actions
const openCreate = () => {
setScrollable(false);
setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()});
dialogRef.current?.showModal();
};
const openCreateWithDescription = () => {
setScrollable(false);
setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID(), name: description!});
setNewActionType("llm")
setNewActionValue(description!)
dialogRef.current?.showModal();
}
const openEdit = () => {
setScrollable(false);
if (!plan) return;
setDraftPlan(structuredClone(plan));
dialogRef.current?.showModal();
};
const close = () => {
setScrollable(true);
dialogRef.current?.close();
setDraftPlan(null);
};
const buildAction = (): Action => {
const id = crypto.randomUUID();
setHasInteractedWithPlan(true)
switch (newActionType) {
case "speech":
return { id, text: newActionValue, type: "speech" };
case "gesture":
return { id, gesture: newActionValue, isTag: newActionGestureType, type: "gesture" };
case "llm":
return { id, goal: newActionValue, type: "llm" };
}
};
return (<>
{/* Create and edit buttons */}
{!plan && (
<button className={styles.nodeButton} onClick={description ? openCreateWithDescription : openCreate}>
Create Plan
</button>
)}
{plan && (
<button className={styles.nodeButton} onClick={openEdit}>
Edit Plan
</button>
)}
{/* Start of dialog (plan editor) */}
<dialog
ref={dialogRef}
className={`${styles.planDialog}`}
//onWheel={(e) => e.stopPropagation()}
data-testid={"PlanEditorDialogTestID"}
>
<form method="dialog" className="flex-col gap-md">
<h3> {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"} </h3>
{/* Plan name text field */}
{draftPlan && (
<TextField
value={draftPlan.name}
setValue={(name) =>
setDraftPlan({ ...draftPlan, name })}
placeholder="Plan name"
data-testid="name_text_field"/>
)}
{/* Entire "bottom" part (adder and steps) without cancel, confirm and reset */}
{draftPlan && (<div className={styles.planEditor}>
<div className={styles.planEditorLeft}>
{/* Left Side (Action Adder) */}
<h4>Add Action</h4>
{(!plan && description && draftPlan.steps.length === 0 && !hasInteractedWithPlan) && (<div className={styles.stepSuggestion}>
<label> Filled in as a suggestion! </label>
<label> Feel free to change! </label>
</div>)}
<label>
Action Type <wbr />
{/* Type selection */}
<select
value={newActionType}
onChange={(e) => {
setNewActionType(e.target.value as ActionTypes);
// Reset value when action type changes
setNewActionValue("");
}}>
<option value="speech">Speech Action</option>
<option value="gesture">Gesture Action</option>
<option value="llm">LLM Action</option>
</select>
</label>
{/* Action value editor*/}
{newActionType === "gesture" ? (
// Gesture get their own editor component
<GestureValueEditor
value={newActionValue}
setValue={setNewActionValue}
setType={setNewActionGestureType}
placeholder="Gesture name"
/>
) : (
<TextField
value={newActionValue}
setValue={setNewActionValue}
placeholder={
newActionType === "speech" ? "Speech text"
: "LLM goal"
}
/>
)}
{/* Adding steps */}
<button
type="button"
disabled={!newActionValue}
onClick={() => {
if (!draftPlan) return;
// Add action to steps
const action = buildAction();
setDraftPlan({
...draftPlan,
steps: [...draftPlan.steps, action],});
// Reset current action building
setNewActionValue("");
setNewActionType("speech");
}}>
Add Step
</button>
</div>
{/* Right Side (Steps shown) */}
<div className={styles.planEditorRight}>
<h4>Steps</h4>
{/* Show if there are no steps yet */}
{draftPlan.steps.length === 0 && (
<div className={styles.emptySteps}>
No steps yet
</div>
)}
{/* Map over all steps */}
{draftPlan.steps.map((step, index) => (
<div
role="button"
tabIndex={0}
key={step.id}
className={styles.planStep}
// Extra logic for screen readers to access using keyboard
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setDraftPlan({
...draftPlan,
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
}}}
onClick={() => {
setDraftPlan({
...draftPlan,
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
}}>
<span className={styles.stepIndex}>{index + 1}.</span>
<span className={styles.stepType}>{step.type}:</span>
<span className={styles.stepName}>
{
// This just tries to find the goals name, i know it looks ugly:(
step.type === "goal"
? ((nodes.find(x => x.id === step.id)?.data.name as string) == "" ?
"unnamed goal": (nodes.find(x => x.id === step.id)?.data.name as string))
: (GetActionValue(step) ?? "")}
</span>
</div>
))}
</div>
</div>
)}
{/* Buttons */}
<div className="flex-row gap-md">
{/* Close button */}
<button type="button" onClick={close}>
Cancel
</button>
{/* Confirm/ Create button */}
<button
type="button"
disabled={!draftPlan}
onClick={() => {
if (!draftPlan) return;
onSave(draftPlan);
close();
}}>
{draftPlan?.id === plan?.id ? "Confirm" : "Create"}
</button>
{/* Reset button */}
<button
type="button"
disabled={!draftPlan}
onClick={() => {
onSave(undefined);
close();
}}>
Reset
</button>
</div>
</form>
</dialog>
</>
);
}

View File

@@ -1,43 +0,0 @@
:global(.react-flow__handle.connected) {
background: lightgray;
border-color: green;
filter: drop-shadow(0 0 0.25rem green);
}
:global(.singleConnectionHandle.connected) {
background: #55dd99;
}
:global(.react-flow__handle.unconnected){
background: lightgray;
border-color: gray;
}
:global(.singleConnectionHandle.unconnected){
background: lightsalmon;
border-color: #ff6060;
filter: drop-shadow(0 0 0.25rem #ff6060);
}
:global(.react-flow__handle.connectingto) {
background: #ff6060;
border-color: coral;
filter: drop-shadow(0 0 0.25rem coral);
}
:global(.react-flow__handle.valid) {
background: #55dd99;
border-color: green;
filter: drop-shadow(0 0 0.25rem green);
}
:global(.react-flow__handle) {
width: calc(8px / var(--flow-zoom, 1));
height: calc(8px / var(--flow-zoom, 1));
transition: width 0.1s ease, height 0.1s ease;
min-width: 8px;
min-height: 8px;
}

View File

@@ -1,88 +0,0 @@
import {
Handle,
type HandleProps,
type Connection,
useNodeId, useNodeConnections
} from '@xyflow/react';
import {useState} from 'react';
import { type HandleRule, useHandleRules} from "../HandleRuleLogic.ts";
import "./RuleBasedHandle.module.css";
export function MultiConnectionHandle({
id,
type,
rules = [],
...otherProps
} : HandleProps & { rules?: HandleRule[]}) {
let nodeId = useNodeId();
// this check is used to make sure that the handle code doesn't break when used inside a test,
// since useNodeId would be undefined if the handle is not used inside a node
nodeId = nodeId ? nodeId : "mockId";
const validate = useHandleRules(nodeId, id!, type!, rules);
const connections = useNodeConnections({
id: nodeId,
handleType: type,
handleId: id!
})
// initialise the handles state with { isValid: true } to show that connections are possible
const [handleState, setHandleState] = useState<{ isSatisfied: boolean, message?: string }>({ isSatisfied: true });
return (
<Handle
{...otherProps}
id={id}
type={type}
className={"multiConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected")}
isValidConnection={(connection) => {
const result = validate(connection as Connection);
setHandleState(result);
return result.isSatisfied;
}}
title={handleState.message}
/>
);
}
export function SingleConnectionHandle({
id,
type,
rules = [],
...otherProps
} : HandleProps & { rules?: HandleRule[]}) {
let nodeId = useNodeId();
// this check is used to make sure that the handle code doesn't break when used inside a test,
// since useNodeId would be undefined if the handle is not used inside a node
nodeId = nodeId ? nodeId : "mockId";
const validate = useHandleRules(nodeId, id!, type!, rules);
const connections = useNodeConnections({
id: nodeId,
handleType: type,
handleId: id!
})
// initialise the handles state with { isValid: true } to show that connections are possible
const [handleState, setHandleState] = useState<{ isSatisfied: boolean, message?: string }>({ isSatisfied: true });
return (
<Handle
{...otherProps}
id={id}
type={type}
className={"singleConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected")}
isConnectable={connections.length === 0}
isValidConnection={(connection) => {
const result = validate(connection as Connection);
setHandleState(result);
return result.isSatisfied;
}}
title={handleState.message}
/>
);
}

View File

@@ -1,30 +0,0 @@
.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

@@ -1,69 +0,0 @@
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

@@ -1,12 +0,0 @@
import type { BasicBeliefNodeData } from "./BasicBeliefNode.tsx";
/**
* Default data for this node
*/
export const BasicBeliefNodeDefaults: BasicBeliefNodeData = {
label: "Belief",
droppable: true,
belief: {type: "keyword", id: "", value: "", label: "Keyword said:"},
hasReduce: true,
};

View File

@@ -1,237 +0,0 @@
import {
type NodeProps,
Position,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents.tsx';
import styles from '../../VisProg.module.css';
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores.tsx';
import { TextField } from '../../../../components/TextField.tsx';
import { MultilineTextField } from '../../../../components/MultilineTextField.tsx';
import {noMatchingLeftRightBelief} from "./BeliefGlobals.ts";
/**
* The default data structure for a BasicBelief node
*
* 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 BasicBelief node.
* @property droppable: Whether this node can be dropped from the toolbar (default: true).
* @property BasicBeliefType - The type of BasicBelief ("keywords" or a custom string).
* @property BasicBeliefs - The list of keyword BasicBeliefs (if applicable).
* @property hasReduce - Whether this node supports reduction logic.
*/
export type BasicBeliefNodeData = {
label: string;
droppable: boolean;
belief: BasicBeliefType;
hasReduce: boolean;
};
// These are all the types a basic belief could be.
export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion | Face
type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"};
type Semantic = { type: "semantic", id: string, value: string, description: string, label: "Detected with LLM:"};
type DetectedObject = { type: "object", id: string, value: string, label: "Object found:"};
type Emotion = { type: "emotion", id: string, value: string, label: "Emotion recognised:"};
type Face = { type: "face", id: string, value: string, label: "Face detected"};
export type BasicBeliefNode = Node<BasicBeliefNodeData>
// update the tooltip to reflect newly added connection options for a belief
export const BasicBeliefTooltip = `
A belief describes a condition that must be met
in order for a connected norm to be activated`;
/**
* This function is called whenever a connection is made with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the received connection
*/
export function BasicBeliefConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
}
/**
* This function is called whenever a connection is made with this node type as the source
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the created connection
*/
export function BasicBeliefConnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
}
/**
* This function is called whenever a connection is disconnected with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the disconnected connection
*/
export function BasicBeliefDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
}
/**
* This function is called whenever a connection is disconnected with this node type as the source
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the diconnected connection
*/
export function BasicBeliefDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
}
/**
* Defines how a BasicBelief node should be rendered
* @param props - Node properties provided by React Flow, including `id` and `data`.
* @returns The rendered BasicBeliefNode React element (React.JSX.Element).
*/
export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
const data = props.data;
const {updateNodeData} = useFlowStore();
const updateValue = (value: string) => updateNodeData(props.id, {...data, belief: {...data.belief, value: value}});
const label_input_id = `basic_belief_${props.id}_label_input`;
type BeliefString = BasicBeliefType["type"];
function updateBeliefType(newType: BeliefString) {
updateNodeData(props.id, {
...data,
belief: {
...data.belief,
type: newType,
value:
newType === "emotion"
? emotionOptions[0]
: data.belief.value,
},
});
}
const setBeliefDescription = (value: string) => {
updateNodeData(props.id, {...data, belief: {...data.belief, description: value}});
}
// Use this
const emotionOptions = ["Happy", "Angry", "Sad", "Cheerful"]
let placeholder = ""
let wrapping = ""
switch (props.data.belief.type) {
case ("keyword"):
placeholder = "keyword..."
wrapping = '"'
break;
case ("semantic"):
placeholder = "short description..."
wrapping = '"'
break;
case ("object"):
placeholder = "object..."
break;
case ("emotion"):
// TODO: emotion should probably be a drop-down menu rather than a string
// So this placeholder won't hold for always
placeholder = "emotion..."
break;
default:
break;
}
return (
<>
<Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeBasicBelief /*TODO: Change this*/}`}>
<div className={"flex-center-x gap-sm"}>
<label htmlFor={label_input_id}>Belief:</label>
</div>
<div className={"flex-row gap-sm"}>
<select
value={data.belief.type}
onChange={(e) => updateBeliefType(e.target.value as BeliefString)}
>
<option value="keyword">Keyword said:</option>
<option value="semantic">Detected with LLM:</option>
<option value="object">Object found:</option>
<option value="emotion">Emotion recognised:</option>
<option value="face">Face detected</option>
</select>
{wrapping}
{data.belief.type === "emotion" && (
<select
value={data.belief.value}
onChange={(e) => updateValue(e.target.value)}
>
{emotionOptions.map((emotion) => (
<option key={emotion} value={emotion.toLowerCase()}>
{emotion}
</option>
))}
</select>
)}
{data.belief.type !== "emotion" &&
(<TextField
id={label_input_id}
value={data.belief.value}
setValue={updateValue}
placeholder={placeholder}
/>)}
{wrapping}
</div>
{data.belief.type === "semantic" && (
<div className={"flex-wrap padding-sm"}>
<MultilineTextField
value={data.belief.description}
setValue={setBeliefDescription}
placeholder={"Describe a detailed desciption of this LLM belief..."}
/>
</div>
)}
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
noMatchingLeftRightBelief,
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"},{nodeType:"InferredBelief",handleId:"inferred_belief"}]),
]}/>
</div>
</>
);
};
/**
* Reduces each BasicBelief, including its children down into its core data.
* @param node - The BasicBelief 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 BasicBeliefs.
*/
export function BasicBeliefReduce(node: Node, _nodes: Node[]) {
const data = node.data as BasicBeliefNodeData;
const result: Record<string, unknown> = {
id: node.id,
};
switch (data.belief.type) {
case "emotion":
result["emotion"] = data.belief.value;
break;
case "keyword":
result["keyword"] = data.belief.value;
break;
case "object":
result["object"] = data.belief.value;
break;
case "semantic":
result["name"] = data.belief.value;
result["description"] = data.belief.description;
break;
case "face":
result["face_present"] = true;
break;
default:
break;
}
return result
}

View File

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

View File

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

View File

@@ -1,96 +0,0 @@
import {
type NodeProps,
Position,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
/**
* 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>
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
allowOnlyConnectionsFromType(["phase"])
]}/>
</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
return {
id: node.id
}
}
export const EndTooltip = `
The end node signifies the endpoint of your program;
the output of the final phase of your program should connect to the end node`;
/**
* This function is called whenever a connection is made with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the received connection
*/
export function EndConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
}
/**
* This function is called whenever a connection is made with this node type as the source
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the created connection
*/
export function EndConnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
}
/**
* This function is called whenever a connection is disconnected with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the disconnected connection
*/
export function EndDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
}
/**
* This function is called whenever a connection is disconnected with this node type as the source
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the diconnected connection
*/
export function EndDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
}

View File

@@ -1,14 +0,0 @@
import type { GoalNodeData } from "./GoalNode";
/**
* Default data for this node
*/
export const GoalNodeDefaults: GoalNodeData = {
label: "Goal Node",
name: "",
droppable: true,
description: "",
achieved: false,
hasReduce: true,
can_fail: false,
};

View File

@@ -1,203 +0,0 @@
import {
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 {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores';
import {DoesPlanIterate, HasCheckingSubGoal, PlanReduce, type Plan } from '../components/Plan';
import PlanEditorDialog from '../components/PlanEditor';
import { MultilineTextField } from '../../../../components/MultilineTextField';
import { defaultPlan } from '../components/Plan.default.ts';
import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
/**
* 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 - this will be checked for completion
* @param hasReduce: whether this node has reducing functionality (true by default)
* @param can_fail: whether this plan should be checked- this plan could possible fail
* @param plan: The (possible) attached plan to this goal
*/
export type GoalNodeData = {
label: string;
name: string;
description: string;
droppable: boolean;
achieved: boolean;
hasReduce: boolean;
can_fail: boolean;
plan?: Plan;
};
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({id, data}: NodeProps<GoalNode>) {
const {updateNodeData} = useFlowStore();
const _nodes = useFlowStore().nodes;
const text_input_id = `goal_${id}_text_input`;
const checkbox_id = `goal_${id}_checkbox`;
const planIterate = DoesPlanIterate(_nodes, data.plan);
const hasCheckSubGoal = data.plan !== undefined && HasCheckingSubGoal(data.plan, _nodes)
const setDescription = (value: string) => {
updateNodeData(id, {...data, description: value});
}
const setName= (value: string) => {
updateNodeData(id, {...data, name: value})
}
const setFailable = (value: boolean) => {
updateNodeData(id, {...data, can_fail: value});
}
return <>
<Toolbar nodeId={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.name}
setValue={(val) => setName(val)}
placeholder={"To ..."}
/>
</div>
{(data.can_fail || hasCheckSubGoal) && (<div>
<label htmlFor={text_input_id}>Description/ Condition of goal:</label>
<div className={"flex-wrap"}>
<MultilineTextField
id={text_input_id}
value={data.description}
setValue={setDescription}
placeholder={"Describe the condition of this goal..."}
/>
</div>
</div>)}
<div>
<label> {!data.plan ? "No plan set to execute while goal is not reached. 🔴" : "Will follow plan '" + data.plan.name + "' until all steps complete. 🟢"} </label>
</div>
{data.plan && (<div className={"flex-row gap-md align-center " + (planIterate ? "" : styles.planNoIterate)}>
{planIterate ? "" : <s></s>}
<label htmlFor={checkbox_id}>{!planIterate ? "This plan always succeeds!" : "Check if this plan fails"}:</label>
<input
id={checkbox_id}
type={"checkbox"}
disabled={!planIterate || (data.plan && HasCheckingSubGoal(data.plan, _nodes))}
checked={!planIterate || data.can_fail || (data.plan && HasCheckingSubGoal(data.plan, _nodes))}
onChange={(e) => planIterate ? setFailable(e.target.checked) : setFailable(false)}
/>
</div>
)}
<div>
<PlanEditorDialog
plan={data.plan}
onSave={(plan) => {
updateNodeData(id, {
...data,
plan,
});
}}
description={data.name}
/>
</div>
<MultiConnectionHandle type="source" position={Position.Right} id="GoalSource" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
]}/>
<MultiConnectionHandle type="target" position={Position.Bottom} id="GoalTarget" rules={[allowOnlyConnectionsFromType(["goal"])]}/>
</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[]) {
const data = node.data as GoalNodeData;
return {
id: node.id,
name: data.name,
description: data.description,
can_fail: data.can_fail || (data.plan && HasCheckingSubGoal(data.plan, _nodes)),
plan: data.plan ? PlanReduce(_nodes, data.plan) : "",
}
}
export const GoalTooltip = `
The goal node allows you to set goals that Pepper has to achieve
before moving to the next phase of your program`;
/**
* This function is called whenever a connection is made with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the received connection
*/
export function GoalConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// Goals should only be targeted by other goals, for them to be part of our plan.
const nodes = useFlowStore.getState().nodes;
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
if (!otherNode || otherNode.type !== "goal") return;
const data = _thisNode.data as GoalNodeData
// First, let's see if we have a plan currently. If not, let's create a default plan with this goal inside.:)
if (!data.plan) {
data.plan = insertGoalInPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()} as Plan, otherNode as GoalNode)
}
// Else, lets just insert this goal into our current plan.
else {
data.plan = insertGoalInPlan(structuredClone(data.plan), otherNode as GoalNode)
}
}
/**
* This function is called whenever a connection is made with this node type as the source
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the created connection
*/
export function GoalConnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
}
/**
* This function is called whenever a connection is disconnected with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the disconnected connection
*/
export function GoalDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// We should probably check if our disconnection was by a goal, since it would mean we have to remove it from our plan list.
const data = _thisNode.data as GoalNodeData
data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId)
}
/**
* This function is called whenever a connection is disconnected with this node type as the source
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the diconnected connection
*/
export function GoalDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,160 +0,0 @@
import {
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 {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores';
import {BeliefGlobalReduce} from "./BeliefGlobals.ts";
/**
* 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;
condition?: string; // id of this node's belief.
norm: string;
hasReduce: boolean;
critical: 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 checkbox_id = `goal_${props.id}_checkbox`;
const setValue = (value: string) => {
updateNodeData(props.id, {norm: value});
}
const setCritical = (value: boolean) => {
updateNodeData(props.id, {...data, critical: 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>
<div className={"flex-row gap-md align-center"}>
<label htmlFor={checkbox_id}>Critical:</label>
<input
id={checkbox_id}
type={"checkbox"}
checked={data.critical || false}
onChange={(e) => setCritical(e.target.checked)}
/>
</div>
{data.condition && (<div className={"flex-row gap-md align-center"} data-testid="norm-condition-information">
<label htmlFor={checkbox_id}>Condition/ Belief attached.</label>
</div>)}
<MultiConnectionHandle type="source" position={Position.Right} id="norms" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}])
]}/>
<SingleConnectionHandle type="target" position={Position.Bottom} id="NormBeliefs" rules={[
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"])
]}/>
</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[]) {
const data = node.data as NormNodeData;
// conditions nodes - make sure to check for empty arrays
const result: Record<string, unknown> = {
id: node.id,
label: data.label,
norm: data.norm,
critical: data.critical,
};
if (data.condition) {
const conditionNode = nodes.find((node) => node.id === data.condition);
// In case something went wrong, and our condition doesn't actually exist;
if (conditionNode == undefined) return result;
result["condition"] = BeliefGlobalReduce(conditionNode, nodes)
}
return result
}
export const NormTooltip = `
A norm describes a behavioral rule Pepper must follow during the connected phase(-s),
for example: "respond using formal language"`;
/**
* This function is called whenever a connection is made with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the received connection
*/
export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
const data = _thisNode.data as NormNodeData;
// If we got a belief connected, this is the condition for the norm.
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && ['basic_belief', 'inferred_belief'].includes(node.type!)))) {
data.condition = _sourceNodeId;
}
}
/**
* This function is called whenever a connection is made with this node type as the source
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the created connection
*/
export function NormConnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
}
/**
* This function is called whenever a connection is disconnected with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the disconnected connection
*/
export function NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
const data = _thisNode.data as NormNodeData;
// remove if the target of disconnection was our condition
if (_sourceNodeId == data.condition) data.condition = undefined
}
/**
* This function is called whenever a connection is disconnected with this node type as the source
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the diconnected connection
*/
export function NormDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
}

View File

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

View File

@@ -1,206 +0,0 @@
import {
type NodeProps,
Position,
type Node
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromType, noSelfConnections} from "../HandleRules.ts";
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)
* @param nextPhaseId:
*/
export type PhaseNodeData = {
label: string;
droppable: boolean;
children: string[];
hasReduce: boolean;
nextPhaseId: string | "end" | null;
isFirstPhase: 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>
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
noSelfConnections,
allowOnlyConnectionsFromType(["phase", "start"]),
]}/>
<MultiConnectionHandle type="target" position={Position.Bottom} id="data" rules={[
allowOnlyConnectionsFromType(["norm", "goal", "trigger"])
]}/>
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
noSelfConnections,
allowOnlyConnectionsFromType(["phase", "end"]),
]}/>
</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 - make sure to check for empty arrays
let childrenNodes: Node[] = [];
if (data.children)
childrenNodes = nodes.filter((node) => data.children.includes(node.id));
// Build the result object
const result: Record<string, unknown> = {
id: thisNode.id,
name: 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"] = [];
for (const typedChild of typedChildren) {
(result[type + "s"] as object[]).push(reducer(typedChild, nodes))
}
}
});
return result;
}
export const PhaseTooltip = `
A phase is a single stage of the program, during a phase Pepper will behave
in accordance with any connected norms, goals and triggers`;
/**
* This function is called whenever a connection is made with this node type as the target (phase)
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the received connection
*/
export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
const data = _thisNode.data as PhaseNodeData
const nodes = useFlowStore.getState().nodes;
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)!
switch (sourceNode.type) {
case "phase": break;
case "start": data.isFirstPhase = true; break;
// we only add none phase or start nodes to the children
// endNodes cannot be the source of an outgoing connection
// so we don't need to cover them with a special case
// before handling the default behavior
default: data.children.push(_sourceNodeId); break;
}
}
/**
* This function is called whenever a connection is made with this node type as the source (phase)
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the created connection
*/
export function PhaseConnectionSource(_thisNode: Node, _targetNodeId: string) {
const data = _thisNode.data as PhaseNodeData
const nodes = useFlowStore.getState().nodes;
const targetNode = nodes.find((node) => node.id === _targetNodeId)
if (!targetNode) {throw new Error("Source node not found")}
// we set the nextPhaseId to the next target's id if the target is a phaseNode,
// or "end" if the target node is the end node
switch (targetNode.type) {
case 'phase': data.nextPhaseId = _targetNodeId; break;
case 'end': data.nextPhaseId = "end"; break;
default: break;
}
}
/**
* This function is called whenever a connection is disconnected with this node type as the target (phase)
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the disconnected connection
*/
export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
const data = _thisNode.data as PhaseNodeData
const nodes = useFlowStore.getState().nodes;
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)
const sourceType = sourceNode ? sourceNode.type : "deleted";
switch (sourceType) {
case "phase": break;
case "start": data.isFirstPhase = false; break;
// we only add none phase or start nodes to the children
// endNodes cannot be the source of an outgoing connection
// so we don't need to cover them with a special case
// before handling the default behavior
default:
data.children = data.children.filter((child) => { if (child != _sourceNodeId) return child; });
break;
}
}
/**
* This function is called whenever a connection is disconnected with this node type as the source (phase)
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the diconnected connection
*/
export function PhaseDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
const data = _thisNode.data as PhaseNodeData
const nodes = useFlowStore.getState().nodes;
// if the target is a phase or end node set the nextPhaseId to null,
// as we are no longer connected to a subsequent phaseNode or to the endNode
if (nodes.some((node) => node.id === _targetNodeId && ['phase', 'end'].includes(node.type!))){
data.nextPhaseId = null;
}
}

View File

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

View File

@@ -1,94 +0,0 @@
import {
type NodeProps,
Position,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
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>
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"target"}])
]}/>
</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
return {
id: node.id
}
}
export const StartTooltip = `
The start node acts as the starting point for a program,
it should be connected to the left handle of the first phase of your program`;
/**
* This function is called whenever a connection is made with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the received connection
*/
export function StartConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
}
/**
* This function is called whenever a connection is made with this node type as the source
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the created connection
*/
export function StartConnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
}
/**
* This function is called whenever a connection is disconnected with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the disconnected connection
*/
export function StartDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
}
/**
* This function is called whenever a connection is disconnected with this node type as the source
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the diconnected connection
*/
export function StartDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
}

View File

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

View File

@@ -1,206 +0,0 @@
import {
type NodeProps,
Position,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores';
import {PlanReduce, type Plan } from '../components/Plan';
import PlanEditorDialog from '../components/PlanEditor';
import {BeliefGlobalReduce} from "./BeliefGlobals.ts";
import type { GoalNode } from './GoalNode.tsx';
import { defaultPlan } from '../components/Plan.default.ts';
import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
import { TextField } from '../../../../components/TextField.tsx';
/**
* 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 hasReduce - Whether this node supports reduction logic.
*/
export type TriggerNodeData = {
label: string;
name: string;
droppable: boolean;
condition?: string; // id of the belief
plan?: Plan;
hasReduce: boolean;
};
export type TriggerNode = Node<TriggerNodeData>
/**
* 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 setName= (value: string) => {
updateNodeData(props.id, {...data, name: value})
}
return <>
<Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
<TextField
value={props.data.name}
setValue={(val) => setName(val)}
placeholder={"Name of this trigger..."}
/>
<div className={"flex-row gap-md"}>Triggers when the condition is met.</div>
<div className={"flex-row gap-md"}>Condition/ Belief is currently {data.condition ? "" : "not"} set. {data.condition ? "🟢" : "🔴"}</div>
<div className={"flex-row gap-md"}>Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}</div>
<MultiConnectionHandle type="source" position={Position.Right} id="TriggerSource" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
]}/>
<SingleConnectionHandle
type="target"
position={Position.Bottom}
id="TriggerBeliefs"
style={{ left: '40%' }}
rules={[
allowOnlyConnectionsFromType(['basic_belief']),
]}
/>
<MultiConnectionHandle
type="target"
position={Position.Bottom}
id="GoalTarget"
style={{ left: '60%' }}
rules={[
allowOnlyConnectionsFromType(['goal']),
]}
/>
<PlanEditorDialog
plan={data.plan}
onSave={(plan) => {
updateNodeData(props.id, {
...data,
plan,
});
}}
/>
</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[]) {
const data = node.data as TriggerNodeData;
const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined
const conditionData = conditionNode ? BeliefGlobalReduce(conditionNode, nodes) : ""
return {
id: node.id,
name: node.data.name,
condition: conditionData, // Make sure we have a condition before reducing, or default to ""
plan: !data.plan ? "" : PlanReduce(nodes, data.plan), // Make sure we have a plan when reducing, or default to ""
}
}
export const TriggerTooltip = `
A trigger node is used to make Pepper execute a predefined plan -
consisting of one or more actions - when the connected beliefs are met`;
/**
* This function is called whenever a connection is made with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the received connection
*/
export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
const data = _thisNode.data as TriggerNodeData;
// If we got a belief connected, this is the condition for the norm.
const nodes = useFlowStore.getState().nodes;
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
if (!otherNode) return;
if (otherNode.type === 'basic_belief' /* TODO: Add the option for an inferred belief */) {
data.condition = _sourceNodeId;
}
else if (otherNode.type === 'goal') {
// First, let's see if we have a plan currently. If not, let's create a default plan with this goal inside.:)
if (!data.plan) {
data.plan = insertGoalInPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()} as Plan, otherNode as GoalNode)
}
// Else, lets just insert this goal into our current plan.
else {
data.plan = insertGoalInPlan(structuredClone(data.plan), otherNode as GoalNode)
}
}
}
/**
* This function is called whenever a connection is made with this node type as the source
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the created connection
*/
export function TriggerConnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
}
/**
* This function is called whenever a connection is disconnected with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the disconnected connection
*/
export function TriggerDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
const data = _thisNode.data as TriggerNodeData;
// remove if the target of disconnection was our condition
if (_sourceNodeId == data.condition) data.condition = undefined
data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId)
}
/**
* This function is called whenever a connection is disconnected with this node type as the source
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the diconnected connection
*/
export function TriggerDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
}
// 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;

View File

@@ -1,19 +0,0 @@
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" });
}

View File

@@ -1,96 +0,0 @@
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

@@ -1,19 +0,0 @@
/**
* 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

@@ -1,21 +0,0 @@
/**
* 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

@@ -1,40 +0,0 @@
import type {PhaseNode} from "../pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
/**
* takes an array of phaseNodes and orders them according to their nextPhaseId attributes,
* starting with the phase that has isFirstPhase = true
*
* @param {PhaseNode[]} nodes an unordered phaseNode array
* @returns {PhaseNode[]} the ordered phaseNode array
*/
export default function orderPhaseNodeArray(nodes: PhaseNode[]) : PhaseNode[] {
// find the first phaseNode of the sequence
const start = nodes.find(node => node.data.isFirstPhase);
if (!start) {
throw new Error('No phaseNode with isFirstObject = true found');
}
// prepare for ordering of phaseNodes
const orderedPhaseNodes: PhaseNode[] = [];
const IdMap = new Map(nodes.map(node => [node.id, node]));
let currentNode: PhaseNode | undefined = start;
// populate orderedPhaseNodes array with the phaseNodes in the correct order
while (currentNode) {
orderedPhaseNodes.push(currentNode);
if (!currentNode.data.nextPhaseId) {
throw new Error("Incomplete phase sequence, program does not reach the end node");
}
if (currentNode.data.nextPhaseId === "end") break;
currentNode = IdMap.get(currentNode.data.nextPhaseId);
if (!currentNode) {
throw new Error(`Incomplete phase sequence, phaseNode with id "${orderedPhaseNodes.at(-1)?.data.nextPhaseId}" not found`);
}
}
return orderedPhaseNodes;
}

View File

@@ -1,24 +0,0 @@
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

@@ -1,133 +0,0 @@
import {create} from "zustand";
// the type of a reduced program
export type ReducedProgram = { phases: Record<string, unknown>[] };
export type GoalWithDepth = Record<string, unknown> & { level: number };
/**
* the type definition of the programStore
*/
export type ProgramState = {
// Basic store functionality:
currentProgram: ReducedProgram;
setProgramState: (state: ReducedProgram) => void;
getProgramState: () => ReducedProgram;
// Utility functions:
// to avoid having to manually go through the entire state for every instance where data is required
getPhaseIds: () => string[];
getPhaseNames: () => string[];
getNormsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
getGoalsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
getGoalsWithDepth: (currentPhaseId: string) => GoalWithDepth[];
getTriggersInPhase: (currentPhaseId: string) => Record<string, unknown>[];
// if more specific utility functions are needed they can be added here:
}
/**
* the ProgramStore can be used to access all information of the most recently sent program,
* it contains basic functions to set and get the current program.
* And it contains some utility functions that allow you to easily gain access
* to the norms, triggers and goals of a specific phase.
*/
const useProgramStore = create<ProgramState>((set, get) => ({
currentProgram: { phases: [] as Record<string, unknown>[]},
/**
* sets the current program by cloning the provided program using a structuredClone
*/
setProgramState: (program: ReducedProgram) => set({currentProgram: structuredClone(program)}),
/**
* gets the current program
*/
getProgramState: () => get().currentProgram,
// utility functions:
/**
* gets the ids of all phases in the program
*/
getPhaseIds: () => get().currentProgram.phases.map(entry => entry["id"] as string),
/**
* gets the names of all phases in the program
*/
getPhaseNames: () => get().currentProgram.phases.map((entry) => (entry["name"] as string)),
/**
* gets the norms for the provided phase
*/
getNormsInPhase: (currentPhaseId) => {
const program = get().currentProgram;
const phase = program.phases.find(val => val["id"] === currentPhaseId);
if (phase) {
return phase["norms"] as Record<string, unknown>[];
}
throw new Error(`phase with id:"${currentPhaseId}" not found`)
},
/**
* gets the goals for the provided phase
*/
getGoalsInPhase: (currentPhaseId) => {
const program = get().currentProgram;
const phase = program.phases.find(val => val["id"] === currentPhaseId);
if (phase) {
return phase["goals"] as Record<string, unknown>[];
}
throw new Error(`phase with id:"${currentPhaseId}" not found`)
},
getGoalsWithDepth: (currentPhaseId: string) => {
const program = get().currentProgram;
const phase = program.phases.find(val => val["id"] === currentPhaseId);
if (!phase) {
throw new Error(`phase with id:"${currentPhaseId}" not found`);
}
const rootGoals = phase["goals"] as Record<string, unknown>[];
const flatList: GoalWithDepth[] = [];
const isGoal = (item: Record<string, unknown>) => {
return item["plan"] !== undefined;
};
// Recursive helper function
const traverse = (goals: Record<string, unknown>[], depth: number) => {
goals.forEach((goal) => {
// 1. Add the current goal to the list
flatList.push({ ...goal, level: depth });
// 2. Check for children
const plan = goal["plan"] as Record<string, unknown> | undefined;
if (plan && Array.isArray(plan["steps"])) {
const steps = plan["steps"] as Record<string, unknown>[];
// 3. FILTER: Only recurse on steps that are actually goals
// If we just passed 'steps', we might accidentally add Actions/Speeches to the goal list
const childGoals = steps.filter(isGoal);
if (childGoals.length > 0) {
traverse(childGoals, depth + 1);
}
}
});
};
// Start traversal
traverse(rootGoals, 0);
return flatList;
},
/**
* gets the triggers for the provided phase
*/
getTriggersInPhase: (currentPhaseId) => {
const program = get().currentProgram;
const phase = program.phases.find(val => val["id"] === currentPhaseId);
if (phase) {
return phase["triggers"] as Record<string, unknown>[];
}
throw new Error(`phase with id:"${currentPhaseId}" not found`)
}
}));
export default useProgramStore;

View File

@@ -1,328 +0,0 @@
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

@@ -1,239 +0,0 @@
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")).toBeDefined();
expect(screen.getByText("WARNING")).toBeDefined();
expect(screen.getByText("logging")).toBeDefined();
expect(screen.getByText("Ping")).toBeDefined();
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")).toBeDefined();
});
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")).toBeDefined();
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

@@ -1,246 +0,0 @@
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);
});
});

View File

@@ -1,104 +0,0 @@
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,299 +0,0 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import MonitoringPage from '../../../src/pages/MonitoringPage/MonitoringPage';
import useProgramStore from '../../../src/utils/programStore';
import * as MonitoringAPI from '../../../src/pages/MonitoringPage/MonitoringPageAPI';
import * as VisProg from '../../../src/pages/VisProgPage/VisProgLogic';
// --- Mocks ---
// Mock the Zustand store
jest.mock('../../../src/utils/programStore', () => ({
__esModule: true,
default: jest.fn(),
}));
// Mock the API layer including hooks
jest.mock('../../../src/pages/MonitoringPage/MonitoringPageAPI', () => ({
nextPhase: jest.fn(),
resetPhase: jest.fn(),
pauseExperiment: jest.fn(),
playExperiment: jest.fn(),
// We mock these to capture the callbacks and trigger them manually in tests
useExperimentLogger: jest.fn(),
useStatusLogger: jest.fn(),
}));
// Mock VisProg functionality
jest.mock('../../../src/pages/VisProgPage/VisProgLogic', () => ({
graphReducer: jest.fn(),
runProgramm: jest.fn(),
}));
// Mock Child Components to reduce noise (optional, but keeps unit test focused)
// For this test, we will allow them to render to test data passing,
// but we mock RobotConnected as it has its own side effects
jest.mock('../../../src/pages/MonitoringPage/MonitoringPageComponents', () => {
const original = jest.requireActual('../../../src/pages/MonitoringPage/MonitoringPageComponents');
return {
...original,
RobotConnected: () => <div data-testid="robot-connected-mock">Robot Status</div>,
};
});
describe('MonitoringPage', () => {
// Capture stream callbacks
let streamUpdateCallback: (data: any) => void;
let statusUpdateCallback: (data: any) => void;
// Setup default store state
const mockGetPhaseIds = jest.fn();
const mockGetPhaseNames = jest.fn();
const mockGetNorms = jest.fn();
const mockGetGoals = jest.fn();
const mockGetGoalsWithDepth = jest.fn();
const mockGetTriggers = jest.fn();
const mockSetProgramState = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Default Store Implementation
(useProgramStore as unknown as jest.Mock).mockImplementation((selector) => {
const state = {
getPhaseIds: mockGetPhaseIds,
getPhaseNames: mockGetPhaseNames,
getNormsInPhase: mockGetNorms,
getGoalsInPhase: mockGetGoals,
getTriggersInPhase: mockGetTriggers,
getGoalsWithDepth: mockGetGoalsWithDepth,
setProgramState: mockSetProgramState,
};
return selector(state);
});
// Capture the hook callbacks
(MonitoringAPI.useExperimentLogger as jest.Mock).mockImplementation((cb) => {
streamUpdateCallback = cb;
});
(MonitoringAPI.useStatusLogger as jest.Mock).mockImplementation((cb) => {
statusUpdateCallback = cb;
});
// Default mock return values
mockGetPhaseIds.mockReturnValue(['phase-1', 'phase-2']);
mockGetPhaseNames.mockReturnValue(['Intro', 'Main']);
mockGetGoals.mockReturnValue([{ id: 'g1', name: 'Goal 1'}, { id: 'g2', name: 'Goal 2'}]);
mockGetGoalsWithDepth.mockReturnValue([
{ id: 'g1', name: 'Goal 1', level: 0 },
{ id: 'g2', name: 'Goal 2', level: 0 }
]);
mockGetTriggers.mockReturnValue([{ id: 't1', name: 'Trigger 1' }]);
mockGetNorms.mockReturnValue([
{ id: 'n1', norm: 'Norm 1', condition: null },
{ id: 'cn1', norm: 'Cond Norm 1', condition: 'some-cond' }
]);
});
test('renders "No program loaded" when phaseIds are empty', () => {
mockGetPhaseIds.mockReturnValue([]);
render(<MonitoringPage />);
expect(screen.getByText('No program loaded.')).toBeInTheDocument();
});
test('renders the dashboard with initial state', () => {
render(<MonitoringPage />);
// Check Header
expect(screen.getByText('Phase 1:')).toBeInTheDocument();
expect(screen.getByText('Intro')).toBeInTheDocument();
// Check Lists
expect(screen.getByText(/Goal 1/)).toBeInTheDocument();
expect(screen.getByText('Trigger 1')).toBeInTheDocument();
expect(screen.getByText('Norm 1')).toBeInTheDocument();
expect(screen.getByText('Cond Norm 1')).toBeInTheDocument();
});
describe('Control Buttons', () => {
test('Pause calls API and updates UI', async () => {
render(<MonitoringPage />);
const pauseBtn = screen.getByText('❚❚');
await act(async () => {
fireEvent.click(pauseBtn);
});
expect(MonitoringAPI.pauseExperiment).toHaveBeenCalled();
// Ensure local state toggled (we check if play button is now inactive style or pause active)
});
test('Play calls API and updates UI', async () => {
render(<MonitoringPage />);
const playBtn = screen.getByText('▶');
await act(async () => {
fireEvent.click(playBtn);
});
expect(MonitoringAPI.playExperiment).toHaveBeenCalled();
});
test('Next Phase calls API', async () => {
render(<MonitoringPage />);
await act(async () => {
fireEvent.click(screen.getByText('⏭'));
});
expect(MonitoringAPI.nextPhase).toHaveBeenCalled();
});
test('Reset Experiment calls logic and resets state', async () => {
render(<MonitoringPage />);
// Mock graph reducer return
(VisProg.graphReducer as jest.Mock).mockReturnValue([{ id: 'new-phase' }]);
await act(async () => {
fireEvent.click(screen.getByText('⟲'));
});
expect(VisProg.graphReducer).toHaveBeenCalled();
expect(mockSetProgramState).toHaveBeenCalledWith({ phases: [{ id: 'new-phase' }] });
expect(VisProg.runProgramm).toHaveBeenCalled();
});
test('Reset Experiment handles errors gracefully', async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
(VisProg.runProgramm as jest.Mock).mockRejectedValue(new Error('Fail'));
render(<MonitoringPage />);
await act(async () => {
fireEvent.click(screen.getByText('⟲'));
});
expect(consoleSpy).toHaveBeenCalledWith('Failed to reset program:', expect.any(Error));
consoleSpy.mockRestore();
});
});
describe('Stream Updates (useExperimentLogger)', () => {
test('Handles phase_update to next phase', () => {
render(<MonitoringPage />);
expect(screen.getByText('Intro')).toBeInTheDocument(); // Phase 0
act(() => {
streamUpdateCallback({ type: 'phase_update', id: 'phase-2' });
});
expect(screen.getByText('Main')).toBeInTheDocument(); // Phase 1
});
test('Handles phase_update to "end"', () => {
render(<MonitoringPage />);
act(() => {
streamUpdateCallback({ type: 'phase_update', id: 'end' });
});
expect(screen.getByText('Experiment finished')).toBeInTheDocument();
expect(screen.getByText('All phases have been successfully completed.')).toBeInTheDocument();
});
test('Handles phase_update with unknown ID gracefully', () => {
render(<MonitoringPage />);
act(() => {
streamUpdateCallback({ type: 'phase_update', id: 'unknown-phase' });
});
// Should remain on current phase
expect(screen.getByText('Intro')).toBeInTheDocument();
});
test('Handles goal_update: advances index and marks previous as achieved', () => {
render(<MonitoringPage />);
// Initial: Goal 1 (index 0) is current.
// Send update for Goal 2 (index 1).
act(() => {
streamUpdateCallback({ type: 'goal_update', id: 'g2' });
});
// Goal 1 should now be marked achieved (passed via activeIds)
// Goal 2 should be current.
// We can inspect the "StatusList" props implicitly by checking styling or indicators if not mocked,
// but since we render the full component, we check the class/text.
// Goal 1 should have checkmark (override logic puts checkmark for activeIds)
// The implementation details of StatusList show ✔️ for activeIds.
const items = screen.getAllByRole('listitem');
// Helper to find checkmarks within items
expect(items[0]).toHaveTextContent('Goal 1');
// After update, g1 is active (achieved), g2 is current
// logic: loop i < gIndex (1). activeIds['g1'] = true.
});
test('Handles goal_update with unknown ID', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
render(<MonitoringPage />);
act(() => {
streamUpdateCallback({ type: 'goal_update', id: 'unknown-goal' });
});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Goal unknown-goal not found'));
warnSpy.mockRestore();
});
test('Handles trigger_update', () => {
render(<MonitoringPage />);
// Trigger 1 initially not achieved
act(() => {
streamUpdateCallback({ type: 'trigger_update', id: 't1', achieved: true });
});
// StatusList logic: if activeId is true, show ✔️
// We look for visual confirmation or check logic
const triggerList = screen.getByText('Triggers').parentElement;
expect(triggerList).toHaveTextContent('✔️'); // Assuming 't1' is the only trigger
});
});
describe('Status Updates (useStatusLogger)', () => {
test('Handles cond_norms_state_update', () => {
render(<MonitoringPage />);
// Initial state: activeIds empty.
act(() => {
statusUpdateCallback({
type: 'cond_norms_state_update',
norms: [{ id: 'cn1', active: true }]
});
});
// Conditional Norm 1 should now be active
const cnList = screen.getByText('Conditional Norms').parentElement;
expect(cnList).toHaveTextContent('✔️');
});
test('Ignores status update if no changes detected', () => {
render(<MonitoringPage />);
// First update
act(() => {
statusUpdateCallback({ type: 'cond_norms_state_update', norms: [{ id: 'cn1', active: true }] });
});
// Second identical update - strictly checking if this causes a rerender is hard in RTL,
// but we ensure no errors and state remains consistent.
act(() => {
statusUpdateCallback({ type: 'cond_norms_state_update', norms: [{ id: 'cn1', active: true }] });
});
const cnList = screen.getByText('Conditional Norms').parentElement;
expect(cnList).toHaveTextContent('✔️');
});
});
});

View File

@@ -1,220 +0,0 @@
import { renderHook, act, cleanup } from '@testing-library/react';
import {
sendAPICall,
nextPhase,
pauseExperiment,
playExperiment,
useExperimentLogger,
useStatusLogger
} from '../../../src/pages/MonitoringPage/MonitoringPageAPI';
// --- MOCK EVENT SOURCE SETUP ---
// This mocks the browser's EventSource so we can manually 'push' messages to our hooks
const mockInstances: MockEventSource[] = [];
class MockEventSource {
url: string;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null; // Added onerror support
closed = false;
constructor(url: string) {
this.url = url;
mockInstances.push(this);
}
sendMessage(data: string) {
if (this.onmessage) {
this.onmessage({ data } as MessageEvent);
}
}
triggerError(err: any) {
if (this.onerror) {
this.onerror(err);
}
}
close() {
this.closed = true;
}
}
// Mock global EventSource
beforeAll(() => {
(globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url));
});
// Mock global fetch
beforeEach(() => {
globalThis.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ reply: 'ok' }),
})
) as jest.Mock;
});
// Cleanup after every test
afterEach(() => {
cleanup();
jest.restoreAllMocks();
mockInstances.length = 0;
});
describe('MonitoringPageAPI', () => {
describe('sendAPICall', () => {
test('sends correct POST request', async () => {
await sendAPICall('test_type', 'test_ctx');
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://localhost:8000/button_pressed',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'test_type', context: 'test_ctx' }),
})
);
});
test('appends endpoint if provided', async () => {
await sendAPICall('t', 'c', '/extra');
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/button_pressed/extra'),
expect.any(Object)
);
});
test('logs error on fetch network failure', async () => {
(globalThis.fetch as jest.Mock).mockRejectedValue('Network error');
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
await sendAPICall('t', 'c');
expect(consoleSpy).toHaveBeenCalledWith('Failed to send api call:', 'Network error');
});
test('throws error if response is not ok', async () => {
(globalThis.fetch as jest.Mock).mockResolvedValue({ ok: false });
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
await sendAPICall('t', 'c');
expect(consoleSpy).toHaveBeenCalledWith('Failed to send api call:', expect.any(Error));
});
});
describe('Helper Functions', () => {
test('nextPhase sends correct params', async () => {
await nextPhase();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: JSON.stringify({ type: 'next_phase', context: '' }) })
);
});
test('pauseExperiment sends correct params', async () => {
await pauseExperiment();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: JSON.stringify({ type: 'pause', context: 'true' }) })
);
});
test('playExperiment sends correct params', async () => {
await playExperiment();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: JSON.stringify({ type: 'pause', context: 'false' }) })
);
});
});
describe('useExperimentLogger', () => {
test('connects to SSE and receives messages', () => {
const onUpdate = jest.fn();
// Hook must be rendered to start the effect
renderHook(() => useExperimentLogger(onUpdate));
// Retrieve the mocked instance created by the hook
const eventSource = mockInstances[0];
expect(eventSource.url).toContain('/experiment_stream');
// Simulate incoming message
act(() => {
eventSource.sendMessage(JSON.stringify({ type: 'phase_update', id: '1' }));
});
expect(onUpdate).toHaveBeenCalledWith({ type: 'phase_update', id: '1' });
});
test('handles JSON parse errors in stream', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
renderHook(() => useExperimentLogger());
const eventSource = mockInstances[0];
act(() => {
eventSource.sendMessage('invalid-json');
});
expect(consoleSpy).toHaveBeenCalledWith('Stream parse error:', expect.any(Error));
});
test('handles SSE connection error', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
renderHook(() => useExperimentLogger());
const eventSource = mockInstances[0];
act(() => {
eventSource.triggerError('Connection lost');
});
expect(consoleSpy).toHaveBeenCalledWith('SSE Connection Error:', 'Connection lost');
expect(eventSource.closed).toBe(true);
});
test('closes EventSource on unmount', () => {
const { unmount } = renderHook(() => useExperimentLogger());
const eventSource = mockInstances[0];
const closeSpy = jest.spyOn(eventSource, 'close');
unmount();
expect(closeSpy).toHaveBeenCalled();
expect(eventSource.closed).toBe(true);
});
});
describe('useStatusLogger', () => {
test('connects to SSE and receives messages', () => {
const onUpdate = jest.fn();
renderHook(() => useStatusLogger(onUpdate));
const eventSource = mockInstances[0];
expect(eventSource.url).toContain('/status_stream');
act(() => {
eventSource.sendMessage(JSON.stringify({ some: 'data' }));
});
expect(onUpdate).toHaveBeenCalledWith({ some: 'data' });
});
test('handles JSON parse errors', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
renderHook(() => useStatusLogger());
const eventSource = mockInstances[0];
act(() => {
eventSource.sendMessage('bad-data');
});
expect(consoleSpy).toHaveBeenCalledWith('Status stream error:', expect.any(Error));
});
});
});

View File

@@ -1,226 +0,0 @@
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import '@testing-library/jest-dom';
// Corrected Imports
import {
GestureControls,
SpeechPresets,
DirectSpeechInput,
StatusList,
RobotConnected
} from '../../../src/pages/MonitoringPage/MonitoringPageComponents';
import * as MonitoringAPI from '../../../src/pages/MonitoringPage/MonitoringPageAPI';
// Mock the API Call function with the correct path
jest.mock('../../../src/pages/MonitoringPage/MonitoringPageAPI', () => ({
sendAPICall: jest.fn(),
}));
describe('MonitoringPageComponents', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GestureControls', () => {
test('renders and sends gesture command', () => {
render(<GestureControls />);
fireEvent.change(screen.getByRole('combobox'), {
target: { value: 'animations/Stand/Gestures/Hey_1' }
});
// Click button
fireEvent.click(screen.getByText('Actuate'));
// Expect the API to be called with that new value
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('gesture', 'animations/Stand/Gestures/Hey_1');
});
});
describe('SpeechPresets', () => {
test('renders buttons and sends speech command', () => {
render(<SpeechPresets />);
const btn = screen.getByText('"Hello, I\'m Pepper"');
fireEvent.click(btn);
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', "Hello, I'm Pepper");
});
});
describe('DirectSpeechInput', () => {
test('inputs text and sends on button click', () => {
render(<DirectSpeechInput />);
const input = screen.getByPlaceholderText('Type message...');
fireEvent.change(input, { target: { value: 'Custom text' } });
fireEvent.click(screen.getByText('Send'));
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', 'Custom text');
expect(input).toHaveValue(''); // Should clear
});
test('sends on Enter key', () => {
render(<DirectSpeechInput />);
const input = screen.getByPlaceholderText('Type message...');
fireEvent.change(input, { target: { value: 'Enter text' } });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', 'Enter text');
});
test('does not send empty text', () => {
render(<DirectSpeechInput />);
fireEvent.click(screen.getByText('Send'));
expect(MonitoringAPI.sendAPICall).not.toHaveBeenCalled();
});
});
describe('StatusList', () => {
const mockSet = jest.fn();
const items = [
{ id: '1', name: 'Item 1' },
{ id: '2', name: 'Item 2' }
];
test('renders list items', () => {
render(<StatusList title="Test List" items={items} type="goal" activeIds={{}} />);
expect(screen.getByText('Test List')).toBeInTheDocument();
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
test('Goals: click override on inactive item calls API', () => {
render(
<StatusList
title="Goals"
items={items}
type="goal"
activeIds={{}}
setActiveIds={mockSet}
/>
);
// Click the X (inactive)
const indicator = screen.getAllByText('❌')[0];
fireEvent.click(indicator);
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('override', '1');
expect(mockSet).toHaveBeenCalled();
});
test('Conditional Norms: click override on ACTIVE item unachieves', () => {
render(
<StatusList
title="CN"
items={items}
type="cond_norm"
activeIds={{ '1': true }}
/>
);
const indicator = screen.getByText('✔️'); // It is active
fireEvent.click(indicator);
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('override_unachieve', '1');
});
test('Current Goal highlighting', () => {
render(
<StatusList
title="Goals"
items={items}
type="goal"
activeIds={{}}
currentGoalIndex={0}
/>
);
// Using regex to handle the "(Current)" text
expect(screen.getByText(/Item 1/)).toBeInTheDocument();
expect(screen.getByText(/(Current)/)).toBeInTheDocument();
});
});
describe('RobotConnected', () => {
let mockEventSource: any;
beforeAll(() => {
Object.defineProperty(window, 'EventSource', {
writable: true,
value: jest.fn().mockImplementation(() => ({
close: jest.fn(),
onmessage: null,
})),
});
});
beforeEach(() => {
mockEventSource = new window.EventSource('url');
(window.EventSource as unknown as jest.Mock).mockClear();
(window.EventSource as unknown as jest.Mock).mockImplementation(() => mockEventSource);
});
test('displays disconnected initially', () => {
render(<RobotConnected />);
expect(screen.getByText('● Robot is disconnected')).toBeInTheDocument();
});
test('updates to connected when SSE receives true', async () => {
render(<RobotConnected />);
act(() => {
if(mockEventSource.onmessage) {
mockEventSource.onmessage({ data: 'true' } as MessageEvent);
}
});
expect(await screen.findByText('● Robot is connected')).toBeInTheDocument();
});
test('handles invalid JSON gracefully', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
render(<RobotConnected />);
act(() => {
if(mockEventSource.onmessage) {
mockEventSource.onmessage({ data: 'invalid-json' } as MessageEvent);
}
});
// Should catch error and log it, state remains disconnected
expect(consoleSpy).toHaveBeenCalledWith('Ping message not in correct format:', 'invalid-json');
consoleSpy.mockRestore();
});
test('logs error if state update fails (inner catch block)', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
// 1. Force useState to return a setter that throws an error
const mockThrowingSetter = jest.fn(() => { throw new Error('Forced State Error'); });
// We use mockImplementation to return [currentState, throwingSetter]
const useStateSpy = jest.spyOn(React, 'useState')
.mockImplementation(() => [null, mockThrowingSetter]);
render(<RobotConnected />);
// 2. Trigger the event with VALID JSON ("true")
// This passes the first JSON.parse try/catch,
// but fails when calling setConnected(true) because of our mock.
await act(async () => {
if (mockEventSource.onmessage) {
mockEventSource.onmessage({ data: 'true' } as MessageEvent);
}
});
// 3. Verify the specific error log from line 205
expect(consoleSpy).toHaveBeenCalledWith("couldnt extract connected from incoming ping data");
// Cleanup spies
useStateSpy.mockRestore();
consoleSpy.mockRestore();
});
});
});

View File

@@ -1,167 +0,0 @@
import { render, screen, act, cleanup, fireEvent } from '@testing-library/react';
import Robot from '../../../src/pages/Robot/Robot';
// Mock EventSource
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) {
this.onmessage?.({ data } as MessageEvent);
}
close() {
this.closed = true;
}
}
// Mock global EventSource
beforeAll(() => {
(globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url));
});
// Mock fetch
beforeEach(() => {
globalThis.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ reply: 'ok' }),
})
) as jest.Mock;
});
// Cleanup
afterEach(() => {
cleanup();
jest.restoreAllMocks();
mockInstances.length = 0;
});
describe('Robot', () => {
test('renders initial state', () => {
render(<Robot />);
expect(screen.getByText('Robot interaction')).toBeInTheDocument();
expect(screen.getByText('Force robot speech')).toBeInTheDocument();
expect(screen.getByText('Listening 🔴')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter a message')).toBeInTheDocument();
});
test('sends message via button', async () => {
render(<Robot />);
const input = screen.getByPlaceholderText('Enter a message');
const button = screen.getByText('Speak');
fireEvent.change(input, { target: { value: 'Hello' } });
await act(async () => fireEvent.click(button));
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://localhost:8000/message',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Hello' }),
})
);
});
test('sends message via Enter key', async () => {
render(<Robot />);
const input = screen.getByPlaceholderText('Enter a message');
fireEvent.change(input, { target: { value: 'Hi Enter' } });
await act(async () =>
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 })
);
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://localhost:8000/message',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Hi Enter' }),
})
);
expect((input as HTMLInputElement).value).toBe('');
});
test('handles fetch errors', async () => {
globalThis.fetch = jest.fn(() => Promise.reject('Network error')) as jest.Mock;
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<Robot />);
const input = screen.getByPlaceholderText('Enter a message');
const button = screen.getByText('Speak');
fireEvent.change(input, { target: { value: 'Error test' } });
await act(async () => fireEvent.click(button));
expect(consoleSpy).toHaveBeenCalledWith(
'Error sending message: ',
'Network error'
);
});
test('updates conversation on SSE', async () => {
render(<Robot />);
const eventSource = mockInstances[0];
await act(async () => {
eventSource.sendMessage(JSON.stringify({ voice_active: true }));
eventSource.sendMessage(JSON.stringify({ speech: 'User says hi' }));
eventSource.sendMessage(JSON.stringify({ llm_response: 'Assistant replies' }));
});
expect(screen.getByText('Listening 🟢')).toBeInTheDocument();
expect(screen.getByText('User says hi')).toBeInTheDocument();
expect(screen.getByText('Assistant replies')).toBeInTheDocument();
});
test('handles invalid SSE JSON', async () => {
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
render(<Robot />);
const eventSource = mockInstances[0];
await act(async () => eventSource.sendMessage('bad-json'));
expect(logSpy).toHaveBeenCalledWith('Unparsable SSE message:', 'bad-json');
});
test('resets conversation with Reset button', async () => {
render(<Robot />);
const eventSource = mockInstances[0];
await act(async () =>
eventSource.sendMessage(JSON.stringify({ speech: 'Hello' }))
);
expect(screen.getByText('Hello')).toBeInTheDocument();
fireEvent.click(screen.getByText('Reset'));
expect(screen.queryByText('Hello')).not.toBeInTheDocument();
});
test('toggles conversationIndex with Stop/Start button', () => {
render(<Robot />);
const stopButton = screen.getByText('Stop');
fireEvent.click(stopButton);
expect(screen.getByText('Start')).toBeInTheDocument();
fireEvent.click(screen.getByText('Start'));
expect(screen.getByText('Stop')).toBeInTheDocument();
});
test('closes EventSource on unmount', () => {
const { unmount } = render(<Robot />);
const eventSource = mockInstances[0];
const closeSpy = jest.spyOn(eventSource, 'close');
unmount();
expect(closeSpy).toHaveBeenCalled();
expect(eventSource.closed).toBe(true);
});
});

View File

@@ -1,83 +0,0 @@
import { render, screen, fireEvent } from "@testing-library/react";
import SimpleProgram from "../../../src/pages/SimpleProgram/SimpleProgram";
import useProgramStore from "../../../src/utils/programStore";
/**
* Helper to preload the program store before rendering.
*/
function loadProgram(phases: Record<string, unknown>[]) {
useProgramStore.getState().setProgramState({ phases });
}
describe("SimpleProgram", () => {
beforeEach(() => {
loadProgram([]);
});
test("shows empty state when no program is loaded", () => {
render(<SimpleProgram />);
expect(screen.getByText("No program loaded.")).toBeInTheDocument();
});
test("renders first phase content", () => {
loadProgram([
{
id: "phase-1",
norms: [{ id: "n1", norm: "Be polite" }],
goals: [{ id: "g1", description: "Finish task", achieved: true }],
triggers: [{ id: "t1", label: "Keyword trigger" }],
},
]);
render(<SimpleProgram />);
expect(screen.getByText("Phase 1 / 1")).toBeInTheDocument();
expect(screen.getByText("Be polite")).toBeInTheDocument();
expect(screen.getByText("Finish task")).toBeInTheDocument();
expect(screen.getByText("Keyword trigger")).toBeInTheDocument();
});
test("allows navigating between phases", () => {
loadProgram([
{
id: "phase-1",
norms: [],
goals: [],
triggers: [],
},
{
id: "phase-2",
norms: [{ id: "n2", norm: "Be careful" }],
goals: [],
triggers: [],
},
]);
render(<SimpleProgram />);
expect(screen.getByText("Phase 1 / 2")).toBeInTheDocument();
fireEvent.click(screen.getByText("Next ▶"));
expect(screen.getByText("Phase 2 / 2")).toBeInTheDocument();
expect(screen.getByText("Be careful")).toBeInTheDocument();
});
test("prev button is disabled on first phase", () => {
loadProgram([
{ id: "phase-1", norms: [], goals: [], triggers: [] },
]);
render(<SimpleProgram />);
expect(screen.getByText("◀ Prev")).toBeDisabled();
});
test("next button is disabled on last phase", () => {
loadProgram([
{ id: "phase-1", norms: [], goals: [], triggers: [] },
]);
render(<SimpleProgram />);
expect(screen.getByText("Next ▶")).toBeDisabled();
});
});

View File

@@ -1,242 +0,0 @@
import {act} from '@testing-library/react';
import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
import { mockReactFlow } from '../../../setupFlowTests.ts';
beforeAll(() => {
mockReactFlow();
});
describe("UndoRedo Middleware", () => {
beforeEach(() => {
jest.useFakeTimers();
});
test("pushSnapshot adds a snapshot to past and clears future", () => {
const store = useFlowStore;
store.setState({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: [],
past: [],
future: [{
nodes: [
{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
},
],
edges: []
}],
});
act(() => {
store.getState().pushSnapshot();
})
const state = store.getState();
expect(state.past.length).toBe(1);
expect(state.past[0]).toEqual({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: []
});
expect(state.future).toEqual([]);
});
test("pushSnapshot does nothing during batch action", () => {
const store = useFlowStore;
act(() => {
store.setState({ isBatchAction: true });
store.getState().pushSnapshot();
})
expect(store.getState().past.length).toBe(0);
});
test("undo restores last snapshot and pushes current snapshot to future", () => {
const store = useFlowStore;
// initial state
store.setState({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: []
});
act(() => {
store.getState().pushSnapshot();
// modified state
store.setState({
nodes: [{
id: 'B',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'B'}
}],
edges: []
});
store.getState().undo();
})
expect(store.getState().nodes).toEqual([{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}]);
expect(store.getState().future.length).toBe(1);
expect(store.getState().future[0]).toEqual({
nodes: [{
id: 'B',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'B'}
}],
edges: []
});
});
test("undo does nothing when past is empty", () => {
const store = useFlowStore;
store.setState({past: []});
act(() => { store.getState().undo(); });
expect(store.getState().nodes).toEqual([]);
expect(store.getState().future).toEqual([]);
});
test("redo restores last future snapshot and pushes current to past", () => {
const store = useFlowStore;
// initial
store.setState({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: []
});
act(() => {
store.getState().pushSnapshot();
store.setState({
nodes: [{
id: 'B',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'B'}
}],
edges: []
});
store.getState().undo();
// redo should restore node with id 'B'
store.getState().redo();
})
expect(store.getState().nodes).toEqual([{
id: 'B',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'B'}
}]);
expect(store.getState().past.length).toBe(1); // snapshot A stored
expect(store.getState().past[0]).toEqual({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: []
});
});
test("redo does nothing when future is empty", () => {
const store = useFlowStore;
store.setState({past: []});
act(() => { store.getState().redo(); });
expect(store.getState().nodes).toEqual([]);
});
test("beginBatchAction pushes snapshot and sets isBatchAction=true", () => {
const store = useFlowStore;
store.setState({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: []
});
act(() => { store.getState().beginBatchAction(); });
expect(store.getState().isBatchAction).toBe(true);
expect(store.getState().past.length).toBe(1);
});
test("endBatchAction sets isBatchAction=false after timeout", () => {
const store = useFlowStore;
store.setState({ isBatchAction: true });
act(() => { store.getState().endBatchAction(); });
// isBatchAction should remain true before the timer has advanced
expect(store.getState().isBatchAction).toBe(true);
jest.advanceTimersByTime(10);
// it should now be set to false as the timer has advanced enough
expect(store.getState().isBatchAction).toBe(false);
});
test("multiple beginBatchAction calls clear the timeout", () => {
const store = useFlowStore;
act(() => {
store.getState().beginBatchAction();
store.getState().endBatchAction(); // starts timeout
store.getState().beginBatchAction(); // should clear previous timeout
});
jest.advanceTimersByTime(10);
// After advancing the timers, isBatchAction should still be true,
// as the timeout should have been cleared
expect(store.getState().isBatchAction).toBe(true);
});
});

View File

@@ -1,5 +1,986 @@
describe('not yet implemented', () => {
test('nothing yet', () => {
expect(true);
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("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);
})
})
});

Some files were not shown because too many files have changed in this diff Show More