Compare commits
91 Commits
fix/text-f
...
feat/monit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bd67debf3 | ||
|
|
e53e1a3958 | ||
|
|
7a89b0aedd | ||
|
|
ed2e0ecb7b | ||
|
|
c25073f20d | ||
|
|
905b9da815 | ||
|
|
58ab95eee1 | ||
|
|
8149d67491 | ||
|
|
647ea1979a | ||
|
|
501f56e009 | ||
|
|
ed11680771 | ||
|
|
80aa1fca2b | ||
|
|
086caea737 | ||
|
|
c639a37dfc | ||
|
|
5e22ed8806 | ||
|
|
1bfcfc0458 | ||
|
|
95397ceccc | ||
|
|
e9ea0fb37e | ||
|
|
413fb05cd8 | ||
|
|
608bd54617 | ||
|
|
c167144b4d | ||
|
|
d41a45793f | ||
|
|
f0c250626f | ||
|
|
d9faeafe32 | ||
|
|
df255a83b6 | ||
|
|
e680ad3195 | ||
|
|
ea85a05f27 | ||
|
|
7d3c63630a | ||
|
|
518045ed1c | ||
|
|
3d7997e8d0 | ||
|
|
fe13017f2d | ||
|
|
3bcc865dd8 | ||
|
|
7640c32830 | ||
|
|
a95fbd15e6 | ||
|
|
d4393e7635 | ||
|
|
ff4ee7e111 | ||
|
|
2261da9915 | ||
|
|
c5d9b8342d | ||
|
|
381fdaca1a | ||
|
|
0ec6f556c9 | ||
|
|
10a2c0c3cd | ||
|
|
5287cb3bf3 | ||
|
|
32c8c985c3 | ||
|
|
690880faa4 | ||
|
|
f87c7fed03 | ||
|
|
79b645df88 | ||
|
|
1dfc14ede8 | ||
|
|
c84f730782 | ||
|
|
f892db7be2 | ||
|
|
4f7c730916 | ||
|
|
1f70ebd799 | ||
|
|
f37df1c726 | ||
|
|
8c2e51114e | ||
|
|
bd7620a182 | ||
|
|
bb4e9d0b26 | ||
|
|
0bbb6101ae | ||
|
|
3e73e78ee9 | ||
|
|
941658a817 | ||
|
|
eabc7c8b04 | ||
|
|
000d221538 | ||
|
|
047e22ce4d | ||
|
|
35ff58eca8 | ||
|
|
c5dc825ca3 | ||
|
|
96bd1c697c | ||
|
|
476c538464 | ||
|
|
fe8e04d305 | ||
|
|
2f7a48415b | ||
|
|
221fbe42c2 | ||
|
|
22da2ca664 | ||
|
|
be4fb0e7cd | ||
|
|
bb7d24b7be | ||
|
|
3cbf983b41 | ||
|
|
45e133e255 | ||
|
|
87cf723c95 | ||
|
|
df4346150e | ||
|
|
8733bb3c04 | ||
|
|
1b8095376b | ||
|
|
571908cd70 | ||
|
|
333bd6e6fd | ||
|
|
5e707224cf | ||
|
|
6a88aa3d75 | ||
|
|
32938edca8 | ||
|
|
4181454a73 | ||
|
|
ea17b95a53 | ||
|
|
fa046e6b2a | ||
|
|
1a0fd92e0f | ||
|
|
60b925e4e7 | ||
|
|
72d61e3985 | ||
|
|
ec4f45b984 | ||
|
|
b78cd53baa | ||
|
|
10522b71c3 |
77
.githooks/check-branch-name.sh
Executable file
77
.githooks/check-branch-name.sh
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This script checks if the current branch name follows the specified format.
|
||||
# It's designed to be used as a 'pre-commit' git hook.
|
||||
|
||||
# Format: <type>/<short-description>
|
||||
# Example: feat/add-user-login
|
||||
|
||||
# --- Configuration ---
|
||||
# An array of allowed commit types
|
||||
ALLOWED_TYPES=(feat fix refactor perf style test docs build chore revert)
|
||||
# An array of branches to ignore
|
||||
IGNORED_BRANCHES=(main dev demo)
|
||||
|
||||
# --- Colors for Output ---
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# --- Helper Functions ---
|
||||
error_exit() {
|
||||
echo -e "${RED}ERROR: $1${NC}" >&2
|
||||
echo -e "${YELLOW}Branch name format is incorrect. Aborting commit.${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Main Logic ---
|
||||
|
||||
# 1. Get the current branch name
|
||||
BRANCH_NAME=$(git symbolic-ref --short HEAD)
|
||||
|
||||
# 2. Check if the current branch is in the ignored list
|
||||
for ignored_branch in "${IGNORED_BRANCHES[@]}"; do
|
||||
if [ "$BRANCH_NAME" == "$ignored_branch" ]; then
|
||||
echo -e "${GREEN}Branch check skipped for default branch: $BRANCH_NAME${NC}"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
# 3. Validate the overall structure: <type>/<description>
|
||||
if ! [[ "$BRANCH_NAME" =~ ^[a-z]+/.+$ ]]; then
|
||||
error_exit "Branch name must be in the format: <type>/<short-description>\nExample: feat/add-user-login"
|
||||
fi
|
||||
|
||||
# 4. Extract the type and description
|
||||
TYPE=$(echo "$BRANCH_NAME" | cut -d'/' -f1)
|
||||
DESCRIPTION=$(echo "$BRANCH_NAME" | cut -d'/' -f2-)
|
||||
|
||||
# 5. Validate the <type>
|
||||
type_valid=false
|
||||
for allowed_type in "${ALLOWED_TYPES[@]}"; do
|
||||
if [ "$TYPE" == "$allowed_type" ]; then
|
||||
type_valid=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$type_valid" == false ]; then
|
||||
error_exit "Invalid type '$TYPE'.\nAllowed types are: ${ALLOWED_TYPES[*]}"
|
||||
fi
|
||||
|
||||
# 6. Validate the <short-description>
|
||||
# Regex breakdown:
|
||||
# ^[a-z0-9]+ - Starts with one or more lowercase letters/numbers (the first word).
|
||||
# (-[a-z0-9]+){0,5} - Followed by a group of (dash + word) 0 to 5 times.
|
||||
# $ - End of the string.
|
||||
# This entire pattern enforces 1 to 6 words total, separated by dashes.
|
||||
DESCRIPTION_REGEX="^[a-z0-9]+(-[a-z0-9]+){0,5}$"
|
||||
|
||||
if ! [[ "$DESCRIPTION" =~ $DESCRIPTION_REGEX ]]; then
|
||||
error_exit "Invalid short description '$DESCRIPTION'.\nIt must be a maximum of 6 words, all lowercase, separated by dashes.\nExample: add-new-user-authentication-feature"
|
||||
fi
|
||||
|
||||
# If all checks pass, exit successfully
|
||||
echo -e "${GREEN}Branch name '$BRANCH_NAME' is valid.${NC}"
|
||||
exit 0
|
||||
138
.githooks/check-commit-msg.sh
Executable file
138
.githooks/check-commit-msg.sh
Executable file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This script checks if a commit message follows the specified format.
|
||||
# It's designed to be used as a 'commit-msg' git hook.
|
||||
|
||||
# Format:
|
||||
# <type>: <short description>
|
||||
#
|
||||
# [optional]<body>
|
||||
#
|
||||
# [ref/close]: <issue identifier>
|
||||
|
||||
# --- Configuration ---
|
||||
# An array of allowed commit types
|
||||
ALLOWED_TYPES=(feat fix refactor perf style test docs build chore revert)
|
||||
|
||||
# --- Colors for Output ---
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# The first argument to the hook is the path to the file containing the commit message
|
||||
COMMIT_MSG_FILE=$1
|
||||
|
||||
# --- Automated Commit Detection ---
|
||||
|
||||
# Read the first line (header) for initial checks
|
||||
HEADER=$(head -n 1 "$COMMIT_MSG_FILE")
|
||||
|
||||
echo 'Given commit message:'
|
||||
echo $HEADER
|
||||
|
||||
# Check for Merge commits (covers 'git merge' and PR merges from GitHub/GitLab)
|
||||
# Examples: "Merge branch 'main' into ...", "Merge pull request #123 from ..."
|
||||
MERGE_PATTERN="^Merge (remote-tracking )?(branch|pull request|tag) .*"
|
||||
if [[ "$HEADER" =~ $MERGE_PATTERN ]]; then
|
||||
echo -e "${GREEN}Merge commit detected by message content. Skipping validation.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check for Revert commits
|
||||
# Example: "Revert "feat: add new feature""
|
||||
REVERT_PATTERN="^Revert \".*\""
|
||||
if [[ "$HEADER" =~ $REVERT_PATTERN ]]; then
|
||||
echo -e "${GREEN}Revert commit detected by message content. Skipping validation.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check for Cherry-pick commits (this pattern appears at the end of the message)
|
||||
# Example: "(cherry picked from commit deadbeef...)"
|
||||
# We use grep -q to search the whole file quietly.
|
||||
CHERRY_PICK_PATTERN="\(cherry picked from commit [a-f0-9]{7,40}\)"
|
||||
if grep -qE "$CHERRY_PICK_PATTERN" "$COMMIT_MSG_FILE"; then
|
||||
echo -e "${GREEN}Cherry-pick detected by message content. Skipping validation.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check for Squash
|
||||
# Example: "Squash commits ..."
|
||||
SQUASH_PATTERN="^Squash .+"
|
||||
if [[ "$HEADER" =~ $SQUASH_PATTERN ]]; then
|
||||
echo -e "${GREEN}Squash commit detected by message content. Skipping validation.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Validation Functions ---
|
||||
|
||||
# Function to print an error message and exit
|
||||
# Usage: error_exit "Your error message here"
|
||||
error_exit() {
|
||||
# >&2 redirects echo to stderr
|
||||
echo -e "${RED}ERROR: $1${NC}" >&2
|
||||
echo -e "${YELLOW}Commit message format is incorrect. Aborting commit.${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Main Logic ---
|
||||
|
||||
# 1. Read the header (first line) of the commit message
|
||||
HEADER=$(head -n 1 "$COMMIT_MSG_FILE")
|
||||
|
||||
# 2. Validate the header format: <type>: <description>
|
||||
# Regex breakdown:
|
||||
# ^(type1|type2|...) - Starts with one of the allowed types
|
||||
# : - Followed by a literal colon
|
||||
# \s - Followed by a single space
|
||||
# .+ - Followed by one or more characters for the description
|
||||
# $ - End of the line
|
||||
TYPES_REGEX=$(
|
||||
IFS="|"
|
||||
echo "${ALLOWED_TYPES[*]}"
|
||||
)
|
||||
HEADER_REGEX="^($TYPES_REGEX): .+$"
|
||||
|
||||
if ! [[ "$HEADER" =~ $HEADER_REGEX ]]; then
|
||||
error_exit "Invalid header format.\n\nHeader must be in the format: <type>: <short description>\nAllowed types: ${ALLOWED_TYPES[*]}\nExample: feat: add new user authentication feature"
|
||||
fi
|
||||
|
||||
# Only validate footer if commit type is not chore
|
||||
TYPE=$(echo "$HEADER" | cut -d':' -f1)
|
||||
if [ "$TYPE" != "chore" ]; then
|
||||
# 3. Validate the footer (last line) of the commit message
|
||||
FOOTER=$(tail -n 1 "$COMMIT_MSG_FILE")
|
||||
|
||||
# Regex breakdown:
|
||||
# ^(ref|close) - Starts with 'ref' or 'close'
|
||||
# : - Followed by a literal colon
|
||||
# \s - Followed by a single space
|
||||
# N25B- - Followed by the literal string 'N25B-'
|
||||
# [0-9]+ - Followed by one or more digits
|
||||
# $ - End of the line
|
||||
FOOTER_REGEX="^(ref|close): N25B-[0-9]+$"
|
||||
|
||||
if ! [[ "$FOOTER" =~ $FOOTER_REGEX ]]; then
|
||||
error_exit "Invalid footer format.\n\nFooter must be in the format: [ref/close]: <issue identifier>\nExample: ref: N25B-123"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. If the message has more than 2 lines, validate the separator
|
||||
# A blank line must exist between the header and the body.
|
||||
LINE_COUNT=$(wc -l <"$COMMIT_MSG_FILE" | xargs) # xargs trims whitespace
|
||||
|
||||
# We only care if there is a body. Header + Footer = 2 lines.
|
||||
# Header + Blank Line + Body... + Footer > 2 lines.
|
||||
if [ "$LINE_COUNT" -gt 2 ]; then
|
||||
# Get the second line
|
||||
SECOND_LINE=$(sed -n '2p' "$COMMIT_MSG_FILE")
|
||||
|
||||
# Check if the second line is NOT empty. If it's not, it's an error.
|
||||
if [ -n "$SECOND_LINE" ]; then
|
||||
error_exit "Missing blank line between header and body.\n\nThe second line of your commit message must be empty if a body is present."
|
||||
fi
|
||||
fi
|
||||
|
||||
# If all checks pass, exit with success
|
||||
echo -e "${GREEN}Commit message is valid.${NC}"
|
||||
exit 0
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
commit_msg_file=$1
|
||||
commit_msg=$(cat "$commit_msg_file")
|
||||
|
||||
if echo "$commit_msg" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert): .+"; then
|
||||
if echo "$commit_msg" | grep -Eq "^(ref|close):\sN25B-.+"; then
|
||||
exit 0
|
||||
else
|
||||
echo "❌ Commit message invalid! Must end with [ref/close]: N25B-000"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ Commit message invalid! Must start with <type>: <description>"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Get current branch
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
if echo "$branch" | grep -Eq "(dev|main)"; then
|
||||
echo 0
|
||||
fi
|
||||
|
||||
# allowed pattern <type/>
|
||||
if echo "$branch" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert)\/\w+(-\w+){0,5}$"; then
|
||||
exit 0
|
||||
else
|
||||
echo "❌ Invalid branch name: $branch"
|
||||
echo "Branch must be named <type>/<description-of-branch> (must have one to six words separated by a dash)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "#<type>: <description>
|
||||
|
||||
#[optional body]
|
||||
|
||||
#[optional footer(s)]
|
||||
|
||||
#[ref/close]: <issue identifier>" > $1
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -24,4 +24,7 @@ dist-ssr
|
||||
*.sw?
|
||||
|
||||
# Coverage report
|
||||
coverage
|
||||
coverage
|
||||
|
||||
# Documentation pages (can be generated)
|
||||
docs
|
||||
|
||||
53
.gitlab-ci.yml
Normal file
53
.gitlab-ci.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
# ---------- GLOBAL SETUP ---------- #
|
||||
workflow:
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
|
||||
stages:
|
||||
- install
|
||||
- lint
|
||||
- test
|
||||
|
||||
variables:
|
||||
NODE_VERSION: "24.11.1"
|
||||
BASE_LAYER: trixie-slim
|
||||
|
||||
default:
|
||||
image: docker.io/library/node:${NODE_VERSION}-${BASE_LAYER}
|
||||
cache:
|
||||
key: "${CI_COMMIT_REF_SLUG}"
|
||||
paths:
|
||||
- node_modules/
|
||||
policy: pull-push
|
||||
|
||||
# --------- INSTALLING --------- #
|
||||
install:
|
||||
stage: install
|
||||
tags:
|
||||
- install
|
||||
script:
|
||||
- npm ci
|
||||
artifacts:
|
||||
paths:
|
||||
- node_modules/
|
||||
expire_in: 1h
|
||||
|
||||
# ---------- LINTING ---------- #
|
||||
lint:
|
||||
stage: lint
|
||||
needs:
|
||||
- install
|
||||
tags:
|
||||
- lint
|
||||
script:
|
||||
- npm run lint
|
||||
|
||||
# ---------- TESTING ---------- #
|
||||
test:
|
||||
stage: test
|
||||
needs:
|
||||
- install
|
||||
tags:
|
||||
- test
|
||||
script:
|
||||
- npm run test
|
||||
1
.husky/commit-msg
Normal file
1
.husky/commit-msg
Normal file
@@ -0,0 +1 @@
|
||||
sh .githooks/check-commit-msg.sh $1
|
||||
3
.husky/pre-commit
Normal file
3
.husky/pre-commit
Normal file
@@ -0,0 +1,3 @@
|
||||
sh .githooks/check-branch-name.sh
|
||||
|
||||
npm run lint
|
||||
26
README.md
26
README.md
@@ -28,16 +28,26 @@ npm run dev
|
||||
|
||||
It should automatically reload when you save changes.
|
||||
|
||||
## GitHooks
|
||||
## Git Hooks
|
||||
|
||||
To activate automatic commits/branch name checks run:
|
||||
To activate automatic linting, branch name checks and commit message checks, run:
|
||||
|
||||
```shell
|
||||
git config --local core.hooksPath .githooks
|
||||
```bash
|
||||
npm run prepare
|
||||
```
|
||||
|
||||
If your commit fails its either:
|
||||
branch name != <type>/description-of-branch ,
|
||||
commit name != <type>: description of the commit.
|
||||
<ref>: N25B-Num's
|
||||
You might get an error along the lines of `Can't install pre-commit with core.hooksPath` set. To fix this, simply unset the hooksPath by running:
|
||||
|
||||
```bash
|
||||
git config --local --unset core.hooksPath
|
||||
```
|
||||
|
||||
Then run the pre-commit install commands again.
|
||||
|
||||
## Documentation
|
||||
|
||||
Generate documentation webpages with the command:
|
||||
|
||||
```shell
|
||||
npx typedoc --entryPointStrategy Expand src
|
||||
```
|
||||
|
||||
265
package-lock.json
generated
265
package-lock.json
generated
@@ -28,10 +28,12 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"ts-jest": "^29.4.5",
|
||||
"typedoc": "^0.28.14",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.44.0",
|
||||
"vite": "^7.1.7"
|
||||
@@ -1348,6 +1350,20 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@gerrit0/mini-shiki": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.15.0.tgz",
|
||||
"integrity": "sha512-L5IHdZIDa4bG4yJaOzfasOH/o22MCesY0mx+n6VATbaiCtMeR59pdRqYk4bEiQkIHfxsHPNgdi7VJlZb2FhdMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/engine-oniguruma": "^3.15.0",
|
||||
"@shikijs/langs": "^3.15.0",
|
||||
"@shikijs/themes": "^3.15.0",
|
||||
"@shikijs/types": "^3.15.0",
|
||||
"@shikijs/vscode-textmate": "^10.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -1460,9 +1476,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
||||
"version": "3.14.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
|
||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2351,6 +2367,55 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@shikijs/engine-oniguruma": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.15.0.tgz",
|
||||
"integrity": "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/types": "3.15.0",
|
||||
"@shikijs/vscode-textmate": "^10.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/langs": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.15.0.tgz",
|
||||
"integrity": "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/types": "3.15.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/themes": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.15.0.tgz",
|
||||
"integrity": "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/types": "3.15.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/types": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.15.0.tgz",
|
||||
"integrity": "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/vscode-textmate": "^10.0.2",
|
||||
"@types/hast": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/vscode-textmate": {
|
||||
"version": "10.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
|
||||
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.34.41",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
|
||||
@@ -2637,6 +2702,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
@@ -2738,6 +2813,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
||||
@@ -3324,12 +3406,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.8.6",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz",
|
||||
"integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==",
|
||||
"version": "12.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.1.tgz",
|
||||
"integrity": "sha512-JRPCT5p7NnPdVSIh15AFvUSSm+8GUyz2I6iuBEC1LG2lKgig/L48AM/ImMHCc3ZUCg+AgTOJDaX2fcRyPA9BTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.70",
|
||||
"@xyflow/system": "0.0.72",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
@@ -3367,9 +3449,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.70",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz",
|
||||
"integrity": "sha512-PpC//u9zxdjj0tfTSmZrg3+sRbTz6kop/Amky44U2Dl51sxzDTIUfXMwETOYpmr2dqICWXBIJwXL2a9QWtX2XA==",
|
||||
"version": "0.0.72",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz",
|
||||
"integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
@@ -4970,6 +5052,22 @@
|
||||
"node": ">=10.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/husky": {
|
||||
"version": "9.1.7",
|
||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"husky": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/typicode"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -5908,9 +6006,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6055,6 +6153,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -6095,6 +6203,13 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lunr": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
|
||||
"integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
@@ -6152,6 +6267,44 @@
|
||||
"tmpl": "1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "^4.4.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"mdurl": "^2.0.0",
|
||||
"punycode.js": "^2.3.1",
|
||||
"uc.micro": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
@@ -6704,6 +6857,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
|
||||
@@ -7602,6 +7765,56 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typedoc": {
|
||||
"version": "0.28.14",
|
||||
"resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.14.tgz",
|
||||
"integrity": "sha512-ftJYPvpVfQvFzpkoSfHLkJybdA/geDJ8BGQt/ZnkkhnBYoYW6lBgPQXu6vqLxO4X75dA55hX8Af847H5KXlEFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@gerrit0/mini-shiki": "^3.12.0",
|
||||
"lunr": "^2.3.9",
|
||||
"markdown-it": "^14.1.0",
|
||||
"minimatch": "^9.0.5",
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"bin": {
|
||||
"typedoc": "bin/typedoc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18",
|
||||
"pnpm": ">= 10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x"
|
||||
}
|
||||
},
|
||||
"node_modules/typedoc/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typedoc/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
@@ -7640,6 +7853,13 @@
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uglify-js": {
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
@@ -7738,9 +7958,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -8142,6 +8362,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"lint": "eslint src test",
|
||||
"preview": "vite preview",
|
||||
"test": "jest",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@neodrag/react": "^2.3.1",
|
||||
@@ -30,10 +32,12 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"ts-jest": "^29.4.5",
|
||||
"typedoc": "^0.28.14",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.44.0",
|
||||
"vite": "^7.1.7"
|
||||
|
||||
@@ -3,9 +3,11 @@ 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";
|
||||
import MonitoringPage from './pages/MonitoringPage/MonitoringPage.tsx'
|
||||
|
||||
function App(){
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
@@ -23,7 +25,9 @@ function App(){
|
||||
<Route path="/template" element={<TemplatePage />} />
|
||||
<Route path="/editor" element={<VisProg />} />
|
||||
<Route path="/robot" element={<Robot />} />
|
||||
</Routes>
|
||||
<Route path="/ConnectedRobots" element={<ConnectedRobots />} />
|
||||
<Route path="/MonitoringPage" element={<MonitoringPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
{showLogs && <Logging />}
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,18 @@ 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],
|
||||
@@ -16,6 +24,17 @@ const optionMapping = new Map([
|
||||
["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,
|
||||
@@ -54,8 +73,19 @@ function LevelPredicateElement({
|
||||
</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,
|
||||
@@ -78,6 +108,7 @@ function GlobalLevelFilter({
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize default global level on mount.
|
||||
useEffect(() => {
|
||||
if (filterPredicates.has(GLOBAL_LOG_LEVEL_PREDICATE_KEY)) return;
|
||||
setSelected("INFO");
|
||||
@@ -91,8 +122,21 @@ function GlobalLevelFilter({
|
||||
/>;
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -105,7 +149,7 @@ function AgentLevelFilters({
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Click outside to close
|
||||
// Close dropdown or panels when clicking outside or pressing Escape.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
@@ -124,13 +168,16 @@ function AgentLevelFilters({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Identify which predicates correspond to agents.
|
||||
const agentPredicates = [...filterPredicates.keys()].filter((key) =>
|
||||
key.startsWith(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX));
|
||||
|
||||
/**
|
||||
* Create or change the predicate for an agent. If the level is not given, the global level is used.
|
||||
* @param agentName The name of the agent.
|
||||
* @param level The level to filter by. If not given, the global level is used.
|
||||
/**
|
||||
* 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";
|
||||
@@ -147,6 +194,11 @@ function AgentLevelFilters({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -184,6 +236,17 @@ function AgentLevelFilters({
|
||||
</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
@@ -8,13 +8,26 @@ 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 }),
|
||||
@@ -22,6 +35,16 @@ const useLoggingSettings = create<LoggingSettings>((set) => ({
|
||||
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,
|
||||
@@ -33,7 +56,8 @@ function LogMessage({
|
||||
const record = useCell(recordCell);
|
||||
|
||||
/**
|
||||
* Normalizes the log level number to a multiple of 10, for which there are CSS styles.
|
||||
* 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.
|
||||
@@ -42,8 +66,10 @@ function LogMessage({
|
||||
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]);
|
||||
@@ -65,11 +91,23 @@ function LogMessage({
|
||||
</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;
|
||||
@@ -85,6 +123,12 @@ function LogMessages({ recordCells }: { recordCells: Cell<LogRecord>[] }) {
|
||||
}
|
||||
}, [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" });
|
||||
@@ -111,6 +155,19 @@ function LogMessages({ recordCells }: { recordCells: Cell<LogRecord>[] }) {
|
||||
</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)
|
||||
|
||||
@@ -3,10 +3,23 @@ import {useCallback, useEffect, useRef, useState} from "react";
|
||||
import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../utils/priorityFiltering.ts";
|
||||
import {cell, type Cell} from "../../utils/cellStore.ts";
|
||||
|
||||
/**
|
||||
* Represents a single log record emitted by the backend logging system.
|
||||
*
|
||||
* @property name - The name of the logger or source (e.g., `"agent.core"`).
|
||||
* @property message - The message content of the log record.
|
||||
* @property levelname - The human-readable severity level (e.g., `"INFO"`, `"ERROR"`).
|
||||
* @property levelno - The numeric severity value corresponding to `levelname`.
|
||||
* @property created - The UNIX timestamp (in seconds) when this record was created.
|
||||
* @property relativeCreated - The time (in milliseconds) since the logging system started.
|
||||
* @property reference - (Optional) A reference identifier linking related log messages.
|
||||
* @property firstCreated - Timestamp of the first log in this reference group.
|
||||
* @property firstRelativeCreated - Relative timestamp of the first log in this reference group.
|
||||
*/
|
||||
export type LogRecord = {
|
||||
name: string;
|
||||
message: string;
|
||||
levelname: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string;
|
||||
levelname: 'LLM' | 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string;
|
||||
levelno: number;
|
||||
created: number;
|
||||
relativeCreated: number;
|
||||
@@ -15,29 +28,68 @@ export type LogRecord = {
|
||||
firstRelativeCreated: number;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type LogFilterPredicate = PriorityFilterPredicate<LogRecord> & { value: any };
|
||||
/**
|
||||
* 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 the filter predicates to a log record.
|
||||
* 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.
|
||||
* @returns `true` if the record passes all filters; otherwise `false`.
|
||||
*/
|
||||
const applyFilters = useCallback((log: LogRecord) =>
|
||||
applyPriorityPredicates(log, [...filtersRef.current.values()]), []);
|
||||
|
||||
/** Recomputes the entire filtered list. Use when filter predicates change. */
|
||||
/**
|
||||
* 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();
|
||||
@@ -49,6 +101,7 @@ export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
|
||||
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) {
|
||||
@@ -59,14 +112,14 @@ export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
|
||||
firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated,
|
||||
}));
|
||||
|
||||
// Don't add it to the list again
|
||||
continue;
|
||||
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);
|
||||
}
|
||||
@@ -75,20 +128,23 @@ export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
|
||||
setFiltered(newFiltered);
|
||||
}, [applyFilters, setFiltered]);
|
||||
|
||||
// Reapply filters to all logs, only when filters change
|
||||
// Re-filter all logs whenever filter predicates change.
|
||||
useEffect(() => {
|
||||
filtersRef.current = filterPredicates;
|
||||
recomputeFiltered();
|
||||
}, [filterPredicates, recomputeFiltered]);
|
||||
|
||||
/**
|
||||
* Handle a new log message. Updates the filtered list and to the full history.
|
||||
* @param message The new log message.
|
||||
* 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) => {
|
||||
// Add to the full history for re-filtering on filter changes
|
||||
// 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);
|
||||
@@ -96,12 +152,14 @@ export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
|
||||
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) {
|
||||
@@ -112,20 +170,28 @@ export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
|
||||
firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated,
|
||||
}));
|
||||
|
||||
// Don't add it to the list again
|
||||
return;
|
||||
return; // Do not duplicate reference group entries.
|
||||
} else {
|
||||
// Add the first message with this reference to the registry
|
||||
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");
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import {useEffect, useRef} from "react";
|
||||
|
||||
/**
|
||||
* An element that always scrolls into view when it is rendered. When added to a list, the entire list will scroll to show this element.
|
||||
* 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(() => {
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import {useState} from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import styles from "./TextField.module.css";
|
||||
|
||||
/**
|
||||
* A text input element in our own style that calls `setValue` at every keystroke.
|
||||
* A styled text input that updates its value **in real time** at every keystroke.
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {string} props.value - The value of the text input.
|
||||
* @param {(value: string) => void} props.setValue - A function that sets the value of the text input.
|
||||
* @param {string} [props.placeholder] - The placeholder text for the text input.
|
||||
* @param {string} [props.className] - Additional CSS classes for the text input.
|
||||
* @param {string} [props.id] - The ID of the text input.
|
||||
* @param {string} [props.ariaLabel] - The ARIA label for the text input.
|
||||
* 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 = "",
|
||||
@@ -31,14 +38,19 @@ export function RealtimeTextField({
|
||||
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();
|
||||
};
|
||||
|
||||
const updateOnEnter = (event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); };
|
||||
/** 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"}
|
||||
@@ -57,15 +69,22 @@ export function RealtimeTextField({
|
||||
}
|
||||
|
||||
/**
|
||||
* A text input element in our own style that calls `setValue` once the user presses the enter key or clicks outside the input.
|
||||
* A styled text input that updates its value **only on commit** (when the user
|
||||
* presses Enter or clicks outside the input).
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {string} props.value - The value of the text input.
|
||||
* @param {(value: string) => void} props.setValue - A function that sets the value of the text input.
|
||||
* @param {string} [props.placeholder] - The placeholder text for the text input.
|
||||
* @param {string} [props.className] - Additional CSS classes for the text input.
|
||||
* @param {string} [props.id] - The ID of the text input.
|
||||
* @param {string} [props.ariaLabel] - The ARIA label for the text input.
|
||||
* 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 = "",
|
||||
@@ -86,6 +105,10 @@ export function TextField({
|
||||
}) {
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(value);
|
||||
}, [value]);
|
||||
|
||||
const onCommit = () => setValue(inputValue);
|
||||
|
||||
return <RealtimeTextField
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
/**
|
||||
* A minimal counter component that demonstrates basic React state handling.
|
||||
*
|
||||
* Maintains an internal count value and provides buttons to increment and reset it.
|
||||
*
|
||||
* @returns A JSX element rendering the counter UI.
|
||||
*/
|
||||
function Counter() {
|
||||
/** The current counter value. */
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,7 +26,6 @@ html, body, #root {
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: canvastext;
|
||||
}
|
||||
|
||||
|
||||
60
src/pages/ConnectedRobots/ConnectedRobots.tsx
Normal file
60
src/pages/ConnectedRobots/ConnectedRobots.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Displays the current connection status of a robot in real time.
|
||||
*
|
||||
* Opens an SSE connection to the backend (`/robot/ping_stream`) that emits
|
||||
* simple boolean JSON messages (`true` or `false`). Updates automatically when
|
||||
* the robot connects or disconnects.
|
||||
*
|
||||
* @returns A React element showing the current robot connection status.
|
||||
*/
|
||||
export default function ConnectedRobots() {
|
||||
|
||||
/**
|
||||
* The current connection state:
|
||||
* - `true`: Robot is connected.
|
||||
* - `false`: Robot is not connected.
|
||||
* - `null`: Connection status is unknown (initial check in progress).
|
||||
*/
|
||||
const [connected, setConnected] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Open a Server-Sent Events (SSE) connection to receive live ping updates.
|
||||
// We're expecting a stream of data like that looks like this: `data = False` or `data = True`
|
||||
const eventSource = new EventSource("http://localhost:8000/robot/ping_stream");
|
||||
eventSource.onmessage = (event) => {
|
||||
|
||||
// Expecting messages in JSON format: `true` or `false`
|
||||
console.log("received message:", event.data);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
try {
|
||||
setConnected(data)
|
||||
}
|
||||
catch {
|
||||
console.log("couldnt extract connected from incoming ping data")
|
||||
}
|
||||
|
||||
} catch {
|
||||
console.log("Ping message not in correct format:", event.data);
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up the SSE connection when the component unmounts.
|
||||
return () => eventSource.close();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Is robot currently connected?</h1>
|
||||
<div>
|
||||
<h2>Robot is currently: {connected == null ? "checking..." : (connected ? "connected! 🟢" : "not connected... 🔴")} </h2>
|
||||
<h3>
|
||||
{connected == null ? "If checking continues, make sure CB is properly loaded with robot at least once." : ""}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,14 @@ import { Link } from 'react-router'
|
||||
import pepperLogo from '../../assets/pepper_transp2_small.svg'
|
||||
import styles from './Home.module.css'
|
||||
|
||||
/**
|
||||
* The home page component providing navigation and project branding.
|
||||
*
|
||||
* Renders the Pepper logo and a set of navigational links
|
||||
* implemented via React Router.
|
||||
*
|
||||
* @returns A JSX element representing the app’s home page.
|
||||
*/
|
||||
function Home() {
|
||||
return (
|
||||
<div className={`flex-col ${styles.gapXl}`}>
|
||||
@@ -14,6 +22,8 @@ function Home() {
|
||||
<Link to={"/robot"}>Robot Interaction →</Link>
|
||||
<Link to={"/editor"}>Editor →</Link>
|
||||
<Link to={"/template"}>Template →</Link>
|
||||
<Link to={"/ConnectedRobots"}>Connected Robots →</Link>
|
||||
<Link to={"/MonitoringPage"}>MonitoringPage →</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
175
src/pages/MonitoringPage/MonitoringPage.module.css
Normal file
175
src/pages/MonitoringPage/MonitoringPage.module.css
Normal file
@@ -0,0 +1,175 @@
|
||||
.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: #f5f5f5;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
.experimentOverview {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
|
||||
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: #ccc;
|
||||
}
|
||||
|
||||
.completed {
|
||||
background-color: #5cb85c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.current {
|
||||
background-color: #f0ad4e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.connected {
|
||||
color: green;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pause{
|
||||
background-color: green;
|
||||
}
|
||||
|
||||
.next {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
.restartPhase{
|
||||
background-color: rgb(255, 123, 0);
|
||||
}
|
||||
|
||||
.restartExperiment{
|
||||
background-color: red;
|
||||
}
|
||||
|
||||
/* MAIN GRID */
|
||||
.phaseOverview {
|
||||
grid-area: main;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: repeat(2, auto);
|
||||
gap: 1rem;
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.phaseBox {
|
||||
background: #f9f9f9;
|
||||
border: 1px solid #ddd;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: inset 0 0 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.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 #ddd;
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.checked::before {
|
||||
content: '✔️ ';
|
||||
}
|
||||
|
||||
/* LOGS */
|
||||
.logs {
|
||||
grid-area: logs;
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.logs textarea {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* FOOTER */
|
||||
.controlsSection {
|
||||
grid-area: footer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.gestures,
|
||||
.speech,
|
||||
.directSpeech {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.speechInput {
|
||||
display: flex;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.speechInput input {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.speechInput button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (max-width: 900px) {
|
||||
.phaseOverview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.controlsSection {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
127
src/pages/MonitoringPage/MonitoringPage.tsx
Normal file
127
src/pages/MonitoringPage/MonitoringPage.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import styles from './MonitoringPage.module.css';
|
||||
|
||||
export default function MonitoringPage() {
|
||||
return (
|
||||
<div className={styles.dashboardContainer}>
|
||||
{/* HEADER */}
|
||||
<header className={styles.experimentOverview}>
|
||||
<div className={styles.phaseName}>
|
||||
<h2>Experiment Overview</h2>
|
||||
<p><strong>Phase name:</strong> Rhyming fish</p>
|
||||
<div className={styles.phaseProgress}>
|
||||
<span className={`${styles.phase} ${styles.completed}`}>1</span>
|
||||
<span className={`${styles.phase} ${styles.completed}`}>2</span>
|
||||
<span className={`${styles.phase} ${styles.current}`}>3</span>
|
||||
<span className={styles.phase}>4</span>
|
||||
<span className={styles.phase}>5</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.experimentControls}>
|
||||
<h3>Experiment Controls</h3>
|
||||
<div className={styles.controlsButtons}>
|
||||
<button className={styles.pause}>▶</button>
|
||||
<button className={styles.next}>⏭</button>
|
||||
<button className={styles.restartPhase}>↩</button>
|
||||
<button className={styles.restartExperiment}>⟲</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.connectionStatus}>
|
||||
<h3>Connection:</h3>
|
||||
<p className={styles.connected}>● Robot is connected</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* MAIN GRID */}
|
||||
|
||||
<main className={styles.phaseOverview}>
|
||||
<section className={styles.phaseOverviewText}>
|
||||
<h3>Phase Overview</h3>
|
||||
</section>
|
||||
|
||||
<section className={styles.phaseBox}>
|
||||
<h3>Goals</h3>
|
||||
<ul>
|
||||
<li className={styles.checked}>Convince the RP that you are a fish</li>
|
||||
<li>Reference Shakespeare</li>
|
||||
<li>Give a compliment</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className={styles.phaseBox}>
|
||||
<h3>Triggers</h3>
|
||||
<ul>
|
||||
<li className={styles.checked}>Convince the RP that you are a fish</li>
|
||||
<li>Reference Shakespeare</li>
|
||||
<li>Give a compliment</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className={styles.phaseBox}>
|
||||
<h3>Norms</h3>
|
||||
<ul>
|
||||
<li>Rhyme when talking</li>
|
||||
<li>Talk like a fish</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className={styles.phaseBox}>
|
||||
<h3>Conditional Norms</h3>
|
||||
<ul>
|
||||
<li>“RP is sad” - Be nice</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* 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}>
|
||||
<div className={styles.gestures}>
|
||||
<h4>Controls</h4>
|
||||
<ul>
|
||||
<li>Gesture: Wave Left Hand</li>
|
||||
<li>Gesture: Wave Right Hand</li>
|
||||
<li>Gesture: Left Thumbs Up</li>
|
||||
<li>Gesture: Right Thumbs Up</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.speech}>
|
||||
<h4>Speech Options</h4>
|
||||
<ul>
|
||||
<li>\"Hello, my name is pepper.\"</li>
|
||||
<li>\"How is the weather today?\"</li>
|
||||
<li>\"I like your outfit, very pretty.\"</li>
|
||||
<li>\"How is your day going?\"</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.directSpeech}>
|
||||
<h4>Direct Pepper Speech</h4>
|
||||
<ul>
|
||||
<li>[time] Send: *Previous message*</li>
|
||||
<li>[time] Send: *Previous message*</li>
|
||||
<li>[time] Send: *Previous message*</li>
|
||||
</ul>
|
||||
<div className={styles.speechInput}>
|
||||
<input type="text" placeholder="Type message..." />
|
||||
<button>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,34 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Displays a live robot interaction panel with user input, conversation history,
|
||||
* and real-time updates from the robot backend via Server-Sent Events (SSE).
|
||||
*
|
||||
* @returns A React element rendering the interactive robot UI.
|
||||
*/
|
||||
export default function Robot() {
|
||||
/** The text message currently entered by the user. */
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
/** Whether the robot’s microphone or listening mode is currently active. */
|
||||
const [listening, setListening] = useState(false);
|
||||
const [conversation, setConversation] = useState<{"role": "user" | "assistant", "content": string}[]>([])
|
||||
/** The ongoing conversation history as a sequence of user/assistant messages. */
|
||||
const [conversation, setConversation] = useState<
|
||||
{"role": "user" | "assistant", "content": string}[]>([])
|
||||
/** Reference to the scrollable conversation container for auto-scrolling. */
|
||||
const conversationRef = useRef<HTMLDivElement | null>(null);
|
||||
/**
|
||||
* Index used to force refresh the SSE connection or clear conversation.
|
||||
* Incrementing this value triggers a reset of the live data stream.
|
||||
*/
|
||||
const [conversationIndex, setConversationIndex] = useState(0);
|
||||
|
||||
/**
|
||||
* Sends a message to the robot backend.
|
||||
*
|
||||
* Makes a POST request to `/message` with the user’s text.
|
||||
* The backend may respond with confirmation or error information.
|
||||
*/
|
||||
const sendMessage = async () => {
|
||||
try {
|
||||
const response = await fetch("http://localhost:8000/message", {
|
||||
@@ -24,6 +45,17 @@ export default function Robot() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Establishes a persistent Server-Sent Events (SSE) connection
|
||||
* to receive real-time updates from the robot backend.
|
||||
*
|
||||
* Handles three event types:
|
||||
* - `voice_active`: whether the robot is currently listening.
|
||||
* - `speech`: recognized user speech input.
|
||||
* - `llm_response`: the robot’s language model-generated reply.
|
||||
*
|
||||
* The connection resets whenever `conversationIndex` changes.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource("http://localhost:8000/sse");
|
||||
|
||||
@@ -43,6 +75,10 @@ export default function Robot() {
|
||||
};
|
||||
}, [conversationIndex]);
|
||||
|
||||
/**
|
||||
* Automatically scrolls the conversation view to the bottom
|
||||
* whenever a new message is added.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!conversationRef || !conversationRef.current) return;
|
||||
conversationRef.current.scrollTop = conversationRef.current.scrollHeight;
|
||||
|
||||
@@ -7,31 +7,6 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.node-text-input {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5pt;
|
||||
padding: 4px 8px;
|
||||
outline: none;
|
||||
background-color: white;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.node-text-input:focus {
|
||||
border-color: gainsboro;
|
||||
}
|
||||
|
||||
.node-text-input:read-only {
|
||||
cursor: pointer;
|
||||
background-color: whitesmoke;
|
||||
}
|
||||
|
||||
.node-text-input:read-only:hover {
|
||||
border-color: gainsboro;
|
||||
}
|
||||
|
||||
.dnd-panel {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: auto;
|
||||
@@ -67,7 +42,7 @@
|
||||
}
|
||||
|
||||
.node-norm {
|
||||
outline: forestgreen solid 2pt;
|
||||
outline: rgb(0, 149, 25) solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem forestgreen);
|
||||
}
|
||||
|
||||
@@ -151,4 +126,3 @@
|
||||
outline: red solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem red);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,35 +7,17 @@ import {
|
||||
MarkerType,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import {useEffect} from "react";
|
||||
import {useShallow} from 'zustand/react/shallow';
|
||||
|
||||
import {
|
||||
StartNodeComponent,
|
||||
EndNodeComponent,
|
||||
PhaseNodeComponent,
|
||||
NormNodeComponent,
|
||||
GoalNodeComponent,
|
||||
} from './visualProgrammingUI/components/NodeDefinitions.tsx';
|
||||
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
||||
import graphReducer from "./visualProgrammingUI/GraphReducer.ts";
|
||||
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
||||
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
||||
import styles from './VisProg.module.css'
|
||||
import TriggerNodeComponent from "./visualProgrammingUI/components/TriggerNodeComponent.tsx";
|
||||
import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
|
||||
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
|
||||
|
||||
// --| config starting params for flow |--
|
||||
|
||||
/**
|
||||
* contains the types of all nodes that are available in the editor
|
||||
*/
|
||||
const NODE_TYPES = {
|
||||
start: StartNodeComponent,
|
||||
end: EndNodeComponent,
|
||||
phase: PhaseNodeComponent,
|
||||
norm: NormNodeComponent,
|
||||
goal: GoalNodeComponent,
|
||||
trigger: TriggerNodeComponent,
|
||||
};
|
||||
|
||||
/**
|
||||
* defines how the default edge looks inside the editor
|
||||
@@ -57,11 +39,16 @@ 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
|
||||
onReconnect: state.onReconnect,
|
||||
undo: state.undo,
|
||||
redo: state.redo,
|
||||
beginBatchAction: state.beginBatchAction,
|
||||
endBatchAction: state.endBatchAction
|
||||
});
|
||||
|
||||
// --| define ReactFlow editor |--
|
||||
@@ -76,32 +63,57 @@ const VisProgUI = () => {
|
||||
const {
|
||||
nodes, edges,
|
||||
onNodesChange,
|
||||
onEdgesDelete,
|
||||
onEdgesChange,
|
||||
onConnect,
|
||||
onReconnect,
|
||||
onReconnectStart,
|
||||
onReconnectEnd
|
||||
onReconnectEnd,
|
||||
undo,
|
||||
redo,
|
||||
beginBatchAction,
|
||||
endBatchAction
|
||||
} = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore
|
||||
|
||||
// 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`}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
||||
nodeTypes={NODE_TYPES}
|
||||
nodeTypes={NodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onReconnect={onReconnect}
|
||||
onReconnectStart={onReconnectStart}
|
||||
onReconnectEnd={onReconnectEnd}
|
||||
onConnect={onConnect}
|
||||
onNodeDragStart={beginBatchAction}
|
||||
onNodeDragStop={endBatchAction}
|
||||
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/>
|
||||
@@ -110,7 +122,6 @@ const VisProgUI = () => {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Places the VisProgUI component inside a ReactFlowProvider
|
||||
*
|
||||
@@ -128,9 +139,33 @@ function VisualProgrammingUI() {
|
||||
|
||||
// currently outputs the prepared program to the console
|
||||
function runProgram() {
|
||||
const program = graphReducer();
|
||||
console.log(program);
|
||||
const phases = graphReducer();
|
||||
const program = {phases}
|
||||
console.log(JSON.stringify(program, null, 2));
|
||||
fetch(
|
||||
"http://localhost:8000/program",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(program),
|
||||
}
|
||||
).then((res) => {
|
||||
if (!res.ok) throw new Error("Failed communicating with the backend.")
|
||||
console.log("Successfully sent the program to the backend.");
|
||||
}).catch(() => console.log("Failed to send program to the backend."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces the graph into its phases' information and recursively calls their reducing function
|
||||
*/
|
||||
function graphReducer() {
|
||||
const { nodes } = useFlowStore.getState();
|
||||
return nodes
|
||||
.filter((n) => n.type == 'phase')
|
||||
.map((n) => {
|
||||
const reducer = NodeReduces['phase'];
|
||||
return reducer(n, nodes)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,4 +182,4 @@ function VisProgPage() {
|
||||
)
|
||||
}
|
||||
|
||||
export default VisProgPage
|
||||
export default VisProgPage
|
||||
|
||||
129
src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts
Normal file
129
src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
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 => ({
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import {
|
||||
type Edge,
|
||||
getIncomers,
|
||||
getOutgoers
|
||||
} from '@xyflow/react';
|
||||
import useFlowStore from "./VisProgStores.tsx";
|
||||
import type {
|
||||
BehaviorProgram,
|
||||
GoalData,
|
||||
GoalReducer,
|
||||
GraphPreprocessor,
|
||||
NormData,
|
||||
NormReducer,
|
||||
OrderedPhases,
|
||||
Phase,
|
||||
PhaseReducer,
|
||||
PreparedGraph,
|
||||
PreparedPhase, Reduced, TriggerReducer
|
||||
} from "./GraphReducerTypes.ts";
|
||||
import type {
|
||||
AppNode,
|
||||
GoalNode,
|
||||
NormNode,
|
||||
PhaseNode, TriggerNode
|
||||
} from "./VisProgTypes.tsx";
|
||||
import type {TriggerNodeProps} from "./components/TriggerNodeComponent.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
|
||||
* @param {TriggerReducer} triggerReducer
|
||||
* @returns {BehaviorProgram}
|
||||
*/
|
||||
export default function graphReducer(
|
||||
graphPreprocessor: GraphPreprocessor = defaultGraphPreprocessor,
|
||||
phaseReducer: PhaseReducer = defaultPhaseReducer,
|
||||
normReducer: NormReducer = defaultNormReducer,
|
||||
goalReducer: GoalReducer = defaultGoalReducer,
|
||||
triggerReducer: TriggerReducer = defaultTriggerReducer,
|
||||
) : 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,
|
||||
triggerReducer,
|
||||
));
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {TriggerReducer} triggerReducer
|
||||
* @returns {Phase}
|
||||
*/
|
||||
export function defaultPhaseReducer(
|
||||
phase: PreparedPhase,
|
||||
normReducer: NormReducer = defaultNormReducer,
|
||||
goalReducer: GoalReducer = defaultGoalReducer,
|
||||
triggerReducer: TriggerReducer = defaultTriggerReducer,
|
||||
) : 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),
|
||||
triggers: phase.connectedTriggers.map(triggerReducer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* the default implementation of the goalNode reducer function
|
||||
*
|
||||
* @param {GoalNode} node
|
||||
* @returns {GoalData}
|
||||
*/
|
||||
function defaultGoalReducer(node: GoalNode) : Reduced<GoalData> {
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.data.label,
|
||||
description: node.data.description,
|
||||
achieved: node.data.achieved,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* the default implementation of the normNode reducer function
|
||||
*
|
||||
* @param {NormNode} node
|
||||
* @returns {NormData}
|
||||
*/
|
||||
function defaultNormReducer(node: NormNode) :Reduced<NormData> {
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.data.label,
|
||||
value: node.data.value
|
||||
}
|
||||
}
|
||||
|
||||
function defaultTriggerReducer(node: TriggerNode): Reduced<TriggerNodeProps> {
|
||||
return {
|
||||
id: node.id,
|
||||
...node.data,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 triggers : TriggerNode[] = nodes.filter((node) => node.type === 'trigger') as TriggerNode[];
|
||||
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),
|
||||
connectedTriggers: getIncomers({id: phase.id}, triggers, edges),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* orderPhases takes the state of the graph created by the editor and turns it into an OrderedPhases object.
|
||||
*
|
||||
* @param {AppNode[]} nodes
|
||||
* @param {Edge[]} edges
|
||||
* @returns {OrderedPhases}
|
||||
*/
|
||||
export function orderPhases(nodes: AppNode[],edges: Edge[]) : OrderedPhases {
|
||||
// find the first Phase node
|
||||
const phaseNodes : PhaseNode[] = nodes.filter((node) => node.type === 'phase') as PhaseNode[];
|
||||
const startNodeIndex = nodes.findIndex((node : AppNode):boolean => {return (node.type === 'start');});
|
||||
const firstPhaseNode = getOutgoers({ id: nodes[startNodeIndex].id },phaseNodes,edges);
|
||||
|
||||
// recursively adds the phase nodes to a list in the order they are connected in the graph
|
||||
const nextPhase = (
|
||||
currentIndex: number,
|
||||
{ phaseNodes: phases, connections: connections} : OrderedPhases
|
||||
) : OrderedPhases => {
|
||||
// get the current phase and the next phases;
|
||||
const currentPhase = phases[currentIndex];
|
||||
const nextPhaseNodes = getOutgoers(currentPhase,phaseNodes,edges);
|
||||
const nextNodes = getOutgoers(currentPhase,nodes, edges);
|
||||
|
||||
// handles adding of the next phase to the chain, and error handle if an invalid state is received
|
||||
if (nextPhaseNodes.length === 1 && nextNodes.length === 1) {
|
||||
connections.set(currentPhase.id, nextPhaseNodes[0].id);
|
||||
return nextPhase(phases.push(nextPhaseNodes[0] as PhaseNode) - 1, {phaseNodes: phases, connections: connections});
|
||||
} else {
|
||||
// handle erroneous states
|
||||
if (nextNodes.length === 0){
|
||||
throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" doesn't have any outgoing connections`);
|
||||
} else {
|
||||
if (nextNodes.length > 1) {
|
||||
throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" connects to too many targets`);
|
||||
} else {
|
||||
if (nextNodes[0].type === "end"){
|
||||
connections.set(currentPhase.id, "end");
|
||||
// returns the final output of the function
|
||||
return { phaseNodes: phases, connections: connections};
|
||||
} else {
|
||||
throw new Error(`| INVALID PROGRAM | the node "${nextNodes[0].id}" that "${currentPhase.id}" connects to is not a phase or end node`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// initializes the Map describing the connections between phase nodes
|
||||
// we need this Map to make sure we preserve this information,
|
||||
// so we don't need to do checks on the entire set of edges in further stages of the reduction algorithm
|
||||
const connections : Map<string, string> = new Map();
|
||||
|
||||
// returns an empty list if no phase nodes are present, otherwise returns an ordered list of phaseNodes
|
||||
if (firstPhaseNode.length > 0) {
|
||||
return nextPhase(0, {phaseNodes: [firstPhaseNode[0] as PhaseNode], connections: connections})
|
||||
} else { return {phaseNodes: [], connections: connections} }
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import type {Edge} from "@xyflow/react";
|
||||
import type {AppNode, GoalNode, NormNode, PhaseNode, TriggerNode} from "./VisProgTypes.tsx";
|
||||
import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx";
|
||||
|
||||
|
||||
export type Reduced<T> = { id: string } & T;
|
||||
|
||||
/**
|
||||
* defines how a norm is represented in the simplified behavior program
|
||||
*/
|
||||
export type NormData = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* defines how a goal is represented in the simplified behavior program
|
||||
*/
|
||||
export type GoalData = {
|
||||
name: string;
|
||||
description: string;
|
||||
achieved: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
triggers: TriggerNodeProps[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) => Reduced<NormData>;
|
||||
export type GoalReducer = (node: GoalNode) => Reduced<GoalData>;
|
||||
export type TriggerReducer = (node: TriggerNode) => Reduced<TriggerNodeProps>;
|
||||
export type PhaseReducer = (
|
||||
preparedPhase: PreparedPhase,
|
||||
normReducer: NormReducer,
|
||||
goalReducer: GoalReducer,
|
||||
triggerReducer: TriggerReducer,
|
||||
) => 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[];
|
||||
connectedTriggers: TriggerNode[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
|
||||
|
||||
|
||||
168
src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts
Normal file
168
src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import EndNode, {
|
||||
EndConnectionTarget,
|
||||
EndConnectionSource,
|
||||
EndDisconnectionTarget,
|
||||
EndDisconnectionSource,
|
||||
EndReduce
|
||||
} from "./nodes/EndNode";
|
||||
import { EndNodeDefaults } from "./nodes/EndNode.default";
|
||||
import StartNode, {
|
||||
StartConnectionTarget,
|
||||
StartConnectionSource,
|
||||
StartDisconnectionTarget,
|
||||
StartDisconnectionSource,
|
||||
StartReduce
|
||||
} from "./nodes/StartNode";
|
||||
import { StartNodeDefaults } from "./nodes/StartNode.default";
|
||||
import PhaseNode, {
|
||||
PhaseConnectionTarget,
|
||||
PhaseConnectionSource,
|
||||
PhaseDisconnectionTarget,
|
||||
PhaseDisconnectionSource,
|
||||
PhaseReduce
|
||||
} from "./nodes/PhaseNode";
|
||||
import { PhaseNodeDefaults } from "./nodes/PhaseNode.default";
|
||||
import NormNode, {
|
||||
NormConnectionTarget,
|
||||
NormConnectionSource,
|
||||
NormDisconnectionTarget,
|
||||
NormDisconnectionSource,
|
||||
NormReduce
|
||||
} from "./nodes/NormNode";
|
||||
import { NormNodeDefaults } from "./nodes/NormNode.default";
|
||||
import GoalNode, {
|
||||
GoalConnectionTarget,
|
||||
GoalConnectionSource,
|
||||
GoalDisconnectionTarget,
|
||||
GoalDisconnectionSource,
|
||||
GoalReduce
|
||||
} from "./nodes/GoalNode";
|
||||
import { GoalNodeDefaults } from "./nodes/GoalNode.default";
|
||||
import TriggerNode, {
|
||||
TriggerConnectionTarget,
|
||||
TriggerConnectionSource,
|
||||
TriggerDisconnectionTarget,
|
||||
TriggerDisconnectionSource,
|
||||
TriggerReduce
|
||||
} from "./nodes/TriggerNode";
|
||||
import { TriggerNodeDefaults } from "./nodes/TriggerNode.default";
|
||||
|
||||
/**
|
||||
* Registered node types in the visual programming system.
|
||||
*
|
||||
* Key: the node type string used in the flow graph.
|
||||
* Value: the corresponding React component for rendering the node.
|
||||
*/
|
||||
export const NodeTypes = {
|
||||
start: StartNode,
|
||||
end: EndNode,
|
||||
phase: PhaseNode,
|
||||
norm: NormNode,
|
||||
goal: GoalNode,
|
||||
trigger: TriggerNode,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default data and settings for each node type.
|
||||
* These are defined in the <node>.default.ts files.
|
||||
* These defaults are used when a new node is created to initialize its properties.
|
||||
*/
|
||||
export const NodeDefaults = {
|
||||
start: StartNodeDefaults,
|
||||
end: EndNodeDefaults,
|
||||
phase: PhaseNodeDefaults,
|
||||
norm: NormNodeDefaults,
|
||||
goal: GoalNodeDefaults,
|
||||
trigger: TriggerNodeDefaults,
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Reduce functions for each node type.
|
||||
*
|
||||
* A reduce function extracts the relevant data from a node and its children.
|
||||
* Used during graph evaluation or export.
|
||||
*/
|
||||
export const NodeReduces = {
|
||||
start: StartReduce,
|
||||
end: EndReduce,
|
||||
phase: PhaseReduce,
|
||||
norm: NormReduce,
|
||||
goal: GoalReduce,
|
||||
trigger: TriggerReduce,
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Connection functions for each node type.
|
||||
*
|
||||
* These functions define 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,
|
||||
},
|
||||
Sources: {
|
||||
start: StartConnectionSource,
|
||||
end: EndConnectionSource,
|
||||
phase: PhaseConnectionSource,
|
||||
norm: NormConnectionSource,
|
||||
goal: GoalConnectionSource,
|
||||
trigger: TriggerConnectionSource,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
Sources: {
|
||||
start: StartDisconnectionSource,
|
||||
end: EndDisconnectionSource,
|
||||
phase: PhaseDisconnectionSource,
|
||||
norm: NormDisconnectionSource,
|
||||
goal: GoalDisconnectionSource,
|
||||
trigger: TriggerDisconnectionSource,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
test: () => false, // Used for coverage of universal/ undefined nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
test: () => false, // Used for coverage of universal/ undefined nodes
|
||||
}
|
||||
@@ -1,142 +1,229 @@
|
||||
import {create} from 'zustand';
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
applyNodeChanges,
|
||||
applyEdgeChanges,
|
||||
addEdge,
|
||||
reconnectEdge, type Edge, type Connection
|
||||
reconnectEdge,
|
||||
type Node,
|
||||
type Edge,
|
||||
type XYPosition,
|
||||
} from '@xyflow/react';
|
||||
import type { FlowState } from './VisProgTypes';
|
||||
import {
|
||||
NodeDefaults,
|
||||
NodeConnections as NodeCs,
|
||||
NodeDisconnections as NodeDs,
|
||||
NodeDeletes
|
||||
} from './NodeRegistry';
|
||||
import { UndoRedo } from "./EditorUndoRedo.ts";
|
||||
|
||||
import {type AppNode, type FlowState} from './VisProgTypes.tsx';
|
||||
|
||||
/**
|
||||
* contains the nodes that are created when the editor is loaded,
|
||||
* should contain at least a start and an end node
|
||||
* A Function to create a new node with the correct default data and properties.
|
||||
*
|
||||
* @param id - The unique ID of the node.
|
||||
* @param type - The type of node to create (must exist in NodeDefaults).
|
||||
* @param position - The XY position of the node in the flow canvas.
|
||||
* @param data - The data object to initialize the node with.
|
||||
* @param deletable - Optional flag to indicate if the node can be deleted (can be deleted by default).
|
||||
* @returns A fully initialized Node object ready to be added to the flow.
|
||||
*/
|
||||
const initialNodes = [
|
||||
{
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
position: {x: 0, y: 0},
|
||||
data: {label: 'start'}
|
||||
},
|
||||
{
|
||||
id: 'phase-1',
|
||||
type: 'phase',
|
||||
position: {x: 0, y: 150},
|
||||
data: {label: 'Generic Phase', number: 1},
|
||||
},
|
||||
{
|
||||
id: 'end',
|
||||
type: 'end',
|
||||
position: {x: 0, y: 300},
|
||||
data: {label: 'End'}
|
||||
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable? : boolean) {
|
||||
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
|
||||
const newData = {
|
||||
id: id,
|
||||
type: type,
|
||||
position: position,
|
||||
data: data,
|
||||
deletable: deletable,
|
||||
}
|
||||
return {...defaultData, ...newData}
|
||||
}
|
||||
|
||||
//* Initial nodes, created by using createNode. */
|
||||
const initialNodes : Node[] = [
|
||||
createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false),
|
||||
createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false),
|
||||
createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children : []}),
|
||||
createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}),
|
||||
];
|
||||
|
||||
/**
|
||||
* contains the initial edges that are created when the editor is loaded
|
||||
*/
|
||||
const initialEdges = [
|
||||
{
|
||||
id: 'start-phase-1',
|
||||
source: 'start',
|
||||
target: 'phase-1',
|
||||
},
|
||||
{
|
||||
id: 'phase-1-end',
|
||||
source: 'phase-1',
|
||||
target: 'end',
|
||||
}
|
||||
// * Initial edges * /
|
||||
const initialEdges: Edge[] = [
|
||||
{ id: 'start-phase-1', source: 'start', target: 'phase-1' },
|
||||
{ id: 'phase-1-end', source: 'phase-1', target: 'end' },
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* The useFlowStore hook contains the implementation for editor functionality and state
|
||||
* we can use this inside our editor component to access the current state
|
||||
* and use any implemented functionality
|
||||
* 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>((set, get) => ({
|
||||
const useFlowStore = create<FlowState>(UndoRedo((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),
|
||||
|
||||
/**
|
||||
* Handles changes to nodes triggered by ReactFlow.
|
||||
*/
|
||||
onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}),
|
||||
|
||||
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); }
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Handles changes to edges triggered by ReactFlow.
|
||||
*/
|
||||
onEdgesChange: (changes) => {
|
||||
set({ edges: applyEdgeChanges(changes, get().edges) })
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles creating a new connection between nodes.
|
||||
* Updates edges and calls the node-specific connection functions.
|
||||
*/
|
||||
onConnect: (connection) => {
|
||||
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) => {
|
||||
get().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
|
||||
});
|
||||
set({ edgeReconnectSuccessful: true });
|
||||
},
|
||||
deleteNode: (nodeId: string) => {
|
||||
set({
|
||||
|
||||
/**
|
||||
* 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)
|
||||
});
|
||||
edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
|
||||
})}
|
||||
},
|
||||
setNodes: (nodes) => {
|
||||
set({nodes});
|
||||
},
|
||||
setEdges: (edges) => {
|
||||
set({edges});
|
||||
},
|
||||
/**
|
||||
* handles updating the data component of a node,
|
||||
* if the provided data object contains entries that aren't present in the updated node's data component
|
||||
* those entries are added to the data component,
|
||||
* entries that do exist within the node's data component,
|
||||
* are simply updated to contain the new value
|
||||
*
|
||||
* the data object
|
||||
* @param {string} nodeId
|
||||
* @param {object} data
|
||||
*/
|
||||
updateNodeData: (nodeId: string, data) => {
|
||||
|
||||
/**
|
||||
* 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) : AppNode => {
|
||||
nodes: get().nodes.map((node) => {
|
||||
if (node.id === nodeId) {
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
...data
|
||||
}
|
||||
};
|
||||
} else { return node; }
|
||||
})
|
||||
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,
|
||||
}))
|
||||
);
|
||||
|
||||
export default useFlowStore;
|
||||
export default useFlowStore;
|
||||
|
||||
@@ -1,50 +1,91 @@
|
||||
import {
|
||||
type Edge,
|
||||
type Node,
|
||||
type OnNodesChange,
|
||||
type OnEdgesChange,
|
||||
type OnConnect,
|
||||
type OnReconnect,
|
||||
} from '@xyflow/react';
|
||||
import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx";
|
||||
// VisProgTypes.ts
|
||||
import type {Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node, OnEdgesDelete} from '@xyflow/react';
|
||||
import type { NodeTypes } from './NodeRegistry';
|
||||
import type {FlowSnapshot} from "./EditorUndoRedo.ts";
|
||||
|
||||
|
||||
type defaultNodeData = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
type OurNode<T> = Node<T & defaultNodeData>;
|
||||
|
||||
export type StartNode = Node<defaultNodeData, 'start'>;
|
||||
export type EndNode = Node<defaultNodeData, 'end'>;
|
||||
export type GoalNode = Node<defaultNodeData & { description: string; achieved: boolean; }, 'goal'>;
|
||||
export type NormNode = Node<defaultNodeData & { value: string; }, 'norm'>;
|
||||
export type PhaseNode = Node<defaultNodeData & { number: number; }, 'phase'>;
|
||||
export type TriggerNode = OurNode<TriggerNodeProps>;
|
||||
|
||||
/**
|
||||
* a type meant to house different node types, currently not used
|
||||
* but will allow us to more clearly define nodeTypes when we implement
|
||||
* computation of the Graph inside the ReactFlow editor
|
||||
* Type representing all registered node types.
|
||||
* This corresponds to the keys of NodeTypes in NodeRegistry.
|
||||
*/
|
||||
export type AppNode = Node | StartNode | EndNode | NormNode | GoalNode | PhaseNode;
|
||||
|
||||
export type AppNode = typeof NodeTypes;
|
||||
|
||||
/**
|
||||
* The type for the Zustand store object used to manage the state of the ReactFlow editor
|
||||
* The FlowState type defines the shape of the Zustand store used for managing the visual programming flow.
|
||||
*
|
||||
* Includes:
|
||||
* - Nodes and edges currently in the flow
|
||||
* - Callbacks for node and edge changes
|
||||
* - Node deletion and updates
|
||||
* - Edge reconnection handling
|
||||
*/
|
||||
export type FlowState = {
|
||||
nodes: AppNode[];
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
edgeReconnectSuccessful: boolean;
|
||||
|
||||
/** Handler for changes to nodes triggered by ReactFlow */
|
||||
onNodesChange: OnNodesChange;
|
||||
|
||||
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;
|
||||
onReconnectEnd: (_: unknown, edge: { id: string }) => 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
|
||||
*/
|
||||
deleteNode: (nodeId: string) => void;
|
||||
setNodes: (nodes: AppNode[]) => void;
|
||||
|
||||
/**
|
||||
* Replaces the current nodes array in the store.
|
||||
* @param nodes - new array of nodes
|
||||
*/
|
||||
setNodes: (nodes: Node[]) => void;
|
||||
|
||||
/**
|
||||
* Replaces the current edges array in the store.
|
||||
* @param edges - new array of edges
|
||||
*/
|
||||
setEdges: (edges: Edge[]) => void;
|
||||
|
||||
/**
|
||||
* Updates the data of a node by merging new data with existing node data.
|
||||
* @param nodeId - the ID of the node to update
|
||||
* @param data - object containing new data fields to merge
|
||||
*/
|
||||
updateNodeData: (nodeId: string, data: object) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new node to the flow.
|
||||
* @param node - the Node object to add
|
||||
*/
|
||||
addNode: (node: Node) => void;
|
||||
|
||||
// UndoRedo Types
|
||||
past: FlowSnapshot[];
|
||||
future: FlowSnapshot[];
|
||||
pushSnapshot: () => void;
|
||||
isBatchAction: boolean;
|
||||
beginBatchAction: () => void;
|
||||
endBatchAction: () => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
};
|
||||
|
||||
@@ -1,176 +1,125 @@
|
||||
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, GoalNode, TriggerNode} from "../VisProgTypes.tsx";
|
||||
|
||||
|
||||
import { useDraggable } from '@neodrag/react';
|
||||
import { useReactFlow, type XYPosition } from '@xyflow/react';
|
||||
import { type ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import useFlowStore from '../VisProgStores';
|
||||
import styles from '../../VisProg.module.css';
|
||||
import { NodeDefaults, type NodeTypes } from '../NodeRegistry'
|
||||
|
||||
/**
|
||||
* DraggableNodeProps dictates the type properties of a DraggableNode
|
||||
* Props for a draggable node within the drag-and-drop toolbar.
|
||||
*
|
||||
* @property className - Optional custom CSS classes for styling.
|
||||
* @property children - The visual content or label rendered inside the draggable node.
|
||||
* @property nodeType - The type of node represented (key from `NodeTypes`).
|
||||
* @property onDrop - Function called when the node is dropped on the flow pane.
|
||||
*/
|
||||
interface DraggableNodeProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
nodeType: string;
|
||||
onDrop: (nodeType: string, position: XYPosition) => void;
|
||||
nodeType: keyof typeof NodeTypes;
|
||||
onDrop: (nodeType: keyof typeof NodeTypes, position: XYPosition) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Definition of a node inside the drag and drop toolbar,
|
||||
* these nodes require an onDrop function to be supplied
|
||||
* that dictates how the node is created in the graph.
|
||||
* A draggable node element used in the drag-and-drop toolbar.
|
||||
*
|
||||
* @param className
|
||||
* @param children
|
||||
* @param nodeType
|
||||
* @param onDrop
|
||||
* @constructor
|
||||
* Integrates with the NeoDrag library to handle drag events.
|
||||
* On drop, it calls the provided `onDrop` function with the node type and drop position.
|
||||
*
|
||||
* @param props - The draggable node configuration.
|
||||
* @returns A React element representing a draggable node.
|
||||
*/
|
||||
function DraggableNode({className, children, nodeType, onDrop}: DraggableNodeProps) {
|
||||
function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeProps) {
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState<XYPosition>({x: 0, y: 0});
|
||||
const [position, setPosition] = useState<XYPosition>({ x: 0, y: 0 });
|
||||
|
||||
// @ts-expect-error comes from a package and doesn't appear to play nicely with strict typescript typing
|
||||
// The NeoDrag hook enables smooth drag functionality for this element.
|
||||
// @ts-expect-error: NeoDrag typing incompatibility — safe to ignore.
|
||||
useDraggable(draggableRef, {
|
||||
position: position,
|
||||
onDrag: ({offsetX, offsetY}) => {
|
||||
// Calculate position relative to the viewport
|
||||
setPosition({
|
||||
x: offsetX,
|
||||
y: offsetY,
|
||||
});
|
||||
position,
|
||||
onDrag: ({ offsetX, offsetY }) => {
|
||||
setPosition({ x: offsetX, y: offsetY });
|
||||
},
|
||||
onDragEnd: ({event}) => {
|
||||
setPosition({x: 0, y: 0});
|
||||
onDrop(nodeType, {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
onDragEnd: ({ event }) => {
|
||||
setPosition({ x: 0, y: 0 });
|
||||
onDrop(nodeType, { x: event.clientX, y: event.clientY });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className} ref={draggableRef}>
|
||||
<div className={className}
|
||||
ref={draggableRef}
|
||||
id={`draggable-${nodeType}`}
|
||||
data-testid={`draggable-${nodeType}`}
|
||||
>
|
||||
{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 { nodes, addNode } = useFlowStore.getState();
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function addNode(nodeType: string, position: XYPosition) {
|
||||
const {setNodes} = useFlowStore.getState();
|
||||
const nds : AppNode[] = useFlowStore.getState().nodes;
|
||||
const newNode = () => {
|
||||
switch (nodeType) {
|
||||
case "phase":
|
||||
{
|
||||
const phaseNodes= nds.filter((node) => node.type === 'phase');
|
||||
let phaseNumber;
|
||||
if (phaseNodes.length > 0) {
|
||||
const finalPhaseId : number = +(phaseNodes[phaseNodes.length - 1].id.split('-')[1]);
|
||||
phaseNumber = finalPhaseId + 1;
|
||||
} else {
|
||||
phaseNumber = 1;
|
||||
}
|
||||
const phaseNode : PhaseNode = {
|
||||
id: `phase-${phaseNumber}`,
|
||||
type: nodeType,
|
||||
position,
|
||||
data: {label: 'new', number: phaseNumber},
|
||||
}
|
||||
return phaseNode;
|
||||
}
|
||||
case "norm":
|
||||
{
|
||||
const normNodes= nds.filter((node) => node.type === 'norm');
|
||||
let normNumber
|
||||
if (normNodes.length > 0) {
|
||||
const finalNormId : number = +(normNodes[normNodes.length - 1].id.split('-')[1]);
|
||||
normNumber = finalNormId + 1;
|
||||
} else {
|
||||
normNumber = 1;
|
||||
}
|
||||
|
||||
const normNode : NormNode = {
|
||||
id: `norm-${normNumber}`,
|
||||
type: nodeType,
|
||||
position,
|
||||
data: {label: `new norm node`, value: ""},
|
||||
}
|
||||
return normNode;
|
||||
}
|
||||
case "goal":
|
||||
{
|
||||
const goalNodes= nds.filter((node) => node.type === 'goal');
|
||||
let goalNumber
|
||||
if (goalNodes.length > 0) {
|
||||
const finalGoalId : number = +(goalNodes[goalNodes.length - 1].id.split('-')[1]);
|
||||
goalNumber = finalGoalId + 1;
|
||||
} else {
|
||||
goalNumber = 1;
|
||||
}
|
||||
|
||||
const goalNode : GoalNode = {
|
||||
id: `goal-${goalNumber}`,
|
||||
type: nodeType,
|
||||
position,
|
||||
data: {label: `new goal node`, description: "", achieved: false},
|
||||
}
|
||||
return goalNode;
|
||||
}
|
||||
case "trigger":
|
||||
{
|
||||
const triggerNodes= nds.filter((node) => node.type === 'trigger');
|
||||
let triggerNumber
|
||||
if (triggerNodes.length > 0) {
|
||||
const finalGoalId : number = +(triggerNodes[triggerNodes.length - 1].id.split('-')[1]);
|
||||
triggerNumber = finalGoalId + 1;
|
||||
} else {
|
||||
triggerNumber = 1;
|
||||
}
|
||||
|
||||
const triggerNode : TriggerNode = {
|
||||
id: `trigger-${triggerNumber}`,
|
||||
type: nodeType,
|
||||
position,
|
||||
data: {label: `new trigger node`, type: "keywords", value: []},
|
||||
}
|
||||
return triggerNode;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Node ${nodeType} not found`);
|
||||
}
|
||||
}
|
||||
// Load any predefined data for this node type.
|
||||
const defaultData = NodeDefaults[nodeType] ?? {}
|
||||
|
||||
// Currently, we find out what the Id is by checking the last node and adding one.
|
||||
const sameTypeNodes = nodes.filter((node) => node.type === nodeType);
|
||||
const nextNumber =
|
||||
sameTypeNodes.length > 0
|
||||
? (() => {
|
||||
const lastNode = sameTypeNodes[sameTypeNodes.length - 1];
|
||||
const parts = lastNode.id.split('-');
|
||||
const lastNum = Number(parts[1]);
|
||||
return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1;
|
||||
})()
|
||||
: 1;
|
||||
const id = `${nodeType}-${nextNumber}`;
|
||||
|
||||
// Create new node
|
||||
const newNode = {
|
||||
id: id,
|
||||
type: nodeType,
|
||||
position,
|
||||
data: JSON.parse(JSON.stringify(defaultData))
|
||||
}
|
||||
|
||||
setNodes(nds.concat(newNode()));
|
||||
addNode(newNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* the DndToolbar defines how the drag and drop toolbar component works
|
||||
* and includes the default onDrop behavior through handleNodeDrop
|
||||
* @constructor
|
||||
* The drag-and-drop toolbar component for the visual programming interface.
|
||||
*
|
||||
* Displays draggable node templates based on entries in `NodeDefaults`.
|
||||
* Each droppable node can be dragged into the flow pane to instantiate it.
|
||||
*
|
||||
* Automatically filters nodes whose `droppable` flag is set to `true`.
|
||||
*
|
||||
* @returns A React element representing the drag-and-drop toolbar.
|
||||
*/
|
||||
export function DndToolbar() {
|
||||
const {screenToFlowPosition} = useReactFlow();
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
/**
|
||||
* handleNodeDrop implements the default onDrop behavior
|
||||
* Handles dropping a node onto the flow pane.
|
||||
* Translates screen coordinates into flow coordinates using React Flow utilities.
|
||||
*/
|
||||
const handleNodeDrop = useCallback(
|
||||
(nodeType: string, screenPosition: XYPosition) => {
|
||||
(nodeType: keyof typeof NodeTypes, screenPosition: XYPosition) => {
|
||||
const flow = document.querySelector('.react-flow');
|
||||
const flowRect = flow?.getBoundingClientRect();
|
||||
|
||||
// Only add the node if it is inside the flow canvas area.
|
||||
const isInFlow =
|
||||
flowRect &&
|
||||
screenPosition.x >= flowRect.left &&
|
||||
@@ -178,34 +127,41 @@ export function DndToolbar() {
|
||||
screenPosition.y >= flowRect.top &&
|
||||
screenPosition.y <= flowRect.bottom;
|
||||
|
||||
// Create a new node and add it to the flow
|
||||
if (isInFlow) {
|
||||
const position = screenToFlowPosition(screenPosition);
|
||||
addNode(nodeType, position);
|
||||
addNodeToFlow(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}`}>
|
||||
<div className="description">
|
||||
You can drag these nodes to the pane to create new nodes.
|
||||
</div>
|
||||
<div className={`flex-row gap-lg ${styles.dndNodeContainer}`}>
|
||||
<DraggableNode className={styles.draggableNodePhase} nodeType="phase" onDrop={handleNodeDrop}>
|
||||
phase Node
|
||||
</DraggableNode>
|
||||
<DraggableNode className={styles.draggableNodeNorm} nodeType="norm" onDrop={handleNodeDrop}>
|
||||
norm Node
|
||||
</DraggableNode>
|
||||
<DraggableNode className={styles.draggableNodeGoal} nodeType="goal" onDrop={handleNodeDrop}>
|
||||
goal Node
|
||||
</DraggableNode>
|
||||
<DraggableNode className={styles.draggableNodeTrigger} nodeType="trigger" onDrop={handleNodeDrop}>
|
||||
trigger Node
|
||||
</DraggableNode>
|
||||
{/* 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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NodeToolbar } from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
|
||||
/**
|
||||
* Props for the Toolbar component.
|
||||
*
|
||||
* @property nodeId - The ID of the node this toolbar is attached to.
|
||||
* @property allowDelete - If `true`, the delete button is enabled; otherwise disabled.
|
||||
*/
|
||||
type ToolbarProps = {
|
||||
nodeId: string;
|
||||
allowDelete: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Node Toolbar definition:
|
||||
* Handles: node deleting functionality
|
||||
* Can be integrated to any custom node component as a React component
|
||||
*
|
||||
* @param {string} nodeId - The ID of the node for which the toolbar is rendered.
|
||||
* @param {boolean} allowDelete - Enables or disables the delete functionality.
|
||||
* @returns {React.JSX.Element} A JSX element representing the toolbar.
|
||||
* @constructor
|
||||
*/
|
||||
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
|
||||
const {deleteNode} = useFlowStore();
|
||||
|
||||
const deleteParentNode = ()=> {
|
||||
deleteNode(nodeId);
|
||||
}
|
||||
return (
|
||||
<NodeToolbar>
|
||||
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
|
||||
</NodeToolbar>);
|
||||
}
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import {
|
||||
Handle,
|
||||
type NodeProps,
|
||||
NodeToolbar,
|
||||
Position
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import styles from '../../VisProg.module.css';
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
import type {
|
||||
StartNode,
|
||||
EndNode,
|
||||
PhaseNode,
|
||||
NormNode, GoalNode
|
||||
} from "../VisProgTypes.tsx";
|
||||
import {TextField} from "../../../../components/TextField.tsx";
|
||||
|
||||
//Toolbar definitions
|
||||
|
||||
type ToolbarProps = {
|
||||
nodeId: string;
|
||||
allowDelete: boolean;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Node Toolbar definition:
|
||||
* handles: node deleting functionality
|
||||
* can be added to any custom node component as a React component
|
||||
*
|
||||
* @param {string} nodeId
|
||||
* @param {boolean} allowDelete
|
||||
* @returns {React.JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
|
||||
const {deleteNode} = useFlowStore();
|
||||
|
||||
const deleteParentNode = ()=> {
|
||||
deleteNode(nodeId);
|
||||
}
|
||||
return (
|
||||
<NodeToolbar>
|
||||
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
|
||||
</NodeToolbar>);
|
||||
}
|
||||
|
||||
|
||||
// Definitions of Nodes
|
||||
|
||||
/**
|
||||
* Start Node definition:
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {defaultNodeData} data
|
||||
* @returns {React.JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export const StartNodeComponent = ({id, data}: NodeProps<StartNode>) => {
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={id} allowDelete={false}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeStart}`}>
|
||||
<div> data test {data.label} </div>
|
||||
<Handle type="source" position={Position.Right} id="start"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* End node definition:
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {defaultNodeData} data
|
||||
* @returns {React.JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export const EndNodeComponent = ({id, data}: NodeProps<EndNode>) => {
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={id} allowDelete={false}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeEnd}`}>
|
||||
<div> {data.label} </div>
|
||||
<Handle type="target" position={Position.Left} id="end"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Phase node definition:
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {defaultNodeData & {number: number}} data
|
||||
* @returns {React.JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export const PhaseNodeComponent = ({id, data}: NodeProps<PhaseNode>) => {
|
||||
const {updateNodeData} = useFlowStore();
|
||||
|
||||
const updateLabel = (value: string) => updateNodeData(id, {...data, label: value});
|
||||
|
||||
const label_input_id = `phase_${id}_label_input`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodePhase}`}>
|
||||
<div className={"flex-row gap-sm"}>
|
||||
<label htmlFor={label_input_id}>Name:</label>
|
||||
<TextField
|
||||
id={label_input_id}
|
||||
value={data.label}
|
||||
setValue={updateLabel}
|
||||
placeholder={"Phase ..."}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="target" position={Position.Left} id="target"/>
|
||||
<Handle type="target" position={Position.Bottom} id="norms"/>
|
||||
<Handle type="source" position={Position.Right} id="source"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Norm node definition:
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {defaultNodeData & {value: string}} data
|
||||
*/
|
||||
export const NormNodeComponent = ({id, data}: NodeProps<NormNode>) => {
|
||||
const {updateNodeData} = useFlowStore();
|
||||
|
||||
const text_input_id = `norm_${id}_text_input`;
|
||||
|
||||
const setValue = (value: string) => {
|
||||
updateNodeData(id, {value: value});
|
||||
}
|
||||
|
||||
return <>
|
||||
<Toolbar nodeId={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.value}
|
||||
setValue={(val) => setValue(val)}
|
||||
placeholder={"Pepper should ..."}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} id="NormSource"/>
|
||||
</div>
|
||||
</>;
|
||||
};
|
||||
|
||||
export const GoalNodeComponent = ({id, data}: NodeProps<GoalNode>) => {
|
||||
const {updateNodeData} = useFlowStore();
|
||||
|
||||
const text_input_id = `goal_${id}_text_input`;
|
||||
const checkbox_id = `goal_${id}_checkbox`;
|
||||
|
||||
const setDescription = (value: string) => {
|
||||
updateNodeData(id, {...data, description: value});
|
||||
}
|
||||
|
||||
const setAchieved = (value: boolean) => {
|
||||
updateNodeData(id, {...data, achieved: value});
|
||||
}
|
||||
|
||||
return <>
|
||||
<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.description}
|
||||
setValue={(val) => setDescription(val)}
|
||||
placeholder={"To ..."}
|
||||
/>
|
||||
</div>
|
||||
<div className={"flex-row gap-md align-center"}>
|
||||
<label htmlFor={checkbox_id}>Achieved:</label>
|
||||
<input
|
||||
id={checkbox_id}
|
||||
type={"checkbox"}
|
||||
value={data.achieved ? "checked" : ""}
|
||||
onChange={(e) => setAchieved(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} id="GoalSource"/>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.save-load-panel {
|
||||
border-radius: 0 0 5pt 5pt;
|
||||
background-color: canvas;
|
||||
}
|
||||
|
||||
label.file-input-button {
|
||||
cursor: pointer;
|
||||
outline: forestgreen solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem forestgreen);
|
||||
transition: filter 200ms;
|
||||
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: drop-shadow(0 0 0.5rem forestgreen);
|
||||
}
|
||||
}
|
||||
|
||||
.save-button {
|
||||
text-decoration: none;
|
||||
outline: dodgerblue solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem dodgerblue);
|
||||
transition: filter 200ms;
|
||||
|
||||
&:hover {
|
||||
filter: drop-shadow(0 0 0.5rem dodgerblue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {type ChangeEvent, useRef, useState} from "react";
|
||||
import useFlowStore from "../VisProgStores";
|
||||
import visProgStyles from "../../VisProg.module.css";
|
||||
import styles from "./SaveLoadPanel.module.css";
|
||||
import { makeProjectBlob, type SavedProject } from "../../../../utils/SaveLoad";
|
||||
|
||||
export default function SaveLoadPanel() {
|
||||
const nodes = useFlowStore((s) => s.nodes);
|
||||
const edges = useFlowStore((s) => s.edges);
|
||||
const setNodes = useFlowStore((s) => s.setNodes);
|
||||
const setEdges = useFlowStore((s) => s.setEdges);
|
||||
|
||||
const [saveUrl, setSaveUrl] = useState<string | null>(null);
|
||||
|
||||
// ref to the file input
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const onSave = async (nameGuess = "visual-program") => {
|
||||
const blob = makeProjectBlob(nameGuess, nodes, edges);
|
||||
const url = URL.createObjectURL(blob);
|
||||
setSaveUrl(url);
|
||||
};
|
||||
|
||||
// input change handler updates the graph with a parsed JSON file
|
||||
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text) as SavedProject;
|
||||
if (!parsed.nodes || !parsed.edges) throw new Error("Invalid file format");
|
||||
setNodes(parsed.nodes);
|
||||
setEdges(parsed.edges);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Loading failed. See console.");
|
||||
} finally {
|
||||
// allow re-selecting same file next time
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const defaultName = "visual-program";
|
||||
return (
|
||||
<div className={`flex-col gap-lg padding-md border-lg ${styles.saveLoadPanel}`}>
|
||||
<div className="description">You can save and load your graph here.</div>
|
||||
<div className={`flex-row gap-lg justify-center`}>
|
||||
<a
|
||||
href={saveUrl ?? "#"}
|
||||
onClick={() => onSave(defaultName)}
|
||||
download={`${defaultName}.json`}
|
||||
className={`${visProgStyles.draggableNode} ${styles.saveButton}`}
|
||||
>
|
||||
Save Graph
|
||||
</a>
|
||||
|
||||
<label className={`${visProgStyles.draggableNode} ${styles.fileInputButton}`}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".visprog.json,.json,.txt,application/json,text/plain"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
Load Graph
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import {Handle, type NodeProps, Position} from "@xyflow/react";
|
||||
import type {TriggerNode} from "../VisProgTypes.tsx";
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
import styles from "../../VisProg.module.css";
|
||||
import {RealtimeTextField, TextField} from "../../../../components/TextField.tsx";
|
||||
import {Toolbar} from "./NodeDefinitions.tsx";
|
||||
import {useState} from "react";
|
||||
import duplicateIndices from "../../../../utils/duplicateIndices.ts";
|
||||
|
||||
export type EmotionTriggerNodeProps = {
|
||||
type: "emotion";
|
||||
value: string;
|
||||
}
|
||||
|
||||
type Keyword = { id: string, keyword: string };
|
||||
|
||||
export type KeywordTriggerNodeProps = {
|
||||
type: "keywords";
|
||||
value: Keyword[];
|
||||
}
|
||||
|
||||
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;
|
||||
|
||||
function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const text_input_id = "keyword_adder_input";
|
||||
|
||||
return <div className={"flex-row gap-md"}>
|
||||
<label htmlFor={text_input_id}>New Keyword:</label>
|
||||
<RealtimeTextField
|
||||
id={text_input_id}
|
||||
value={input}
|
||||
setValue={setInput}
|
||||
onCommit={() => {
|
||||
if (!input) return;
|
||||
addKeyword(input);
|
||||
setInput("");
|
||||
}}
|
||||
placeholder={"..."}
|
||||
className={"flex-1"}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Keywords({
|
||||
keywords,
|
||||
setKeywords,
|
||||
}: {
|
||||
keywords: Keyword[];
|
||||
setKeywords: (keywords: Keyword[]) => void;
|
||||
}) {
|
||||
type Interpolatable = string | number | boolean | bigint | null | undefined;
|
||||
|
||||
const inputElementId = (id: Interpolatable) => `keyword_${id}_input`;
|
||||
|
||||
/** Indices of duplicates in the keyword array. */
|
||||
const [duplicates, setDuplicates] = useState<number[]>([]);
|
||||
|
||||
function replace(id: string, value: string) {
|
||||
value = value.trim();
|
||||
const newKeywords = value === ""
|
||||
? keywords.filter((kw) => kw.id != id)
|
||||
: keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw);
|
||||
setKeywords(newKeywords);
|
||||
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
||||
}
|
||||
|
||||
function add(value: string) {
|
||||
value = value.trim();
|
||||
if (value === "") return;
|
||||
const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}];
|
||||
setKeywords(newKeywords);
|
||||
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
||||
}
|
||||
|
||||
return <>
|
||||
<span>Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken.</span>
|
||||
{[...keywords].map(({id, keyword}, index) => {
|
||||
return <div key={id} className={"flex-row gap-md"}>
|
||||
<label htmlFor={inputElementId(id)}>Keyword:</label>
|
||||
<TextField
|
||||
id={inputElementId(id)}
|
||||
value={keyword}
|
||||
setValue={(val) => replace(id, val)}
|
||||
placeholder={"..."}
|
||||
className={"flex-1"}
|
||||
invalid={duplicates.includes(index)}
|
||||
/>
|
||||
</div>;
|
||||
})}
|
||||
<KeywordAdder addKeyword={add} />
|
||||
</>;
|
||||
}
|
||||
|
||||
export default function TriggerNodeComponent({
|
||||
id,
|
||||
data,
|
||||
}: NodeProps<TriggerNode>) {
|
||||
const {updateNodeData} = useFlowStore();
|
||||
|
||||
const setKeywords = (keywords: Keyword[]) => {
|
||||
updateNodeData(id, {...data, value: keywords});
|
||||
}
|
||||
|
||||
return <>
|
||||
<Toolbar nodeId={id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
|
||||
{data.type === "emotion" && (
|
||||
<div className={"flex-row gap-md"}>Emotion?</div>
|
||||
)}
|
||||
{data.type === "keywords" && (
|
||||
<Keywords
|
||||
keywords={data.value}
|
||||
setKeywords={setKeywords}
|
||||
/>
|
||||
)}
|
||||
<Handle type="source" position={Position.Right} id="TriggerSource"/>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { EndNodeData } from "./EndNode";
|
||||
|
||||
/**
|
||||
* Default data for this node.
|
||||
*/
|
||||
export const EndNodeDefaults: EndNodeData = {
|
||||
label: "End Node",
|
||||
droppable: false,
|
||||
hasReduce: true
|
||||
};
|
||||
88
src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx
Normal file
88
src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Handle,
|
||||
type NodeProps,
|
||||
Position,
|
||||
type Node,
|
||||
} from '@xyflow/react';
|
||||
import { Toolbar } from '../components/NodeComponents';
|
||||
import styles from '../../VisProg.module.css';
|
||||
|
||||
|
||||
/**
|
||||
* The typing of this node's data
|
||||
*/
|
||||
export type EndNodeData = {
|
||||
label: string;
|
||||
droppable: boolean;
|
||||
hasReduce: boolean;
|
||||
};
|
||||
|
||||
export type EndNode = Node<EndNodeData>
|
||||
|
||||
/**
|
||||
* Default function to render an end node given its properties
|
||||
* @param props the node's properties
|
||||
* @returns React.JSX.Element
|
||||
*/
|
||||
export default function EndNode(props: NodeProps<EndNode>) {
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={props.id} allowDelete={false}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeEnd}`}>
|
||||
<div className={"flex-row gap-sm"}>
|
||||
End
|
||||
</div>
|
||||
<Handle type="target" position={Position.Left} id="target"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Functionality for reducing this node into its more compact json program
|
||||
* @param node the node to reduce
|
||||
* @param _nodes all nodes present
|
||||
* @returns Dictionary, {id: node.id}
|
||||
*/
|
||||
export function EndReduce(node: Node, _nodes: Node[]) {
|
||||
// Replace this for nodes functionality
|
||||
return {
|
||||
id: node.id
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { GoalNodeData } from "./GoalNode";
|
||||
|
||||
/**
|
||||
* Default data for this node
|
||||
*/
|
||||
export const GoalNodeDefaults: GoalNodeData = {
|
||||
label: "Goal Node",
|
||||
droppable: true,
|
||||
description: "The robot will strive towards this goal",
|
||||
achieved: false,
|
||||
hasReduce: true,
|
||||
};
|
||||
125
src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx
Normal file
125
src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
Handle,
|
||||
type NodeProps,
|
||||
Position,
|
||||
type Node,
|
||||
} from '@xyflow/react';
|
||||
import { Toolbar } from '../components/NodeComponents';
|
||||
import styles from '../../VisProg.module.css';
|
||||
import { TextField } from '../../../../components/TextField';
|
||||
import useFlowStore from '../VisProgStores';
|
||||
|
||||
/**
|
||||
* The default data dot a phase node
|
||||
* @param label: the label of this phase
|
||||
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
||||
* @param desciption: description of the goal
|
||||
* @param hasReduce: whether this node has reducing functionality (true by default)
|
||||
*/
|
||||
export type GoalNodeData = {
|
||||
label: string;
|
||||
description: string;
|
||||
droppable: boolean;
|
||||
achieved: boolean;
|
||||
hasReduce: boolean;
|
||||
};
|
||||
|
||||
export type GoalNode = Node<GoalNodeData>
|
||||
|
||||
|
||||
/**
|
||||
* Defines how a Goal node should be rendered
|
||||
* @param props NodeProps, like id, label, children
|
||||
* @returns React.JSX.Element
|
||||
*/
|
||||
export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
||||
const {updateNodeData} = useFlowStore();
|
||||
|
||||
const text_input_id = `goal_${id}_text_input`;
|
||||
const checkbox_id = `goal_${id}_checkbox`;
|
||||
|
||||
const setDescription = (value: string) => {
|
||||
updateNodeData(id, {...data, description: value});
|
||||
}
|
||||
|
||||
const setAchieved = (value: boolean) => {
|
||||
updateNodeData(id, {...data, achieved: value});
|
||||
}
|
||||
|
||||
return <>
|
||||
<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.description}
|
||||
setValue={(val) => setDescription(val)}
|
||||
placeholder={"To ..."}
|
||||
/>
|
||||
</div>
|
||||
<div className={"flex-row gap-md align-center"}>
|
||||
<label htmlFor={checkbox_id}>Achieved:</label>
|
||||
<input
|
||||
id={checkbox_id}
|
||||
type={"checkbox"}
|
||||
checked={data.achieved || false}
|
||||
onChange={(e) => setAchieved(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} id="GoalSource"/>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reduces each Goal, including its children down into its relevant data.
|
||||
* @param node The Node Properties of this node.
|
||||
* @param _nodes all the nodes in the graph
|
||||
*/
|
||||
export function GoalReduce(node: Node, _nodes: Node[]) {
|
||||
const data = node.data as GoalNodeData;
|
||||
return {
|
||||
id: node.id,
|
||||
label: data.label,
|
||||
description: data.description,
|
||||
achieved: data.achieved,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is made with this node type 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) {
|
||||
// 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 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) {
|
||||
// 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 GoalDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||
// no additional connection logic exists yet
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { NormNodeData } from "./NormNode";
|
||||
|
||||
/**
|
||||
* Default data for this node
|
||||
*/
|
||||
export const NormNodeDefaults: NormNodeData = {
|
||||
label: "Norm Node",
|
||||
droppable: true,
|
||||
norm: "",
|
||||
hasReduce: true,
|
||||
};
|
||||
109
src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx
Normal file
109
src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Handle,
|
||||
type NodeProps,
|
||||
Position,
|
||||
type Node,
|
||||
} from '@xyflow/react';
|
||||
import { Toolbar } from '../components/NodeComponents';
|
||||
import styles from '../../VisProg.module.css';
|
||||
import { TextField } from '../../../../components/TextField';
|
||||
import useFlowStore from '../VisProgStores';
|
||||
|
||||
/**
|
||||
* The default data dot a phase node
|
||||
* @param label: the label of this phase
|
||||
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
||||
* @param norm: list of strings of norms for this node
|
||||
* @param hasReduce: whether this node has reducing functionality (true by default)
|
||||
*/
|
||||
export type NormNodeData = {
|
||||
label: string;
|
||||
droppable: boolean;
|
||||
norm: string;
|
||||
hasReduce: boolean;
|
||||
};
|
||||
|
||||
export type NormNode = Node<NormNodeData>
|
||||
|
||||
/**
|
||||
* Defines how a Norm node should be rendered
|
||||
* @param props NodeProps, like id, label, children
|
||||
* @returns React.JSX.Element
|
||||
*/
|
||||
export default function NormNode(props: NodeProps<NormNode>) {
|
||||
const data = props.data;
|
||||
const {updateNodeData} = useFlowStore();
|
||||
|
||||
const text_input_id = `norm_${props.id}_text_input`;
|
||||
|
||||
const setValue = (value: string) => {
|
||||
updateNodeData(props.id, {norm: value});
|
||||
}
|
||||
|
||||
return <>
|
||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
|
||||
<div className={"flex-row gap-sm"}>
|
||||
<label htmlFor={text_input_id}>Norm :</label>
|
||||
<TextField
|
||||
id={text_input_id}
|
||||
value={data.norm}
|
||||
setValue={(val) => setValue(val)}
|
||||
placeholder={"Pepper should ..."}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} id="norms"/>
|
||||
</div>
|
||||
</>;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Reduces each Norm, including its children down into its relevant data.
|
||||
* @param node The Node Properties of this node.
|
||||
* @param _nodes all the nodes in the graph
|
||||
*/
|
||||
export function NormReduce(node: Node, _nodes: Node[]) {
|
||||
const data = node.data as NormNodeData;
|
||||
return {
|
||||
id: node.id,
|
||||
label: data.label,
|
||||
norm: data.norm,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is made with this node type 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) {
|
||||
// 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 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) {
|
||||
// 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 NormDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||
// no additional connection logic exists yet
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { PhaseNodeData } from "./PhaseNode";
|
||||
|
||||
/**
|
||||
* Default data for this node
|
||||
*/
|
||||
export const PhaseNodeDefaults: PhaseNodeData = {
|
||||
label: "Phase Node",
|
||||
droppable: true,
|
||||
children: [],
|
||||
hasReduce: true,
|
||||
};
|
||||
148
src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx
Normal file
148
src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
Handle,
|
||||
type NodeProps,
|
||||
Position,
|
||||
type Node
|
||||
} from '@xyflow/react';
|
||||
import { Toolbar } from '../components/NodeComponents';
|
||||
import styles from '../../VisProg.module.css';
|
||||
import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry';
|
||||
import useFlowStore from '../VisProgStores';
|
||||
import { TextField } from '../../../../components/TextField';
|
||||
|
||||
/**
|
||||
* The default data dot a phase node
|
||||
* @param label: the label of this phase
|
||||
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
||||
* @param children: ID's of children of this node
|
||||
* @param hasReduce: whether this node has reducing functionality (true by default)
|
||||
*/
|
||||
export type PhaseNodeData = {
|
||||
label: string;
|
||||
droppable: boolean;
|
||||
children: string[];
|
||||
hasReduce: boolean;
|
||||
};
|
||||
|
||||
export type PhaseNode = Node<PhaseNodeData>
|
||||
|
||||
/**
|
||||
* Defines how a phase node should be rendered
|
||||
* @param props NodeProps, like id, label, children
|
||||
* @returns React.JSX.Element
|
||||
*/
|
||||
export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
||||
const data = props.data;
|
||||
const {updateNodeData} = useFlowStore();
|
||||
const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value});
|
||||
const label_input_id = `phase_${props.id}_label_input`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodePhase}`}>
|
||||
<div className={"flex-row gap-sm"}>
|
||||
<label htmlFor={label_input_id}>Name:</label>
|
||||
<TextField
|
||||
id={label_input_id}
|
||||
value={data.label}
|
||||
setValue={updateLabel}
|
||||
placeholder={"Phase ..."}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="target" position={Position.Left} id="target"/>
|
||||
<Handle type="target" position={Position.Bottom} id="norms"/>
|
||||
<Handle type="source" position={Position.Right} id="source"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduces each phase, including its children down into its relevant data.
|
||||
* @param node the node which is being reduced
|
||||
* @param nodes all the nodes currently in the flow.
|
||||
* @returns A collection of all reduced nodes in this phase, starting with this phases' reduced data.
|
||||
*/
|
||||
export function PhaseReduce(node: Node, nodes: Node[]) {
|
||||
const thisnode = node as PhaseNode;
|
||||
const data = thisnode.data as PhaseNodeData;
|
||||
|
||||
// node typings that are not in phase
|
||||
const nodesNotInPhase: string[] = Object.entries(NodesInPhase)
|
||||
.filter(([, f]) => !f())
|
||||
.map(([t]) => t);
|
||||
|
||||
// node typings that then are in phase
|
||||
const nodesInPhase: string[] = Object.entries(NodeTypes)
|
||||
.filter(([t]) => !nodesNotInPhase.includes(t))
|
||||
.map(([t]) => t);
|
||||
|
||||
// children nodes - 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,
|
||||
label: data.label,
|
||||
};
|
||||
|
||||
nodesInPhase.forEach((type) => {
|
||||
const typedChildren = childrenNodes.filter((child) => child.type == type);
|
||||
const reducer = NodeReduces[type as keyof typeof NodeReduces];
|
||||
if (!reducer) {
|
||||
console.warn(`No reducer found for node type ${type}`);
|
||||
result[type + "s"] = [];
|
||||
} else {
|
||||
result[type + "s"] = typedChildren.map((child) => reducer(child, nodes));
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is made with this node type 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 node = _thisNode as PhaseNode
|
||||
const data = node.data as PhaseNodeData
|
||||
// we only add none phase nodes to the children
|
||||
if (!(useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'phase'))) {
|
||||
data.children.push(_sourceNodeId)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// no additional connection logic exists yet
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 node = _thisNode as PhaseNode
|
||||
const data = node.data as PhaseNodeData
|
||||
data.children = data.children.filter((child) => { if (child != _sourceNodeId) return child; });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// no additional connection logic exists yet
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { StartNodeData } from "./StartNode";
|
||||
|
||||
/**
|
||||
* Default data for this node.
|
||||
*/
|
||||
export const StartNodeDefaults: StartNodeData = {
|
||||
label: "Start Node",
|
||||
droppable: false,
|
||||
hasReduce: true
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Handle,
|
||||
type NodeProps,
|
||||
Position,
|
||||
type Node,
|
||||
} from '@xyflow/react';
|
||||
import { Toolbar } from '../components/NodeComponents';
|
||||
import styles from '../../VisProg.module.css';
|
||||
|
||||
|
||||
export type StartNodeData = {
|
||||
label: string;
|
||||
droppable: boolean;
|
||||
hasReduce: boolean;
|
||||
};
|
||||
|
||||
|
||||
export type StartNode = Node<StartNodeData>
|
||||
|
||||
|
||||
/**
|
||||
* Defines how a Norm node should be rendered
|
||||
* @param props NodeProps, like id, label, children
|
||||
* @returns React.JSX.Element
|
||||
*/
|
||||
export default function StartNode(props: NodeProps<StartNode>) {
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={props.id} allowDelete={false}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeStart}`}>
|
||||
<div className={"flex-row gap-sm"}>
|
||||
Start
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} id="source"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reduce function for this node type.
|
||||
* @param node this node
|
||||
* @param _nodes all the nodes in the graph
|
||||
* @returns a reduced structure of this node
|
||||
*/
|
||||
export function StartReduce(node: Node, _nodes: Node[]) {
|
||||
// Replace this for nodes functionality
|
||||
return {
|
||||
id: node.id
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TriggerNodeData } from "./TriggerNode";
|
||||
|
||||
/**
|
||||
* Default data for this node
|
||||
*/
|
||||
export const TriggerNodeDefaults: TriggerNodeData = {
|
||||
label: "Trigger Node",
|
||||
droppable: true,
|
||||
triggers: [],
|
||||
triggerType: "keywords",
|
||||
hasReduce: true,
|
||||
};
|
||||
246
src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx
Normal file
246
src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
Handle,
|
||||
type NodeProps,
|
||||
Position,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
} from '@xyflow/react';
|
||||
import { Toolbar } from '../components/NodeComponents';
|
||||
import styles from '../../VisProg.module.css';
|
||||
import useFlowStore from '../VisProgStores';
|
||||
import { useState } from 'react';
|
||||
import { RealtimeTextField, TextField } from '../../../../components/TextField';
|
||||
import duplicateIndices from '../../../../utils/duplicateIndices';
|
||||
|
||||
/**
|
||||
* The default data structure for a Trigger node
|
||||
*
|
||||
* Represents configuration for a node that activates when a specific condition is met,
|
||||
* such as keywords being spoken or emotions detected.
|
||||
*
|
||||
* @property label: the display label of this Trigger node.
|
||||
* @property droppable: Whether this node can be dropped from the toolbar (default: true).
|
||||
* @property triggerType - The type of trigger ("keywords" or a custom string).
|
||||
* @property triggers - The list of keyword triggers (if applicable).
|
||||
* @property hasReduce - Whether this node supports reduction logic.
|
||||
*/
|
||||
export type TriggerNodeData = {
|
||||
label: string;
|
||||
droppable: boolean;
|
||||
triggerType: "keywords" | string;
|
||||
triggers: Keyword[] | never;
|
||||
hasReduce: boolean;
|
||||
};
|
||||
|
||||
|
||||
export type TriggerNode = Node<TriggerNodeData>
|
||||
|
||||
|
||||
/**
|
||||
* Determines whether a Trigger node can connect to another node or edge.
|
||||
*
|
||||
* @param connection - The connection or edge being attempted to connect towards.
|
||||
* @returns `true` if the connection is defined; otherwise, `false`.
|
||||
*/
|
||||
export function TriggerNodeCanConnect(connection: Connection | Edge): boolean {
|
||||
return (connection != undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines how a Trigger node should be rendered
|
||||
* @param props - Node properties provided by React Flow, including `id` and `data`.
|
||||
* @returns The rendered TriggerNode React element (React.JSX.Element).
|
||||
*/
|
||||
export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
||||
const data = props.data;
|
||||
const {updateNodeData} = useFlowStore();
|
||||
|
||||
const setKeywords = (keywords: Keyword[]) => {
|
||||
updateNodeData(props.id, {...data, triggers: keywords});
|
||||
}
|
||||
|
||||
return <>
|
||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
|
||||
{data.triggerType === "emotion" && (
|
||||
<div className={"flex-row gap-md"}>Emotion?</div>
|
||||
)}
|
||||
{data.triggerType === "keywords" && (
|
||||
<Keywords
|
||||
keywords={data.triggers}
|
||||
setKeywords={setKeywords}
|
||||
/>
|
||||
)}
|
||||
<Handle type="source" position={Position.Right} id="TriggerSource"/>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces each Trigger, including its children down into its core data.
|
||||
* @param node - The Trigger node to reduce.
|
||||
* @param _nodes - The list of all nodes in the current flow graph.
|
||||
* @returns A simplified object containing the node label and its list of triggers.
|
||||
*/
|
||||
export function TriggerReduce(node: Node, _nodes: Node[]) {
|
||||
const data = node.data;
|
||||
switch (data.triggerType) {
|
||||
case "keywords":
|
||||
return {
|
||||
id: node.id,
|
||||
type: "keywords",
|
||||
label: data.label,
|
||||
keywords: data.triggers,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
...data,
|
||||
id: node.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Renders an input element that allows users to add new keyword triggers.
|
||||
*
|
||||
* When the input is committed, the `addKeyword` callback is called with the new keyword.
|
||||
*
|
||||
* @param param0 - An object containing the `addKeyword` function.
|
||||
* @returns A React element(React.JSX.Element) providing an input for adding keywords.
|
||||
*/
|
||||
function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const text_input_id = "keyword_adder_input";
|
||||
|
||||
return <div className={"flex-row gap-md"}>
|
||||
<label htmlFor={text_input_id}>New Keyword:</label>
|
||||
<RealtimeTextField
|
||||
id={text_input_id}
|
||||
value={input}
|
||||
setValue={setInput}
|
||||
onCommit={() => {
|
||||
if (!input) return;
|
||||
addKeyword(input);
|
||||
setInput("");
|
||||
}}
|
||||
placeholder={"..."}
|
||||
className={"flex-1"}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays and manages a list of keyword triggers for a Trigger node.
|
||||
* Handles adding, editing, and removing keywords, as well as detecting duplicate entries.
|
||||
*
|
||||
* @param keywords - The current list of keyword triggers.
|
||||
* @param setKeywords - A callback to update the keyword list in the parent node.
|
||||
* @returns A React element(React.JSX.Element) for editing keyword triggers.
|
||||
*/
|
||||
function Keywords({
|
||||
keywords,
|
||||
setKeywords,
|
||||
}: {
|
||||
keywords: Keyword[];
|
||||
setKeywords: (keywords: Keyword[]) => void;
|
||||
}) {
|
||||
type Interpolatable = string | number | boolean | bigint | null | undefined;
|
||||
|
||||
const inputElementId = (id: Interpolatable) => `keyword_${id}_input`;
|
||||
|
||||
/** Indices of duplicates in the keyword array. */
|
||||
const [duplicates, setDuplicates] = useState<number[]>([]);
|
||||
|
||||
function replace(id: string, value: string) {
|
||||
value = value.trim();
|
||||
const newKeywords = value === ""
|
||||
? keywords.filter((kw) => kw.id != id)
|
||||
: keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw);
|
||||
setKeywords(newKeywords);
|
||||
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
||||
}
|
||||
|
||||
function add(value: string) {
|
||||
value = value.trim();
|
||||
if (value === "") return;
|
||||
const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}];
|
||||
setKeywords(newKeywords);
|
||||
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
||||
}
|
||||
|
||||
return <>
|
||||
<span>Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken.</span>
|
||||
{[...keywords].map(({id, keyword}, index) => {
|
||||
return <div key={id} className={"flex-row gap-md"}>
|
||||
<label htmlFor={inputElementId(id)}>Keyword:</label>
|
||||
<TextField
|
||||
id={inputElementId(id)}
|
||||
value={keyword}
|
||||
setValue={(val) => replace(id, val)}
|
||||
placeholder={"..."}
|
||||
className={"flex-1"}
|
||||
invalid={duplicates.includes(index)}
|
||||
/>
|
||||
</div>;
|
||||
})}
|
||||
<KeywordAdder addKeyword={add} />
|
||||
</>;
|
||||
}
|
||||
19
src/utils/SaveLoad.ts
Normal file
19
src/utils/SaveLoad.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {type Edge, type Node } from "@xyflow/react";
|
||||
|
||||
export type SavedProject = {
|
||||
name: string;
|
||||
savedASavedProject: string; // ISO timestamp
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
};
|
||||
|
||||
// Creates a JSON Blob containing the current visual program (nodes + edges)
|
||||
export function makeProjectBlob(name: string, nodes: Node[], edges: Edge[]): Blob {
|
||||
const payload = {
|
||||
name,
|
||||
savedAt: new Date().toISOString(),
|
||||
nodes,
|
||||
edges,
|
||||
};
|
||||
return new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||
}
|
||||
@@ -2,12 +2,56 @@ 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>();
|
||||
@@ -24,6 +68,29 @@ export function cell<T>(initial: T): Cell<T> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook that subscribes a component to a Cell.
|
||||
*
|
||||
* Automatically re-renders the component whenever the Cell's value changes.
|
||||
* Uses React’s 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);
|
||||
}
|
||||
|
||||
@@ -127,10 +127,10 @@ describe("Logging component", () => {
|
||||
|
||||
render(<Logging/>);
|
||||
|
||||
expect(screen.getByText("Logs")).toBeInTheDocument();
|
||||
expect(screen.getByText("WARNING")).toBeInTheDocument();
|
||||
expect(screen.getByText("logging")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ping")).toBeInTheDocument();
|
||||
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) {
|
||||
@@ -141,7 +141,7 @@ describe("Logging component", () => {
|
||||
}
|
||||
|
||||
await user.click(timestamp);
|
||||
expect(screen.getByText("00:00:12.345")).toBeInTheDocument();
|
||||
expect(screen.getByText("00:00:12.345")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows the scroll-to-bottom button after a manual scroll and scrolls when clicked", async () => {
|
||||
@@ -188,7 +188,7 @@ describe("Logging component", () => {
|
||||
logCell.set({...current, message: "Updated"});
|
||||
});
|
||||
|
||||
expect(screen.getByText("Updated")).toBeInTheDocument();
|
||||
expect(screen.getByText("Updated")).toBeDefined();
|
||||
await waitFor(() => {
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
104
test/pages/connectedRobots/ConnectedRobots.test.tsx
Normal file
104
test/pages/connectedRobots/ConnectedRobots.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { render, screen, act, cleanup, waitFor } from '@testing-library/react';
|
||||
import ConnectedRobots from '../../../src/pages/ConnectedRobots/ConnectedRobots';
|
||||
|
||||
// Mock event source
|
||||
const mockInstances: MockEventSource[] = [];
|
||||
class MockEventSource {
|
||||
url: string;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
closed = false;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
mockInstances.push(this);
|
||||
}
|
||||
|
||||
sendMessage(data: string) {
|
||||
// Trigger whatever the component listens to
|
||||
this.onmessage?.({ data } as MessageEvent);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// mock event source generation with fake function that returns our fake mock source
|
||||
beforeAll(() => {
|
||||
// Cast globalThis to a type exposing EventSource and assign a mocked constructor.
|
||||
(globalThis as unknown as { EventSource?: typeof EventSource }).EventSource =
|
||||
jest.fn((url: string) => new MockEventSource(url)) as unknown as typeof EventSource;
|
||||
});
|
||||
|
||||
// clean after tests
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
jest.restoreAllMocks();
|
||||
mockInstances.length = 0;
|
||||
});
|
||||
|
||||
describe('ConnectedRobots', () => {
|
||||
test('renders initial state correctly', () => {
|
||||
render(<ConnectedRobots />);
|
||||
|
||||
// Check initial texts (before connection)
|
||||
expect(screen.getByText('Is robot currently connected?')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Robot is currently:\s*checking/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/If checking continues, make sure CB is properly loaded/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('updates to connected when message data is true', async () => {
|
||||
render(<ConnectedRobots />);
|
||||
const eventSource = mockInstances[0];
|
||||
expect(eventSource).toBeDefined();
|
||||
|
||||
// Check state after getting 'true' message
|
||||
await act(async () => {
|
||||
eventSource.sendMessage('true');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/connected! 🟢/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('updates to not connected when message data is false', async () => {
|
||||
render(<ConnectedRobots />);
|
||||
const eventSource = mockInstances[0];
|
||||
|
||||
// Check statew after getting 'false' message
|
||||
await act(async () => {
|
||||
eventSource.sendMessage('false');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/not connected.*🔴/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('handles invalid JSON gracefully', async () => {
|
||||
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
render(<ConnectedRobots />);
|
||||
const eventSource = mockInstances[0];
|
||||
|
||||
await act(async () => {
|
||||
eventSource.sendMessage('not-json');
|
||||
});
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
'Ping message not in correct format:',
|
||||
'not-json'
|
||||
);
|
||||
});
|
||||
|
||||
test('closes EventSource on unmount', () => {
|
||||
render(<ConnectedRobots />);
|
||||
const eventSource = mockInstances[0];
|
||||
const closeSpy = jest.spyOn(eventSource, 'close');
|
||||
cleanup();
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
expect(eventSource.closed).toBe(true);
|
||||
});
|
||||
});
|
||||
167
test/pages/robot/Robot.test.tsx
Normal file
167
test/pages/robot/Robot.test.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
||||
import {act} from '@testing-library/react';
|
||||
import type {Connection, Edge, Node} from "@xyflow/react";
|
||||
import { NodeDisconnections } from "../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts";
|
||||
import type {PhaseNodeData} from "../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
|
||||
import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
|
||||
import { mockReactFlow } from '../../../setupFlowTests.ts';
|
||||
|
||||
@@ -6,18 +9,187 @@ beforeAll(() => {
|
||||
mockReactFlow();
|
||||
});
|
||||
|
||||
// default state values for testing,
|
||||
const normNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Test',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
const phaseNode: Node = {
|
||||
id: 'phase-1',
|
||||
type: 'phase',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
label: 'Phase 1',
|
||||
droppable: true,
|
||||
children: ["norm-1"],
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
const testEdge: Edge = {
|
||||
id: 'xy-edge__1-2',
|
||||
source: 'norm-1',
|
||||
target: 'phase-1',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
}
|
||||
|
||||
const testStateReconnectEnd = {
|
||||
nodes: [phaseNode, normNode],
|
||||
edges: [testEdge],
|
||||
}
|
||||
|
||||
const phaseNodeUnconnected = {
|
||||
id: 'phase-2',
|
||||
type: 'phase',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
label: 'Phase 2',
|
||||
droppable: true,
|
||||
children: [],
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
const testConnection: Connection = {
|
||||
source: 'norm-1',
|
||||
target: 'phase-2',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
}
|
||||
const testStateOnConnect = {
|
||||
nodes: [phaseNodeUnconnected, normNode],
|
||||
edges: [],
|
||||
}
|
||||
|
||||
describe('FlowStore Functionality', () => {
|
||||
describe('Node changes', () => {
|
||||
// currently just using a single function from the ReactFlow library,
|
||||
// so testing would mean we are testing already tested behavior.
|
||||
// if implementation gets modified tests should be added for custom behavior
|
||||
});
|
||||
describe('ReactFlow onEdgesDelete', () => {
|
||||
test('Deleted edge is reflected in removed phaseNode child', () => {
|
||||
const {onEdgesDelete} = useFlowStore.getState();
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [{
|
||||
id: 'phase-1',
|
||||
type: 'phase',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
label: 'Phase 1',
|
||||
droppable: true,
|
||||
children: ["norm-1"],
|
||||
hasReduce: true,
|
||||
},
|
||||
},{
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Test',
|
||||
hasReduce: true,
|
||||
},
|
||||
}],
|
||||
edges: [], // edges is empty as onEdgesDelete is triggered after the edges are deleted
|
||||
})
|
||||
|
||||
act(() => {
|
||||
onEdgesDelete([testEdge])
|
||||
});
|
||||
|
||||
const outcome = useFlowStore.getState();
|
||||
expect((outcome.nodes[0].data as PhaseNodeData).children.length).toBe(0);
|
||||
})
|
||||
test('Deleted edge is reflected in phaseNode,even if normNode was already deleted and caused edge removal', () => {
|
||||
const { onEdgesDelete } = useFlowStore.getState();
|
||||
useFlowStore.setState({
|
||||
nodes: [{
|
||||
id: 'phase-1',
|
||||
type: 'phase',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
label: 'Phase 1',
|
||||
droppable: true,
|
||||
children: ["norm-1"],
|
||||
hasReduce: true,
|
||||
},
|
||||
}],
|
||||
edges: [], // edges is empty as onEdgesDelete is triggered after the edges are deleted
|
||||
})
|
||||
|
||||
act(() => {
|
||||
onEdgesDelete([testEdge]);
|
||||
})
|
||||
|
||||
const outcome = useFlowStore.getState();
|
||||
expect((outcome.nodes[0].data as PhaseNodeData).children.length).toBe(0);
|
||||
})
|
||||
test('edge removal resulting from deletion of targetNode calls only the connection function for the sourceNode', () => {
|
||||
const { onEdgesDelete } = useFlowStore.getState();
|
||||
useFlowStore.setState({
|
||||
nodes: [{
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Test',
|
||||
hasReduce: true,
|
||||
},
|
||||
}],
|
||||
edges: [], // edges is empty as onEdgesDelete is triggered after the edges are deleted
|
||||
})
|
||||
|
||||
const targetDisconnectSpy = jest.spyOn(NodeDisconnections.Targets, 'phase');
|
||||
const sourceDisconnectSpy = jest.spyOn(NodeDisconnections.Sources, 'norm');
|
||||
|
||||
act(() => {
|
||||
onEdgesDelete([testEdge]);
|
||||
})
|
||||
|
||||
expect(sourceDisconnectSpy).toHaveBeenCalledWith(normNode, 'phase-1');
|
||||
expect(targetDisconnectSpy).not.toHaveBeenCalled();
|
||||
|
||||
sourceDisconnectSpy.mockRestore();
|
||||
targetDisconnectSpy.mockRestore();
|
||||
})
|
||||
})
|
||||
describe('Edge changes', () => {
|
||||
// currently just using a single function from the ReactFlow library,
|
||||
// so testing would mean we are testing already tested behavior.
|
||||
// if implementation gets modified tests should be added for custom behavior
|
||||
})
|
||||
describe('ReactFlow onConnect', () => {
|
||||
test('Adds connecting node to children of phaseNode', () => {
|
||||
const {onConnect} = useFlowStore.getState();
|
||||
useFlowStore.setState({
|
||||
nodes: testStateOnConnect.nodes,
|
||||
edges: testStateOnConnect.edges
|
||||
})
|
||||
|
||||
act(() => {
|
||||
onConnect(testConnection);
|
||||
})
|
||||
|
||||
const outcome = useFlowStore.getState();
|
||||
|
||||
// phaseNode adds the normNode to its children
|
||||
expect((outcome.nodes[0].data as PhaseNodeData).children).toEqual(['norm-1']);
|
||||
|
||||
})
|
||||
test('adds an edge when onConnect is triggered', () => {
|
||||
const {onConnect} = useFlowStore.getState();
|
||||
|
||||
@@ -39,6 +211,53 @@ describe('FlowStore Functionality', () => {
|
||||
});
|
||||
});
|
||||
describe('ReactFlow onReconnect', () => {
|
||||
test('PhaseNodes correctly change their children', () => {
|
||||
const {onReconnect} = useFlowStore.getState();
|
||||
useFlowStore.setState({
|
||||
nodes: [{
|
||||
id: 'phase-1',
|
||||
type: 'phase',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
label: 'Phase 1',
|
||||
droppable: true,
|
||||
children: ["norm-1"],
|
||||
hasReduce: true,
|
||||
},
|
||||
},{
|
||||
id: 'phase-2',
|
||||
type: 'phase',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
label: 'Phase 2',
|
||||
droppable: true,
|
||||
children: [],
|
||||
hasReduce: true,
|
||||
},
|
||||
},{
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Test',
|
||||
hasReduce: true,
|
||||
},
|
||||
}],
|
||||
edges: [testEdge],
|
||||
})
|
||||
|
||||
act(() => {
|
||||
onReconnect(testEdge, testConnection);
|
||||
})
|
||||
|
||||
const outcome = useFlowStore.getState();
|
||||
|
||||
// phaseNodes lose and gain children when norm node's connection is changed from phaseNode to PhaseNodeUnconnected
|
||||
expect((outcome.nodes[1].data as PhaseNodeData).children).toEqual(['norm-1']);
|
||||
expect((outcome.nodes[0].data as PhaseNodeData).children).toEqual([]);
|
||||
})
|
||||
test('reconnects an existing edge when onReconnect is triggered', () => {
|
||||
const {onReconnect} = useFlowStore.getState();
|
||||
const oldEdge = {
|
||||
@@ -93,36 +312,63 @@ describe('FlowStore Functionality', () => {
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
||||
test('successfully removes edge if no successful reconnect occurred', () => {
|
||||
const {onReconnectEnd} = useFlowStore.getState();
|
||||
useFlowStore.setState({edgeReconnectSuccessful: false});
|
||||
useFlowStore.setState({
|
||||
edgeReconnectSuccessful: false,
|
||||
edges: testStateReconnectEnd.edges,
|
||||
nodes: testStateReconnectEnd.nodes
|
||||
});
|
||||
|
||||
act(() => {
|
||||
onReconnectEnd(null, {id: 'xy-edge__A-B'});
|
||||
onReconnectEnd(null, testEdge);
|
||||
});
|
||||
|
||||
const updatedState = useFlowStore.getState();
|
||||
expect(updatedState.edgeReconnectSuccessful).toBe(true);
|
||||
expect(updatedState.edges).toHaveLength(0);
|
||||
expect(updatedState.nodes[0].data.children).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not remove reconnecting edge if successful reconnect occurred', () => {
|
||||
const {onReconnectEnd} = useFlowStore.getState();
|
||||
useFlowStore.setState({
|
||||
edgeReconnectSuccessful: true,
|
||||
edges: [testEdge],
|
||||
nodes: [{
|
||||
id: 'phase-1',
|
||||
type: 'phase',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
label: 'Phase 1',
|
||||
droppable: true,
|
||||
children: ["norm-1"],
|
||||
hasReduce: true,
|
||||
},
|
||||
},{
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Test',
|
||||
hasReduce: true,
|
||||
},
|
||||
}]
|
||||
});
|
||||
|
||||
act(() => {
|
||||
onReconnectEnd(null, {id: 'xy-edge__A-B'});
|
||||
onReconnectEnd(null, testEdge);
|
||||
});
|
||||
|
||||
const updatedState = useFlowStore.getState();
|
||||
expect(updatedState.edgeReconnectSuccessful).toBe(true);
|
||||
expect(updatedState.edges).toHaveLength(1);
|
||||
expect(updatedState.edges).toMatchObject([
|
||||
{
|
||||
id: 'xy-edge__A-B',
|
||||
source: 'A',
|
||||
target: 'B'
|
||||
}]
|
||||
);
|
||||
expect(updatedState.edges).toMatchObject([testEdge]);
|
||||
expect(updatedState.nodes[0].data.children).toEqual(["norm-1"]);
|
||||
});
|
||||
});
|
||||
describe('ReactFlow deleteNode', () => {
|
||||
|
||||
@@ -1,33 +1,106 @@
|
||||
import { mockReactFlow } from '../../../../setupFlowTests.ts';
|
||||
import {act} from "@testing-library/react";
|
||||
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
||||
import {addNode} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx";
|
||||
import { getByTestId, render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||
import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg';
|
||||
|
||||
|
||||
beforeAll(() => {
|
||||
mockReactFlow();
|
||||
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
window.ResizeObserver = ResizeObserver;
|
||||
|
||||
jest.mock('@neodrag/react', () => ({
|
||||
useDraggable: (ref: React.RefObject<HTMLElement>, options: any) => {
|
||||
// We access the real useEffect from React to attach a listener
|
||||
// This bridges the gap between the test's userEvent and the component's logic
|
||||
const { useEffect } = jest.requireActual('react');
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
// When the test fires a "pointerup" (end of click/drag),
|
||||
// we manually trigger the library's onDragEnd callback.
|
||||
const handlePointerUp = (e: PointerEvent) => {
|
||||
if (options.onDragEnd) {
|
||||
options.onDragEnd({ event: e });
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('pointerup', handlePointerUp as EventListener);
|
||||
return () => {
|
||||
element.removeEventListener('pointerup', handlePointerUp as EventListener);
|
||||
};
|
||||
}, [ref, options]);
|
||||
},
|
||||
}));
|
||||
|
||||
// We will mock @xyflow/react so we control screenToFlowPosition
|
||||
jest.mock('@xyflow/react', () => {
|
||||
const actual = jest.requireActual('@xyflow/react');
|
||||
return {
|
||||
...actual,
|
||||
useReactFlow: () => ({
|
||||
screenToFlowPosition: ({ x, y }: { x: number; y: number }) => ({
|
||||
x: x - 100,
|
||||
y: y - 100,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Drag-and-Drop sidebar', () => {
|
||||
test.each(['phase', 'phase'])('new nodes get added correctly', (nodeType: string) => {
|
||||
act(()=> {
|
||||
addNode(nodeType, {x:100, y:100});
|
||||
})
|
||||
const updatedState = useFlowStore.getState();
|
||||
expect(updatedState.nodes.length).toBe(1);
|
||||
expect(updatedState.nodes[0].type).toBe(nodeType);
|
||||
describe("Drag & drop node creation", () => {
|
||||
|
||||
test("drops a phase node inside the canvas and adds it with transformed position", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const { container } = render(<VisProgPage />);
|
||||
|
||||
// --- Mock ReactFlow bounding box ---
|
||||
// Your DndToolbar checks these values:
|
||||
const flowEl = container.querySelector('.react-flow');
|
||||
jest.spyOn(flowEl!, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
right: 800,
|
||||
top: 0,
|
||||
bottom: 600,
|
||||
width: 800,
|
||||
height: 600,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {},
|
||||
});
|
||||
|
||||
|
||||
const phaseLabel = getByTestId(container, 'draggable-phase')
|
||||
|
||||
await user.pointer([
|
||||
// touch the screen at element1
|
||||
{keys: '[TouchA>]', target: phaseLabel},
|
||||
// move the touch pointer to element2
|
||||
{pointerName: 'TouchA', coords: {x: 300, y: 250}},
|
||||
// release the touch pointer at the last position (element2)
|
||||
{keys: '[/TouchA]'},
|
||||
]);
|
||||
|
||||
// Read the Zustand store
|
||||
const { nodes } = useFlowStore.getState();
|
||||
|
||||
// --- Assertions ---
|
||||
expect(nodes.length).toBe(1);
|
||||
|
||||
const node = nodes[0];
|
||||
|
||||
expect(node.type).toBe("phase");
|
||||
expect(node.id).toBe("phase-1");
|
||||
|
||||
// screenToFlowPosition was mocked to subtract 100
|
||||
expect(node.position).toEqual({
|
||||
x: 200,
|
||||
y: 150,
|
||||
});
|
||||
});
|
||||
test.each(['phase', 'norm'])('new nodes get correct Id', (nodeType) => {
|
||||
act(()=> {
|
||||
addNode(nodeType, {x:100, y:100});
|
||||
addNode(nodeType, {x:100, y:100});
|
||||
})
|
||||
const updatedState = useFlowStore.getState();
|
||||
expect(updatedState.nodes.length).toBe(2);
|
||||
expect(updatedState.nodes[0].id).toBe(`${nodeType}-1`);
|
||||
expect(updatedState.nodes[1].id).toBe(`${nodeType}-2`);
|
||||
});
|
||||
test('throws error on unexpected node type', () => {
|
||||
expect(() => addNode('I do not Exist', {x:100, y:100})).toThrow("Node I do not Exist not found");
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
// SaveLoadPanel.all.test.tsx
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
|
||||
import SaveLoadPanel from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx';
|
||||
import { makeProjectBlob } from '../../../../../src/utils/SaveLoad.ts';
|
||||
import { mockReactFlow } from "../../../../setupFlowTests.ts"; // optional helper if present
|
||||
|
||||
// helper to read Blob contents in tests (works in Node/Jest env)
|
||||
async function blobToText(blob: Blob): Promise<string> {
|
||||
if (typeof (blob as any).text === "function") return await (blob as any).text();
|
||||
if (typeof (blob as any).arrayBuffer === "function") {
|
||||
const buf = await (blob as any).arrayBuffer();
|
||||
return new TextDecoder().decode(buf);
|
||||
}
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(String(fr.result));
|
||||
fr.onerror = () => reject(fr.error);
|
||||
fr.readAsText(blob);
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
// if you have a mockReactFlow helper used in other tests, call it
|
||||
if (typeof mockReactFlow === "function") mockReactFlow();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// clear and seed the zustand store to a known empty state
|
||||
act(() => {
|
||||
const { setNodes, setEdges } = useFlowStore.getState();
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
});
|
||||
|
||||
// Ensure URL.createObjectURL exists so jest.spyOn works
|
||||
if (!URL.createObjectURL) URL.createObjectURL = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("SaveLoadPanel - combined tests", () => {
|
||||
test("makeProjectBlob creates a valid JSON blob", async () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: "n1",
|
||||
type: "start",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { label: "Start" },
|
||||
} as any,
|
||||
];
|
||||
const edges: any[] = [];
|
||||
|
||||
const blob = makeProjectBlob("my-project", nodes, edges);
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
|
||||
const text = await blobToText(blob);
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
expect(parsed.name).toBe("my-project");
|
||||
expect(typeof parsed.savedAt).toBe("string");
|
||||
expect(Array.isArray(parsed.nodes)).toBe(true);
|
||||
expect(Array.isArray(parsed.edges)).toBe(true);
|
||||
expect(parsed.nodes).toEqual(nodes);
|
||||
expect(parsed.edges).toEqual(edges);
|
||||
});
|
||||
|
||||
test("onSave creates a blob URL and sets anchor href", async () => {
|
||||
// Seed the store so onSave has nodes to save
|
||||
act(() => {
|
||||
useFlowStore.getState().setNodes([
|
||||
{ id: "start", type: "start", position: { x: 0, y: 0 }, data: { label: "start" } } as any,
|
||||
]);
|
||||
useFlowStore.getState().setEdges([]);
|
||||
});
|
||||
|
||||
// Ensure createObjectURL exists and spy it
|
||||
if (!URL.createObjectURL) URL.createObjectURL = jest.fn();
|
||||
const createObjectURLSpy = jest.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-url");
|
||||
|
||||
render(<SaveLoadPanel />);
|
||||
|
||||
const saveAnchor = screen.getByText(/Save Graph/i) as HTMLAnchorElement;
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(saveAnchor);
|
||||
});
|
||||
|
||||
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
|
||||
const blobArg = createObjectURLSpy.mock.calls[0][0];
|
||||
expect(blobArg).toBeInstanceOf(Blob);
|
||||
|
||||
expect(saveAnchor.getAttribute("href")).toBe("blob:fake-url");
|
||||
|
||||
const text = await blobToText(blobArg as Blob);
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
expect(parsed.name).toBeDefined();
|
||||
expect(parsed.nodes).toBeDefined();
|
||||
expect(parsed.edges).toBeDefined();
|
||||
|
||||
createObjectURLSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("onLoad with invalid JSON does not update store", async () => {
|
||||
const file = new File(["not json"], "bad.json", { type: "application/json" });
|
||||
file.text = jest.fn(() => Promise.resolve(`{"bad json`));
|
||||
|
||||
window.alert = jest.fn();
|
||||
|
||||
render(<SaveLoadPanel />);
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
// Give some input
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.alert).toHaveBeenCalledTimes(1);
|
||||
|
||||
const nodesAfter = useFlowStore.getState().nodes;
|
||||
expect(nodesAfter).toHaveLength(0);
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
test("onLoad resolves to null when no file is chosen (user cancels) and does not update store", async () => {
|
||||
render(<SaveLoadPanel />);
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
// Click Load to set resolver
|
||||
const loadButton = screen.getByLabelText(/load graph/i);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(loadButton);
|
||||
// simulate user cancelling: change with empty files
|
||||
fireEvent.change(input, { target: { files: [] } });
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const nodesAfter = useFlowStore.getState().nodes;
|
||||
const edgesAfter = useFlowStore.getState().edges;
|
||||
expect(nodesAfter).toHaveLength(0);
|
||||
expect(edgesAfter).toHaveLength(0);
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { act } from '@testing-library/react';
|
||||
import ScrollIntoView from '../../../../../src/components/ScrollIntoView';
|
||||
|
||||
test('scrolls the element into view on render', () => {
|
||||
const scrollMock = jest.fn();
|
||||
HTMLElement.prototype.scrollIntoView = scrollMock;
|
||||
|
||||
act(() => {
|
||||
render(<ScrollIntoView />);
|
||||
});
|
||||
|
||||
expect(scrollMock).toHaveBeenCalledWith({ behavior: 'smooth' });
|
||||
});
|
||||
@@ -0,0 +1,749 @@
|
||||
import { describe, it, beforeEach } from '@jest/globals';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
||||
import NormNode, {
|
||||
NormReduce,
|
||||
type NormNodeData,
|
||||
NormConnectionSource, NormConnectionTarget
|
||||
} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode';
|
||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||
import type { Node } from '@xyflow/react';
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
|
||||
|
||||
describe('NormNode', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
user = userEvent.setup();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the norm node with default data', () => {
|
||||
const mockNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<NormNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText('Pepper should ...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with pre-populated norm text', () => {
|
||||
const mockNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Be respectful to humans',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<NormNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('Be respectful to humans');
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with selected state', () => {
|
||||
const mockNode: Node<NormNodeData> = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<NormNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const norm = screen.getByText("Norm :")
|
||||
expect(norm).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with dragging state', () => {
|
||||
const mockNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Dragged norm',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<NormNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('Dragged norm');
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should update norm text when user types in the input field', async () => {
|
||||
const mockNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<NormNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Pepper should ...');
|
||||
await user.type(input, 'Be polite to guests{enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const updatedNode = state.nodes.find(n => n.id === 'norm-1');
|
||||
expect(updatedNode?.data.norm).toBe('Be polite to guests');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle clearing the norm text', async () => {
|
||||
const mockNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Initial norm text',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<NormNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('Initial norm text') as HTMLInputElement;
|
||||
|
||||
// clearing the norm text is the same as just deleting all characters one by one
|
||||
// TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/
|
||||
for (let a = 0; a < 'Initial norm text'.length; a++){
|
||||
await user.type(input, '{backspace}')
|
||||
}
|
||||
await user.type(input,'{enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const updatedNode = state.nodes.find(n => n.id === 'norm-1');
|
||||
expect(updatedNode?.data.norm).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('should update norm text multiple times', async () => {
|
||||
const mockNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<NormNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Pepper should ...');
|
||||
await user.type(input, 'First norm{enter}');
|
||||
await waitFor(() => {
|
||||
expect(useFlowStore.getState().nodes[0].data.norm).toBe('First norm');
|
||||
});
|
||||
|
||||
|
||||
// TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/
|
||||
for (let a = 0; a < 'First norm'.length; a++){
|
||||
await user.type(input, '{backspace}')
|
||||
}
|
||||
|
||||
await user.type(input, 'Second norm{enter}');
|
||||
await waitFor(() => {
|
||||
expect(useFlowStore.getState().nodes[0].data.norm).toBe('Second norm');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle special characters in norm text', async () => {
|
||||
const mockNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<NormNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Pepper should ...');
|
||||
await user.type(input, "Don't harm & be nice!{enter}" );
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useFlowStore.getState().nodes[0].data.norm).toBe("Don't harm & be nice!");
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle long norm text', async () => {
|
||||
const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.';
|
||||
const mockNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<NormNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Pepper should ...');
|
||||
await user.type(input, longText);
|
||||
await user.type(input, "{enter}")
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useFlowStore.getState().nodes[0].data.norm).toBe(longText);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('NormReduce Function', () => {
|
||||
it('should reduce a norm node to its essential data', () => {
|
||||
const normNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Safety Norm',
|
||||
droppable: true,
|
||||
norm: 'Never harm humans',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
const allNodes: Node[] = [normNode];
|
||||
const result = NormReduce(normNode, allNodes);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'norm-1',
|
||||
label: 'Safety Norm',
|
||||
norm: 'Never harm humans',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reduce multiple norm nodes independently', () => {
|
||||
const norm1: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Norm 1',
|
||||
droppable: true,
|
||||
norm: 'Be helpful',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
const norm2: Node = {
|
||||
id: 'norm-2',
|
||||
type: 'norm',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
label: 'Norm 2',
|
||||
droppable: true,
|
||||
norm: 'Be honest',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
const allNodes: Node[] = [norm1, norm2];
|
||||
|
||||
const result1 = NormReduce(norm1, allNodes);
|
||||
const result2 = NormReduce(norm2, allNodes);
|
||||
|
||||
expect(result1.id).toBe('norm-1');
|
||||
expect(result1.norm).toBe('Be helpful');
|
||||
expect(result2.id).toBe('norm-2');
|
||||
expect(result2.norm).toBe('Be honest');
|
||||
});
|
||||
|
||||
it('should handle empty norm text', () => {
|
||||
const normNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Empty Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = NormReduce(normNode, [normNode]);
|
||||
|
||||
expect(result.norm).toBe('');
|
||||
expect(result.id).toBe('norm-1');
|
||||
});
|
||||
|
||||
it('should preserve node label in reduction', () => {
|
||||
const normNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Custom Label',
|
||||
droppable: false,
|
||||
norm: 'Test norm',
|
||||
hasReduce: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = NormReduce(normNode, [normNode]);
|
||||
|
||||
expect(result.label).toBe('Custom Label');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NormConnects Function', () => {
|
||||
it('should handle connection without errors', () => {
|
||||
const normNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Test',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
const phaseNode: Node = {
|
||||
id: 'phase-1',
|
||||
type: 'phase',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
label: 'Phase 1',
|
||||
droppable: true,
|
||||
children: [],
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
NormConnectionSource(normNode, phaseNode.id);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle connection when norm is target', () => {
|
||||
const normNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Test',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
const phaseNode: Node = {
|
||||
id: 'phase-1',
|
||||
type: 'phase',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
label: 'Phase 1',
|
||||
droppable: true,
|
||||
children: [],
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
NormConnectionTarget(normNode, phaseNode.id);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle self-connection', () => {
|
||||
const normNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Test',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
NormConnectionTarget(normNode, normNode.id);
|
||||
NormConnectionSource(normNode, normNode.id);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with Store', () => {
|
||||
it('should properly update the store when editing norm text', async () => {
|
||||
const mockNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<NormNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Pepper should ...');
|
||||
|
||||
// TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/
|
||||
for (let a = 0; a < 20; a++){
|
||||
await user.type(input, '{backspace}')
|
||||
}
|
||||
await user.type(input, 'New norm value{enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
expect(state.nodes).toHaveLength(1);
|
||||
expect(state.nodes[0].id).toBe('norm-1');
|
||||
expect(state.nodes[0].data.norm).toBe('New norm value');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not affect other nodes when updating one norm node', async () => {
|
||||
const norm1: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Norm 1',
|
||||
droppable: true,
|
||||
norm: 'Original norm 1',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
const norm2: Node = {
|
||||
id: 'norm-2',
|
||||
type: 'norm',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
label: 'Norm 2',
|
||||
droppable: true,
|
||||
norm: 'Original norm 2',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [norm1, norm2],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<NormNode
|
||||
id={norm1.id}
|
||||
type={norm1.type as string}
|
||||
data={norm1.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('Original norm 1') as HTMLInputElement;
|
||||
|
||||
|
||||
// TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/
|
||||
for (let a = 0; a < 20; a++){
|
||||
await user.type(input, '{backspace}')
|
||||
}
|
||||
await user.type(input, 'Updated norm 1{enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const updatedNorm1 = state.nodes.find(n => n.id === 'norm-1');
|
||||
const unchangedNorm2 = state.nodes.find(n => n.id === 'norm-2');
|
||||
|
||||
expect(updatedNorm1?.data.norm).toBe('Updated norm 1');
|
||||
expect(unchangedNorm2?.data.norm).toBe('Original norm 2');
|
||||
});
|
||||
});
|
||||
|
||||
it('should maintain data consistency with multiple rapid updates', async () => {
|
||||
const mockNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'haa haa fuyaaah - link',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<NormNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Pepper should ...');
|
||||
|
||||
await user.type(input, 'a');
|
||||
await waitFor(() => {
|
||||
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link');
|
||||
});
|
||||
|
||||
await user.type(input, 'b');
|
||||
await waitFor(() => {
|
||||
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link');
|
||||
});
|
||||
|
||||
await user.type(input, 'c');
|
||||
await waitFor(() => {
|
||||
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link');
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||
import type { PhaseNodeData } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode";
|
||||
import { getByTestId, render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg';
|
||||
|
||||
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
window.ResizeObserver = ResizeObserver;
|
||||
|
||||
jest.mock('@neodrag/react', () => ({
|
||||
useDraggable: (ref: React.RefObject<HTMLElement>, options: any) => {
|
||||
// We access the real useEffect from React to attach a listener
|
||||
// This bridges the gap between the test's userEvent and the component's logic
|
||||
const { useEffect } = jest.requireActual('react');
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
// When the test fires a "pointerup" (end of click/drag),
|
||||
// we manually trigger the library's onDragEnd callback.
|
||||
const handlePointerUp = (e: PointerEvent) => {
|
||||
if (options.onDragEnd) {
|
||||
options.onDragEnd({ event: e });
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('pointerup', handlePointerUp as EventListener);
|
||||
return () => {
|
||||
element.removeEventListener('pointerup', handlePointerUp as EventListener);
|
||||
};
|
||||
}, [ref, options]);
|
||||
},
|
||||
}));
|
||||
|
||||
describe('PhaseNode', () => {
|
||||
it('each created phase gets its own children array, not the same reference ', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const { container } = render(<VisProgPage />);
|
||||
|
||||
// --- Mock ReactFlow bounding box ---
|
||||
// Your DndToolbar checks these values:
|
||||
const flowEl = container.querySelector('.react-flow');
|
||||
jest.spyOn(flowEl!, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
right: 800,
|
||||
top: 0,
|
||||
bottom: 600,
|
||||
width: 800,
|
||||
height: 600,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {},
|
||||
});
|
||||
|
||||
// Find the draggable norm node in the toolbar
|
||||
const phaseButton = getByTestId(container, 'draggable-phase')
|
||||
|
||||
// Simulate dropping phase down in graph (twice)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await user.pointer([
|
||||
// touch the screen at element1
|
||||
{keys: '[TouchA>]', target: phaseButton},
|
||||
// move the touch pointer to element2
|
||||
{pointerName: 'TouchA', coords: {x: 300, y: 250}},
|
||||
// release the touch pointer at the last position (element2)
|
||||
{keys: '[/TouchA]'},
|
||||
]);
|
||||
}
|
||||
|
||||
// Find nodes
|
||||
const nodes = useFlowStore.getState().nodes;
|
||||
const p1 = nodes.find((x) => x.id === 'phase-1')!;
|
||||
const p2 = nodes.find((x) => x.id === 'phase-2')!;
|
||||
|
||||
// expect same value, not same reference
|
||||
expect(p1.data.children).not.toBe(p2.data.children);
|
||||
expect(p1.data.children).toEqual(p2.data.children);
|
||||
|
||||
// Add nodes to children
|
||||
const p1_data = p1.data as PhaseNodeData;
|
||||
const p2_data = p2.data as PhaseNodeData;
|
||||
p1_data.children.push("norm-1");
|
||||
p2_data.children.push("norm-2");
|
||||
p2_data.children.push("goal-1");
|
||||
|
||||
// check that after adding, its not the same reference, and its not the same children
|
||||
expect(p1.data.children).not.toBe(p2.data.children);
|
||||
expect(p1.data.children).not.toEqual(p2.data.children);
|
||||
|
||||
// expect them to have the correct length.
|
||||
expect(p1_data.children.length == 1);
|
||||
expect(p2_data.children.length == 2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it } from '@jest/globals';
|
||||
import '@testing-library/jest-dom';
|
||||
import { screen } from '@testing-library/react';
|
||||
import type { Node } from '@xyflow/react';
|
||||
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
||||
import StartNode, {
|
||||
StartConnectionSource, StartConnectionTarget,
|
||||
StartReduce
|
||||
} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode';
|
||||
|
||||
|
||||
describe('StartNode', () => {
|
||||
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders the StartNode correctly', () => {
|
||||
const mockNode: Node = {
|
||||
id: 'start-1',
|
||||
type: 'start', // TypeScript now knows this is a string
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Start Node',
|
||||
droppable: false,
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<StartNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type!} // <--- fix here
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={false}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
expect(screen.getByText('Start')).toBeInTheDocument();
|
||||
|
||||
// The handle should exist in the DOM
|
||||
expect(document.querySelector('[data-handleid="source"]')).toBeInTheDocument();
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
describe('StartReduce Function', () => {
|
||||
it('reduces the StartNode to its minimal structure', () => {
|
||||
const mockNode: Node = {
|
||||
id: 'start-1',
|
||||
type: 'start',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Start Node',
|
||||
droppable: false,
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = StartReduce(mockNode, [mockNode]);
|
||||
expect(result).toEqual({ id: 'start-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('StartConnects Function', () => {
|
||||
it('handles connections without throwing', () => {
|
||||
const startNode: Node = {
|
||||
id: 'start-1',
|
||||
type: 'start',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Start Node',
|
||||
droppable: false,
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
const otherNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
label: 'Norm Node',
|
||||
droppable: true,
|
||||
norm: 'test',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StartConnectionSource(startNode, otherNode.id)).not.toThrow();
|
||||
expect(() => StartConnectionTarget(startNode, otherNode.id)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, it, beforeEach } from '@jest/globals';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
||||
import TriggerNode, {
|
||||
TriggerReduce,
|
||||
TriggerNodeCanConnect,
|
||||
type TriggerNodeData,
|
||||
TriggerConnectionSource, TriggerConnectionTarget
|
||||
} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode';
|
||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||
import type { Node } from '@xyflow/react';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
describe('TriggerNode', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
user = userEvent.setup();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render TriggerNode with keywords type', () => {
|
||||
const mockNode: Node<TriggerNodeData> = {
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Keyword Trigger',
|
||||
droppable: true,
|
||||
triggerType: 'keywords',
|
||||
triggers: [],
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<TriggerNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Triggers when the keyword is spoken/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render TriggerNode with emotion type', () => {
|
||||
const mockNode: Node<TriggerNodeData> = {
|
||||
id: 'trigger-2',
|
||||
type: 'trigger',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Emotion Trigger',
|
||||
droppable: true,
|
||||
triggerType: 'emotion',
|
||||
triggers: [],
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<TriggerNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Emotion\?/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should add a new keyword', async () => {
|
||||
const mockNode: Node<TriggerNodeData> = {
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Keyword Trigger',
|
||||
droppable: true,
|
||||
triggerType: 'keywords',
|
||||
triggers: [],
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({ nodes: [mockNode], edges: [] });
|
||||
|
||||
renderWithProviders(
|
||||
<TriggerNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('...');
|
||||
await user.type(input, 'hello{enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node<TriggerNodeData> | undefined;
|
||||
expect(node?.data.triggers.length).toBe(1);
|
||||
expect(node?.data.triggers[0].keyword).toBe('hello');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it('should remove a keyword when cleared', async () => {
|
||||
const mockNode: Node<TriggerNodeData> = {
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Keyword Trigger',
|
||||
droppable: true,
|
||||
triggerType: 'keywords',
|
||||
triggers: [{ id: 'kw1', keyword: 'hello' }],
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({ nodes: [mockNode], edges: [] });
|
||||
|
||||
renderWithProviders(
|
||||
<TriggerNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('hello');
|
||||
for (let i = 0; i < 'hello'.length; i++) {
|
||||
await user.type(input, '{backspace}');
|
||||
}
|
||||
await user.type(input, '{enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node<TriggerNodeData> | undefined;
|
||||
expect(node?.data.triggers.length).toBe(0);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
describe('TriggerReduce Function', () => {
|
||||
it('should reduce a trigger node to its essential data', () => {
|
||||
const triggerNode: Node = {
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Keyword Trigger',
|
||||
droppable: true,
|
||||
triggerType: 'keywords',
|
||||
triggers: [{ id: 'kw1', keyword: 'hello' }],
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
const allNodes: Node[] = [triggerNode];
|
||||
const result = TriggerReduce(triggerNode, allNodes);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'trigger-1',
|
||||
type: 'keywords',
|
||||
label: 'Keyword Trigger',
|
||||
keywords: [{ id: 'kw1', keyword: 'hello' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('TriggerConnects Function', () => {
|
||||
it('should handle connection without errors', () => {
|
||||
const node1: Node = {
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Trigger 1',
|
||||
droppable: true,
|
||||
triggerType: 'keywords',
|
||||
triggers: [],
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
const node2: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
label: 'Norm 1',
|
||||
droppable: true,
|
||||
norm: 'test',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
TriggerConnectionSource(node1, node2.id);
|
||||
TriggerConnectionTarget(node1, node2.id);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should return true for TriggerNodeCanConnect if connection exists', () => {
|
||||
const connection = { source: 'trigger-1', target: 'norm-1' };
|
||||
expect(TriggerNodeCanConnect(connection as any)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, beforeEach } from '@jest/globals';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
||||
import type { XYPosition } from '@xyflow/react';
|
||||
import { NodeTypes, NodeDefaults, NodeConnections, NodeReduces, NodesInPhase } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry';
|
||||
import '@testing-library/jest-dom'
|
||||
import { createElement } from 'react';
|
||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||
|
||||
|
||||
describe('NormNode', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable?: boolean) {
|
||||
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
|
||||
const newData = {
|
||||
id: id,
|
||||
type: type,
|
||||
position: position,
|
||||
data: data,
|
||||
deletable: deletable,
|
||||
}
|
||||
return {...defaultData, ...newData}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reduces the graph into its phases' information and recursively calls their reducing function
|
||||
*/
|
||||
function graphReducer() {
|
||||
const { nodes } = useFlowStore.getState();
|
||||
return nodes
|
||||
.filter((n) => n.type == 'phase')
|
||||
.map((n) => {
|
||||
const reducer = NodeReduces['phase'];
|
||||
return reducer(n, nodes)
|
||||
});
|
||||
}
|
||||
|
||||
function getAllTypes() {
|
||||
return Object.entries(NodeTypes).map(([t])=>t)
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
test.each(getAllTypes())('it should render %s node with the default data', (nodeType) => {
|
||||
const lengthBefore = screen.getAllByText(/.*/).length;
|
||||
|
||||
const newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {});
|
||||
|
||||
const found = Object.entries(NodeTypes).find(([t]) => t === nodeType);
|
||||
const uiElement = found ? found[1] : null;
|
||||
|
||||
expect(uiElement).not.toBeNull();
|
||||
const props = {
|
||||
id: newNode.id,
|
||||
type: newNode.type as string,
|
||||
data: newNode.data as any,
|
||||
selected: false,
|
||||
isConnectable: true,
|
||||
zIndex: 0,
|
||||
dragging: false,
|
||||
selectable: true,
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
};
|
||||
|
||||
renderWithProviders(createElement(uiElement as React.ComponentType<any>, props));
|
||||
const lengthAfter = screen.getAllByText(/.*/).length;
|
||||
|
||||
expect(lengthBefore + 1 === lengthAfter);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
describe('Connecting', () => {
|
||||
test.each(getAllTypes())('it should call the connect function when %s node is connected', (nodeType) => {
|
||||
// Create two nodes - one of the current type and one to connect to
|
||||
const sourceNode = createNode('source-1', nodeType, {x: 100, y: 100}, {});
|
||||
const targetNode = createNode('target-1', 'end', {x: 300, y: 100}, {});
|
||||
|
||||
// Add nodes to store
|
||||
useFlowStore.setState({ nodes: [sourceNode, targetNode] });
|
||||
|
||||
// Spy on the connect functions
|
||||
const sourceConnectSpy = jest.spyOn(NodeConnections.Sources, nodeType as keyof typeof NodeConnections.Sources);
|
||||
const targetConnectSpy = jest.spyOn(NodeConnections.Targets, 'end');
|
||||
|
||||
// Simulate connection
|
||||
useFlowStore.getState().onConnect({
|
||||
source: 'source-1',
|
||||
target: 'target-1',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
});
|
||||
|
||||
// Verify the connect functions were called
|
||||
expect(sourceConnectSpy).toHaveBeenCalledWith(sourceNode, targetNode.id);
|
||||
expect(targetConnectSpy).toHaveBeenCalledWith(targetNode, sourceNode.id);
|
||||
|
||||
sourceConnectSpy.mockRestore();
|
||||
targetConnectSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reducing', () => {
|
||||
test.each(getAllTypes())('it should correctly call/ not call the reduce function when %s node is in a phase', (nodeType) => {
|
||||
// Create a phase node and a node of the current type
|
||||
const phaseNode = createNode('phase-1', 'phase', {x: 200, y: 100}, { label: 'Test Phase', children: [] });
|
||||
const testNode = createNode('node-1', nodeType, {x: 100, y: 100}, {});
|
||||
|
||||
// Add the test node as a child of the phase
|
||||
(phaseNode.data as any).children.push(testNode.id);
|
||||
|
||||
// Add nodes to store
|
||||
useFlowStore.setState({ nodes: [phaseNode, testNode] });
|
||||
|
||||
// Spy on the reduce functions
|
||||
const phaseReduceSpy = jest.spyOn(NodeReduces, 'phase');
|
||||
const nodeReduceSpy = jest.spyOn(NodeReduces, nodeType as keyof typeof NodeReduces);
|
||||
|
||||
// Simulate reducing - using the graphReducer
|
||||
const result = graphReducer();
|
||||
|
||||
// Verify the reduce functions were called
|
||||
expect(phaseReduceSpy).toHaveBeenCalledWith(phaseNode, [phaseNode, testNode]);
|
||||
// Check if this node type is in NodesInPhase and returns false
|
||||
const nodesInPhaseFunc = NodesInPhase[nodeType as keyof typeof NodesInPhase];
|
||||
if (nodesInPhaseFunc && !nodesInPhaseFunc() && nodeType !== 'phase') {
|
||||
// Node is NOT in phase, so it should NOT be called
|
||||
expect(nodeReduceSpy).not.toHaveBeenCalled();
|
||||
} else {
|
||||
// Node IS in phase, so it SHOULD be called
|
||||
expect(nodeReduceSpy).toHaveBeenCalled();
|
||||
}
|
||||
|
||||
// Verify the correct structure is present using NodesInPhase
|
||||
expect(result).toHaveLength(nodeType !== 'phase' ? 1 : 2);
|
||||
expect(result[0]).toHaveProperty('id', 'phase-1');
|
||||
expect(result[0]).toHaveProperty('label', 'Test Phase');
|
||||
|
||||
// Restore mocks
|
||||
phaseReduceSpy.mockRestore();
|
||||
nodeReduceSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,9 @@ beforeAll(() => {
|
||||
useFlowStore.setState({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
past: [],
|
||||
future: [],
|
||||
isBatchAction: false,
|
||||
edgeReconnectSuccessful: true
|
||||
});
|
||||
});
|
||||
@@ -78,6 +81,9 @@ afterEach(() => {
|
||||
useFlowStore.setState({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
past: [],
|
||||
future: [],
|
||||
isBatchAction: false,
|
||||
edgeReconnectSuccessful: true
|
||||
});
|
||||
});
|
||||
|
||||
41
test/test-utils/mocks.ts
Normal file
41
test/test-utils/mocks.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
/**
|
||||
* Mock for @xyflow/react
|
||||
* Provides simplified versions of React Flow hooks and components
|
||||
*/
|
||||
jest.mock('@xyflow/react', () => ({
|
||||
useReactFlow: jest.fn(() => ({
|
||||
screenToFlowPosition: jest.fn((pos: any) => pos),
|
||||
getNode: jest.fn(),
|
||||
getNodes: jest.fn(() => []),
|
||||
getEdges: jest.fn(() => []),
|
||||
setNodes: jest.fn(),
|
||||
setEdges: jest.fn(),
|
||||
})),
|
||||
ReactFlowProvider: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', { 'data-testid': 'react-flow-provider' }, children),
|
||||
ReactFlow: ({ children, ...props }: any) =>
|
||||
React.createElement('div', { 'data-testid': 'react-flow', ...props }, children),
|
||||
Handle: ({ type, position, id }: any) =>
|
||||
React.createElement('div', { 'data-testid': `handle-${type}-${id}`, 'data-position': position }),
|
||||
Panel: ({ children, position }: any) =>
|
||||
React.createElement('div', { 'data-testid': 'panel', 'data-position': position }, children),
|
||||
Controls: () => React.createElement('div', { 'data-testid': 'controls' }),
|
||||
Background: () => React.createElement('div', { 'data-testid': 'background' }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Mock for @neodrag/react
|
||||
* Simplifies drag behavior for testing
|
||||
*/
|
||||
jest.mock('@neodrag/react', () => ({
|
||||
useDraggable: jest.fn((ref: any, options?: any) => {
|
||||
// Store the options so we can trigger them in tests
|
||||
if (ref && ref.current) {
|
||||
(ref.current as any)._dragOptions = options;
|
||||
}
|
||||
}),
|
||||
}));
|
||||
24
test/test-utils/test-utils.tsx
Normal file
24
test/test-utils/test-utils.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
// __tests__/utils/test-utils.tsx
|
||||
import { render, type RenderOptions } from '@testing-library/react';
|
||||
import { type ReactElement, type ReactNode } from 'react';
|
||||
import { ReactFlowProvider } from '@xyflow/react';
|
||||
|
||||
/**
|
||||
* Custom render function that wraps components with necessary providers
|
||||
* This ensures all components have access to ReactFlow context
|
||||
*/
|
||||
export function renderWithProviders(
|
||||
ui: ReactElement,
|
||||
options?: Omit<RenderOptions, 'wrapper'>
|
||||
) {
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <ReactFlowProvider>{children}</ReactFlowProvider>;
|
||||
}
|
||||
|
||||
return render(ui, { wrapper: Wrapper, ...options });
|
||||
}
|
||||
|
||||
|
||||
// Re-export everything from testing library
|
||||
//eslint-disable-next-line react-refresh/only-export-components
|
||||
export * from '@testing-library/react';
|
||||
Reference in New Issue
Block a user