Compare commits
203 Commits
build/dock
...
feat/edito
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a85dbeaca6 | ||
|
|
53568476d5 | ||
|
|
58bd57818e | ||
|
|
ee79660276 | ||
|
|
85b84c2281 | ||
|
|
f9e0eb95f8 | ||
|
|
47c5e94b8f | ||
|
|
820884f8aa | ||
|
|
b17d1e7618 | ||
|
|
641d794cf0 | ||
|
|
ec211ccbc3 | ||
|
|
7757a04694 | ||
|
|
2a6ead352d | ||
|
|
274ffb0238 | ||
|
|
a00fd02634 | ||
|
|
f6b692e420 | ||
|
|
2cbd905f0b | ||
|
|
84d9cbb19d | ||
|
|
e5b438c17e | ||
|
|
64dcdc49b3 | ||
|
|
9c64455a19 | ||
|
|
9f359de953 | ||
|
|
9d2f5127c1 | ||
|
|
bb053fda21 | ||
|
|
f92467b409 | ||
|
|
c9c7f55aa0 | ||
|
|
d6d74d4c6b | ||
|
|
e86c06c3e5 | ||
|
|
363054afda | ||
|
|
327d1de621 | ||
|
|
3f6d95683d | ||
|
|
6f4471ce6f | ||
|
|
5d55ebaaa2 | ||
|
|
23a02b2b4a | ||
|
|
487ee30923 | ||
|
|
8c28dd6c1c | ||
|
|
5a9b78fdda | ||
|
|
a6f24b677f | ||
|
|
022a6708ea | ||
|
|
f62f416af3 | ||
|
|
385ec250cc | ||
|
|
35bf3ad9e5 | ||
|
|
66daafe1f0 | ||
|
|
5d650b36ce | ||
|
|
e9acab456e | ||
|
|
1a8670ba13 | ||
|
|
f174623a4c | ||
|
|
b3b77b94ad | ||
|
|
67558a7ac7 | ||
|
|
e1257bdf48 | ||
|
|
3f7e196bb7 | ||
|
|
566c4c18cc | ||
|
|
8ffc919e7e | ||
|
|
a5a345b9a9 | ||
|
|
96afba2a1d | ||
|
|
c7ed3c8ef2 | ||
|
|
e6f29a0f6b | ||
|
|
a4428c0d67 | ||
| 5385bd72b1 | |||
|
|
e805c882fe | ||
|
|
35ab95bd35 | ||
|
|
ad8111d6c2 | ||
|
|
4e07b95722 | ||
|
|
442df423d1 | ||
|
|
bd079a4121 | ||
|
|
9e7c192804 | ||
|
|
d2d4dc1242 | ||
|
|
e6b0d7564d | ||
|
|
6d1c17e77b | ||
|
|
4e9a048c90 | ||
|
|
c13fb7d33d | ||
|
|
0ad2d5935f | ||
|
|
9b3414ba98 | ||
|
|
381cb0c822 | ||
|
|
0b74763e24 | ||
|
|
08374ac2c2 | ||
|
|
46c2e0ede6 | ||
|
|
9c80391fea | ||
|
|
f4745c736f | ||
|
|
508fa48be6 | ||
|
|
9dae45e398 | ||
|
|
bd93b04bfd | ||
|
|
216b136a75 | ||
|
|
111400bd82 | ||
|
|
01d73b777a | ||
|
|
9f26edb6ec | ||
|
|
8f1367ed83 | ||
|
|
bd2ffe622f | ||
|
|
b4df868e26 | ||
|
|
149b82cb66 | ||
|
|
c5f44536b7 | ||
|
|
444e8b0289 | ||
|
|
c1ef924be1 | ||
|
|
0b29cb5858 | ||
|
|
fcc279fb31 | ||
|
|
709dd28959 | ||
|
|
099afebe98 | ||
|
|
faaf67138d | ||
|
|
ed2e0ecb7b | ||
|
|
c25073f20d | ||
|
|
8d4c3fc64b | ||
|
|
7925023f25 | ||
|
|
2faa42bd4c | ||
|
|
ae8ef317a4 | ||
|
|
757435e9f8 | ||
|
|
f22fe38e22 | ||
|
|
9d4f10213e | ||
|
|
905b9da815 | ||
|
|
58ab95eee1 | ||
|
|
10d5a15c88 | ||
|
|
62c8118650 | ||
|
|
d5480f957b | ||
|
|
062e9e3f38 | ||
|
|
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 | ||
|
|
e5fee333fb | ||
|
|
aeaf526797 | ||
|
|
f534f0cefa | ||
|
|
231d7a5ba1 | ||
|
|
221fbe42c2 | ||
|
|
22da2ca664 | ||
|
|
be4fb0e7cd | ||
|
|
bb7d24b7be | ||
|
|
3cbf983b41 | ||
|
|
45e133e255 | ||
|
|
b7eb0cb5ec | ||
|
|
d4d1aecb8c | ||
|
|
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?
|
*.sw?
|
||||||
|
|
||||||
# Coverage report
|
# 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.
|
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
|
```bash
|
||||||
git config --local core.hooksPath .githooks
|
npm run prepare
|
||||||
```
|
```
|
||||||
|
|
||||||
If your commit fails its either:
|
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:
|
||||||
branch name != <type>/description-of-branch ,
|
|
||||||
commit name != <type>: description of the commit.
|
|
||||||
<ref>: N25B-Num's
|
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,23 +1,38 @@
|
|||||||
import js from '@eslint/js'
|
import js from "@eslint/js"
|
||||||
import globals from 'globals'
|
import globals from "globals"
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from "eslint-plugin-react-hooks"
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from "eslint-plugin-react-refresh"
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from "typescript-eslint"
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from "eslint/config"
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(["dist"]),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ["**/*.{ts,tsx}"],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
reactHooks.configs['recommended-latest'],
|
reactHooks.configs["recommended-latest"],
|
||||||
reactRefresh.configs.vite,
|
reactRefresh.configs.vite,
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["test/**/*.{ts,tsx}"],
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
793
package-lock.json
generated
793
package-lock.json
generated
@@ -10,9 +10,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neodrag/react": "^2.3.1",
|
"@neodrag/react": "^2.3.1",
|
||||||
"@xyflow/react": "^12.8.6",
|
"@xyflow/react": "^12.8.6",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router": "^7.9.3",
|
"react-router": "^7.9.3",
|
||||||
|
"reactflow": "^11.11.4",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -24,14 +26,17 @@
|
|||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.1.13",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@vitejs/plugin-react": "^5.0.3",
|
"@vitejs/plugin-react": "^5.0.3",
|
||||||
|
"baseline-browser-mapping": "^2.9.11",
|
||||||
"eslint": "^9.36.0",
|
"eslint": "^9.36.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.4.0",
|
"globals": "^16.4.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"ts-jest": "^29.4.5",
|
"ts-jest": "^29.4.5",
|
||||||
|
"typedoc": "^0.28.14",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.44.0",
|
"typescript-eslint": "^8.44.0",
|
||||||
"vite": "^7.1.7"
|
"vite": "^7.1.7"
|
||||||
@@ -1348,6 +1353,20 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@gerrit0/mini-shiki": {
|
||||||
|
"version": "3.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.15.0.tgz",
|
||||||
|
"integrity": "sha512-L5IHdZIDa4bG4yJaOzfasOH/o22MCesY0mx+n6VATbaiCtMeR59pdRqYk4bEiQkIHfxsHPNgdi7VJlZb2FhdMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@shikijs/engine-oniguruma": "^3.15.0",
|
||||||
|
"@shikijs/langs": "^3.15.0",
|
||||||
|
"@shikijs/themes": "^3.15.0",
|
||||||
|
"@shikijs/types": "^3.15.0",
|
||||||
|
"@shikijs/vscode-textmate": "^10.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -1460,9 +1479,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
||||||
"version": "3.14.1",
|
"version": "3.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2036,6 +2055,276 @@
|
|||||||
"url": "https://opencollective.com/pkgr"
|
"url": "https://opencollective.com/pkgr"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reactflow/background": {
|
||||||
|
"version": "11.3.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
|
||||||
|
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@reactflow/core": "11.11.4",
|
||||||
|
"classcat": "^5.0.3",
|
||||||
|
"zustand": "^4.4.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17",
|
||||||
|
"react-dom": ">=17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reactflow/background/node_modules/zustand": {
|
||||||
|
"version": "4.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=16.8",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=16.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reactflow/controls": {
|
||||||
|
"version": "11.2.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
|
||||||
|
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@reactflow/core": "11.11.4",
|
||||||
|
"classcat": "^5.0.3",
|
||||||
|
"zustand": "^4.4.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17",
|
||||||
|
"react-dom": ">=17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reactflow/controls/node_modules/zustand": {
|
||||||
|
"version": "4.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=16.8",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=16.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reactflow/core": {
|
||||||
|
"version": "11.11.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
|
||||||
|
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3": "^7.4.0",
|
||||||
|
"@types/d3-drag": "^3.0.1",
|
||||||
|
"@types/d3-selection": "^3.0.3",
|
||||||
|
"@types/d3-zoom": "^3.0.1",
|
||||||
|
"classcat": "^5.0.3",
|
||||||
|
"d3-drag": "^3.0.0",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"d3-zoom": "^3.0.0",
|
||||||
|
"zustand": "^4.4.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17",
|
||||||
|
"react-dom": ">=17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reactflow/core/node_modules/zustand": {
|
||||||
|
"version": "4.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=16.8",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=16.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reactflow/minimap": {
|
||||||
|
"version": "11.7.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
|
||||||
|
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@reactflow/core": "11.11.4",
|
||||||
|
"@types/d3-selection": "^3.0.3",
|
||||||
|
"@types/d3-zoom": "^3.0.1",
|
||||||
|
"classcat": "^5.0.3",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"d3-zoom": "^3.0.0",
|
||||||
|
"zustand": "^4.4.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17",
|
||||||
|
"react-dom": ">=17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reactflow/minimap/node_modules/zustand": {
|
||||||
|
"version": "4.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=16.8",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=16.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reactflow/node-resizer": {
|
||||||
|
"version": "2.2.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
|
||||||
|
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@reactflow/core": "11.11.4",
|
||||||
|
"classcat": "^5.0.4",
|
||||||
|
"d3-drag": "^3.0.0",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"zustand": "^4.4.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17",
|
||||||
|
"react-dom": ">=17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reactflow/node-resizer/node_modules/zustand": {
|
||||||
|
"version": "4.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=16.8",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=16.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reactflow/node-toolbar": {
|
||||||
|
"version": "1.3.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
|
||||||
|
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@reactflow/core": "11.11.4",
|
||||||
|
"classcat": "^5.0.3",
|
||||||
|
"zustand": "^4.4.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17",
|
||||||
|
"react-dom": ">=17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reactflow/node-toolbar/node_modules/zustand": {
|
||||||
|
"version": "4.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=16.8",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=16.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.35",
|
"version": "1.0.0-beta.35",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz",
|
||||||
@@ -2351,6 +2640,55 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@shikijs/engine-oniguruma": {
|
||||||
|
"version": "3.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.15.0.tgz",
|
||||||
|
"integrity": "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@shikijs/types": "3.15.0",
|
||||||
|
"@shikijs/vscode-textmate": "^10.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@shikijs/langs": {
|
||||||
|
"version": "3.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.15.0.tgz",
|
||||||
|
"integrity": "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@shikijs/types": "3.15.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@shikijs/themes": {
|
||||||
|
"version": "3.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.15.0.tgz",
|
||||||
|
"integrity": "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@shikijs/types": "3.15.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@shikijs/types": {
|
||||||
|
"version": "3.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.15.0.tgz",
|
||||||
|
"integrity": "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@shikijs/vscode-textmate": "^10.0.2",
|
||||||
|
"@types/hast": "^3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@shikijs/vscode-textmate": {
|
||||||
|
"version": "10.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
|
||||||
|
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@sinclair/typebox": {
|
"node_modules/@sinclair/typebox": {
|
||||||
"version": "0.34.41",
|
"version": "0.34.41",
|
||||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
|
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
|
||||||
@@ -2581,12 +2919,102 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3": {
|
||||||
|
"version": "7.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||||
|
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "*",
|
||||||
|
"@types/d3-axis": "*",
|
||||||
|
"@types/d3-brush": "*",
|
||||||
|
"@types/d3-chord": "*",
|
||||||
|
"@types/d3-color": "*",
|
||||||
|
"@types/d3-contour": "*",
|
||||||
|
"@types/d3-delaunay": "*",
|
||||||
|
"@types/d3-dispatch": "*",
|
||||||
|
"@types/d3-drag": "*",
|
||||||
|
"@types/d3-dsv": "*",
|
||||||
|
"@types/d3-ease": "*",
|
||||||
|
"@types/d3-fetch": "*",
|
||||||
|
"@types/d3-force": "*",
|
||||||
|
"@types/d3-format": "*",
|
||||||
|
"@types/d3-geo": "*",
|
||||||
|
"@types/d3-hierarchy": "*",
|
||||||
|
"@types/d3-interpolate": "*",
|
||||||
|
"@types/d3-path": "*",
|
||||||
|
"@types/d3-polygon": "*",
|
||||||
|
"@types/d3-quadtree": "*",
|
||||||
|
"@types/d3-random": "*",
|
||||||
|
"@types/d3-scale": "*",
|
||||||
|
"@types/d3-scale-chromatic": "*",
|
||||||
|
"@types/d3-selection": "*",
|
||||||
|
"@types/d3-shape": "*",
|
||||||
|
"@types/d3-time": "*",
|
||||||
|
"@types/d3-time-format": "*",
|
||||||
|
"@types/d3-timer": "*",
|
||||||
|
"@types/d3-transition": "*",
|
||||||
|
"@types/d3-zoom": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-axis": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-brush": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-chord": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-color": {
|
"node_modules/@types/d3-color": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-contour": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "*",
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-delaunay": {
|
||||||
|
"version": "6.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||||
|
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-dispatch": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-drag": {
|
"node_modules/@types/d3-drag": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||||
@@ -2596,6 +3024,54 @@
|
|||||||
"@types/d3-selection": "*"
|
"@types/d3-selection": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-dsv": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-fetch": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-dsv": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-force": {
|
||||||
|
"version": "3.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||||
|
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-format": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-geo": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-hierarchy": {
|
||||||
|
"version": "3.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
|
||||||
|
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-interpolate": {
|
"node_modules/@types/d3-interpolate": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
@@ -2605,12 +3081,78 @@
|
|||||||
"@types/d3-color": "*"
|
"@types/d3-color": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-polygon": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-quadtree": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-random": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale-chromatic": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-selection": {
|
"node_modules/@types/d3-selection": {
|
||||||
"version": "3.0.11",
|
"version": "3.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time-format": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-transition": {
|
"node_modules/@types/d3-transition": {
|
||||||
"version": "3.0.9",
|
"version": "3.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||||
@@ -2637,6 +3179,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/hast": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/unist": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/istanbul-lib-coverage": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||||
@@ -2738,6 +3296,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/unist": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.33",
|
"version": "17.0.33",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
||||||
@@ -3324,12 +3889,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xyflow/react": {
|
"node_modules/@xyflow/react": {
|
||||||
"version": "12.8.6",
|
"version": "12.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz",
|
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.1.tgz",
|
||||||
"integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==",
|
"integrity": "sha512-JRPCT5p7NnPdVSIh15AFvUSSm+8GUyz2I6iuBEC1LG2lKgig/L48AM/ImMHCc3ZUCg+AgTOJDaX2fcRyPA9BTA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xyflow/system": "0.0.70",
|
"@xyflow/system": "0.0.72",
|
||||||
"classcat": "^5.0.3",
|
"classcat": "^5.0.3",
|
||||||
"zustand": "^4.4.0"
|
"zustand": "^4.4.0"
|
||||||
},
|
},
|
||||||
@@ -3367,9 +3932,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xyflow/system": {
|
"node_modules/@xyflow/system": {
|
||||||
"version": "0.0.70",
|
"version": "0.0.72",
|
||||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz",
|
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz",
|
||||||
"integrity": "sha512-PpC//u9zxdjj0tfTSmZrg3+sRbTz6kop/Amky44U2Dl51sxzDTIUfXMwETOYpmr2dqICWXBIJwXL2a9QWtX2XA==",
|
"integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-drag": "^3.0.7",
|
"@types/d3-drag": "^3.0.7",
|
||||||
@@ -3616,9 +4181,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.8.6",
|
"version": "2.9.11",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
||||||
"integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==",
|
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -3888,6 +4453,15 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/co": {
|
"node_modules/co": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||||
@@ -4787,9 +5361,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "10.4.5",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4970,6 +5544,22 @@
|
|||||||
"node": ">=10.17.0"
|
"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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
@@ -5908,9 +6498,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6055,6 +6645,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uc.micro": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -6095,6 +6695,13 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lunr": {
|
||||||
|
"version": "2.3.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
|
||||||
|
"integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
@@ -6152,6 +6759,44 @@
|
|||||||
"tmpl": "1.0.5"
|
"tmpl": "1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/markdown-it": {
|
||||||
|
"version": "14.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||||
|
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^2.0.1",
|
||||||
|
"entities": "^4.4.0",
|
||||||
|
"linkify-it": "^5.0.0",
|
||||||
|
"mdurl": "^2.0.0",
|
||||||
|
"punycode.js": "^2.3.1",
|
||||||
|
"uc.micro": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"markdown-it": "bin/markdown-it.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/markdown-it/node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/merge-stream": {
|
"node_modules/merge-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
@@ -6704,6 +7349,16 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/punycode.js": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pure-rand": {
|
"node_modules/pure-rand": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
|
||||||
@@ -6781,9 +7436,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.9.3",
|
"version": "7.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
|
||||||
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
|
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -6802,6 +7457,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reactflow": {
|
||||||
|
"version": "11.11.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
|
||||||
|
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@reactflow/background": "11.3.14",
|
||||||
|
"@reactflow/controls": "11.2.14",
|
||||||
|
"@reactflow/core": "11.11.4",
|
||||||
|
"@reactflow/minimap": "11.7.14",
|
||||||
|
"@reactflow/node-resizer": "2.2.14",
|
||||||
|
"@reactflow/node-toolbar": "1.3.14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17",
|
||||||
|
"react-dom": ">=17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redent": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -7602,6 +8275,56 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typedoc": {
|
||||||
|
"version": "0.28.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.14.tgz",
|
||||||
|
"integrity": "sha512-ftJYPvpVfQvFzpkoSfHLkJybdA/geDJ8BGQt/ZnkkhnBYoYW6lBgPQXu6vqLxO4X75dA55hX8Af847H5KXlEFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@gerrit0/mini-shiki": "^3.12.0",
|
||||||
|
"lunr": "^2.3.9",
|
||||||
|
"markdown-it": "^14.1.0",
|
||||||
|
"minimatch": "^9.0.5",
|
||||||
|
"yaml": "^2.8.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"typedoc": "bin/typedoc"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18",
|
||||||
|
"pnpm": ">= 10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typedoc/node_modules/brace-expansion": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typedoc/node_modules/minimatch": {
|
||||||
|
"version": "9.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||||
|
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.8.3",
|
"version": "5.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
@@ -7640,6 +8363,13 @@
|
|||||||
"typescript": ">=4.8.4 <6.0.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uc.micro": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/uglify-js": {
|
"node_modules/uglify-js": {
|
||||||
"version": "3.19.3",
|
"version": "3.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||||
@@ -7738,9 +8468,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.5.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
@@ -8142,6 +8872,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -6,15 +6,19 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint src test",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "jest",
|
||||||
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neodrag/react": "^2.3.1",
|
"@neodrag/react": "^2.3.1",
|
||||||
"@xyflow/react": "^12.8.6",
|
"@xyflow/react": "^12.8.6",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router": "^7.9.3",
|
"react-router": "^7.9.3",
|
||||||
|
"reactflow": "^11.11.4",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -26,14 +30,17 @@
|
|||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.1.13",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@vitejs/plugin-react": "^5.0.3",
|
"@vitejs/plugin-react": "^5.0.3",
|
||||||
|
"baseline-browser-mapping": "^2.9.11",
|
||||||
"eslint": "^9.36.0",
|
"eslint": "^9.36.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.4.0",
|
"globals": "^16.4.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"ts-jest": "^29.4.5",
|
"ts-jest": "^29.4.5",
|
||||||
|
"typedoc": "^0.28.14",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.44.0",
|
"typescript-eslint": "^8.44.0",
|
||||||
"vite": "^7.1.7"
|
"vite": "^7.1.7"
|
||||||
|
|||||||
96
src/App.css
96
src/App.css
@@ -82,6 +82,10 @@ button.movePage:hover{
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -96,6 +100,7 @@ header {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
background-color: var(--accent-color);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
z-index: 1; /* Otherwise any translated elements render above the blur?? */
|
z-index: 1; /* Otherwise any translated elements render above the blur?? */
|
||||||
}
|
}
|
||||||
@@ -104,6 +109,10 @@ main {
|
|||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.flex-row {
|
.flex-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -121,6 +130,14 @@ main {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.min-height-0 {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-y {
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
.align-center {
|
.align-center {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -141,6 +158,10 @@ main {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.margin-0 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.padding-sm {
|
.padding-sm {
|
||||||
padding: .25rem;
|
padding: .25rem;
|
||||||
}
|
}
|
||||||
@@ -150,7 +171,19 @@ main {
|
|||||||
.padding-lg {
|
.padding-lg {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
.padding-b-sm {
|
||||||
|
padding-bottom: .25rem;
|
||||||
|
}
|
||||||
|
.padding-b-md {
|
||||||
|
padding-bottom: .5rem;
|
||||||
|
}
|
||||||
|
.padding-b-lg {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-sm, .round-md, .round-lg {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
.round-sm {
|
.round-sm {
|
||||||
border-radius: .25rem;
|
border-radius: .25rem;
|
||||||
}
|
}
|
||||||
@@ -159,4 +192,67 @@ main {
|
|||||||
}
|
}
|
||||||
.round-lg {
|
.round-lg {
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-sm {
|
||||||
|
border: 1px solid canvastext;
|
||||||
|
}
|
||||||
|
.border-md {
|
||||||
|
border: 2px solid canvastext;
|
||||||
|
}
|
||||||
|
.border-lg {
|
||||||
|
border: 3px solid canvastext;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-small {
|
||||||
|
font-size: .75rem;
|
||||||
|
}
|
||||||
|
.font-medium {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.font-large {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.user-select-all {
|
||||||
|
-webkit-user-select: all;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
.user-select-none {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
button.no-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-center-x {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center; /* horizontal centering */
|
||||||
|
text-align: center; /* center multi-line text */
|
||||||
|
width: 100%; /* allow it to stretch */
|
||||||
|
flex-wrap: wrap; /* optional: let text wrap naturally */
|
||||||
}
|
}
|
||||||
30
src/App.tsx
30
src/App.tsx
@@ -3,24 +3,34 @@ import './App.css'
|
|||||||
import TemplatePage from './pages/TemplatePage/Template.tsx'
|
import TemplatePage from './pages/TemplatePage/Template.tsx'
|
||||||
import Home from './pages/Home/Home.tsx'
|
import Home from './pages/Home/Home.tsx'
|
||||||
import Robot from './pages/Robot/Robot.tsx';
|
import Robot from './pages/Robot/Robot.tsx';
|
||||||
|
import ConnectedRobots from './pages/ConnectedRobots/ConnectedRobots.tsx'
|
||||||
import VisProg from "./pages/VisProgPage/VisProg.tsx";
|
import VisProg from "./pages/VisProgPage/VisProg.tsx";
|
||||||
|
import {useState} from "react";
|
||||||
|
import Logging from "./components/Logging/Logging.tsx";
|
||||||
|
|
||||||
function App(){
|
function App(){
|
||||||
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<header>
|
<header>
|
||||||
<Link to={"/"}>Home</Link>
|
<Link to={"/"}>Home</Link>
|
||||||
|
<button onClick={() => setShowLogs(!showLogs)}>Toggle Logging</button>
|
||||||
</header>
|
</header>
|
||||||
<main className={"flex-col align-center"}>
|
<div className={"flex-row justify-center flex-1 min-height-0"}>
|
||||||
<Routes>
|
<main className={"flex-col align-center flex-1 scroll-y"}>
|
||||||
<Route path="/" element={<Home />} />
|
<Routes>
|
||||||
<Route path="/template" element={<TemplatePage />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/editor" element={<VisProg />} />
|
<Route path="/template" element={<TemplatePage />} />
|
||||||
<Route path="/robot" element={<Robot />} />
|
<Route path="/editor" element={<VisProg />} />
|
||||||
|
<Route path="/robot" element={<Robot />} />
|
||||||
|
<Route path="/ConnectedRobots" element={<ConnectedRobots />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
{showLogs && <Logging />}
|
||||||
)
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
34
src/components/Logging/Filters.module.css
Normal file
34
src/components/Logging/Filters.module.css
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
.filter-root {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .25rem;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: canvas;
|
||||||
|
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
|
||||||
|
width: 300px;
|
||||||
|
|
||||||
|
*:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
*:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button.deletable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
263
src/components/Logging/Filters.tsx
Normal file
263
src/components/Logging/Filters.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
|
||||||
|
import type {LogFilterPredicate} from "./useLogs.ts";
|
||||||
|
|
||||||
|
import styles from "./Filters.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A generic setter type compatible with React's state setters.
|
||||||
|
*/
|
||||||
|
type Setter<T> = (value: T | ((prev: T) => T)) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping of log level names to their corresponding numeric severity.
|
||||||
|
* Used for comparison in log filtering predicates.
|
||||||
|
*/
|
||||||
|
const optionMapping = new Map([
|
||||||
|
["ALL", 0],
|
||||||
|
["LLM", 9],
|
||||||
|
["DEBUG", 10],
|
||||||
|
["INFO", 20],
|
||||||
|
["WARNING", 30],
|
||||||
|
["ERROR", 40],
|
||||||
|
["CRITICAL", 50],
|
||||||
|
["NONE", 999_999_999_999], // It is technically possible to have a higher level, but this is fine
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a single log-level selector (dropdown) for a specific filter target.
|
||||||
|
*
|
||||||
|
* Used by both the global filter and agent-specific filters.
|
||||||
|
*
|
||||||
|
* @param name - The display name or identifier for the filter target.
|
||||||
|
* @param level - The currently selected log level.
|
||||||
|
* @param setLevel - Function to update the selected log level.
|
||||||
|
* @param onDelete - Optional callback for deleting this filter element.
|
||||||
|
* @returns A JSX element that renders a labeled dropdown for selecting log levels.
|
||||||
|
*/
|
||||||
|
function LevelPredicateElement({
|
||||||
|
name,
|
||||||
|
level,
|
||||||
|
setLevel,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
level: string;
|
||||||
|
setLevel: (level: string) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}) {
|
||||||
|
const normalizedName = name.split(".").pop() || name;
|
||||||
|
|
||||||
|
return <div className={"flex-row gap-sm align-center"}>
|
||||||
|
<label
|
||||||
|
htmlFor={`log_level_${name}`}
|
||||||
|
className={"font-small"}
|
||||||
|
>
|
||||||
|
{onDelete
|
||||||
|
? <button
|
||||||
|
className={`no-button ${styles.deletable}`}
|
||||||
|
onClick={onDelete}
|
||||||
|
>{normalizedName}:</button>
|
||||||
|
: normalizedName + ':'
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`log_level_${name}`}
|
||||||
|
value={level}
|
||||||
|
onChange={(e) => setLevel(e.target.value)}
|
||||||
|
>
|
||||||
|
{Array.from(optionMapping.keys()).map((key) => (
|
||||||
|
<option key={key} value={key}>{key}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Key used for the global log-level predicate in the filter map. */
|
||||||
|
const GLOBAL_LOG_LEVEL_PREDICATE_KEY = "global_log_level";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders and manages the **global log-level filter**.
|
||||||
|
*
|
||||||
|
* This component defines a baseline log level that all logs must meet or exceed
|
||||||
|
* to be displayed, unless overridden by per-agent filters.
|
||||||
|
*
|
||||||
|
* @param filterPredicates - Map of current log filter predicates.
|
||||||
|
* @param setFilterPredicates - Setter function to update the filter predicates map.
|
||||||
|
* @returns A JSX element rendering the global log-level selector.
|
||||||
|
*/
|
||||||
|
function GlobalLevelFilter({
|
||||||
|
filterPredicates,
|
||||||
|
setFilterPredicates,
|
||||||
|
}: {
|
||||||
|
filterPredicates: Map<string, LogFilterPredicate>;
|
||||||
|
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
|
||||||
|
}) {
|
||||||
|
const selected = filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value || "ALL";
|
||||||
|
const setSelected = (selected: string | null) => {
|
||||||
|
if (!selected || !optionMapping.has(selected)) return;
|
||||||
|
|
||||||
|
setFilterPredicates((curr) => {
|
||||||
|
const next = new Map(curr);
|
||||||
|
next.set(GLOBAL_LOG_LEVEL_PREDICATE_KEY, {
|
||||||
|
predicate: (record) => record.levelno >= optionMapping.get(selected)!,
|
||||||
|
priority: 0,
|
||||||
|
value: selected,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize default global level on mount.
|
||||||
|
useEffect(() => {
|
||||||
|
if (filterPredicates.has(GLOBAL_LOG_LEVEL_PREDICATE_KEY)) return;
|
||||||
|
setSelected("INFO");
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // Run only once when the component mounts, not when anything changes
|
||||||
|
|
||||||
|
return <LevelPredicateElement
|
||||||
|
name={"Global"}
|
||||||
|
level={selected}
|
||||||
|
setLevel={setSelected}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefix for agent-specific log-level predicate keys in the filter map. */
|
||||||
|
const AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX = "agent_log_level_";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders and manages **per-agent log-level filters**.
|
||||||
|
*
|
||||||
|
* Allows the user to set specific log levels for individual agents, overriding
|
||||||
|
* the global filter for those agents. Includes functionality to add, edit,
|
||||||
|
* or remove agent-level filters.
|
||||||
|
*
|
||||||
|
* @param filterPredicates - Map of current log filter predicates.
|
||||||
|
* @param setFilterPredicates - Setter function to update the filter predicates map.
|
||||||
|
* @param agentNames - Set of agent names available for filtering.
|
||||||
|
* @returns A JSX element rendering agent-level filters and a dropdown to add new ones.
|
||||||
|
*/
|
||||||
|
function AgentLevelFilters({
|
||||||
|
filterPredicates,
|
||||||
|
setFilterPredicates,
|
||||||
|
agentNames,
|
||||||
|
}: {
|
||||||
|
filterPredicates: Map<string, LogFilterPredicate>;
|
||||||
|
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
|
||||||
|
agentNames: Set<string>;
|
||||||
|
}) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// Close dropdown or panels when clicking outside or pressing Escape.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDocClick = (e: MouseEvent) => {
|
||||||
|
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
setOpen(false);
|
||||||
|
e.preventDefault(); // Don't exit fullscreen mode
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDocClick);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDocClick);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Identify which predicates correspond to agents.
|
||||||
|
const agentPredicates = [...filterPredicates.keys()].filter((key) =>
|
||||||
|
key.startsWith(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates or updates the log filter predicate for a specific agent.
|
||||||
|
* Falls back to the global log level if no level is specified.
|
||||||
|
*
|
||||||
|
* @param agentName - The name of the agent to filter.
|
||||||
|
* @param level - Optional log level to apply; defaults to the global level.
|
||||||
|
*/
|
||||||
|
const setAgentPredicate = (agentName: string, level?: string ) => {
|
||||||
|
level = level ?? filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value ?? "ALL";
|
||||||
|
setFilterPredicates((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName, {
|
||||||
|
predicate: (record) => record.name === agentName
|
||||||
|
? record.levelno >= optionMapping.get(level!)!
|
||||||
|
: null,
|
||||||
|
priority: 1,
|
||||||
|
value: {agentName, level},
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the log filter predicate for a specific agent.
|
||||||
|
*
|
||||||
|
* @param agentName - The name of the agent whose filter should be removed.
|
||||||
|
*/
|
||||||
|
const deleteAgentPredicate = (agentName: string) => {
|
||||||
|
setFilterPredicates((curr) => {
|
||||||
|
const fullName = AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName;
|
||||||
|
if (!curr.has(fullName)) return curr; // Return unchanged, no re-render
|
||||||
|
const next = new Map(curr);
|
||||||
|
next.delete(fullName);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{agentPredicates.map((key) => {
|
||||||
|
const {agentName, level} = filterPredicates.get(key)!.value;
|
||||||
|
|
||||||
|
return <LevelPredicateElement
|
||||||
|
key={key}
|
||||||
|
name={agentName}
|
||||||
|
level={level}
|
||||||
|
setLevel={(level) => setAgentPredicate(agentName, level)}
|
||||||
|
onDelete={() => deleteAgentPredicate(agentName)}
|
||||||
|
/>;
|
||||||
|
})}
|
||||||
|
<div className={"flex-row gap-sm align-center"}>
|
||||||
|
<label htmlFor={"add_agent"} className={"font-small"}>Add:</label>
|
||||||
|
<select
|
||||||
|
id={"add_agent"}
|
||||||
|
value={""}
|
||||||
|
onChange={(e) => !!e.target.value && setAgentPredicate(e.target.value)}
|
||||||
|
>
|
||||||
|
{["", ...agentNames.keys()].map((key) => (
|
||||||
|
<option key={key} value={key}>{key.split(".").pop()}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Filters component that aggregates global and per-agent log filters.
|
||||||
|
*
|
||||||
|
* Combines the global log-level filter and agent-specific filters into a unified UI.
|
||||||
|
* Updates a shared `Map<string, LogFilterPredicate>` to determine which logs are shown.
|
||||||
|
*
|
||||||
|
* @param filterPredicates - The map of all active log filter predicates.
|
||||||
|
* @param setFilterPredicates - Setter to update the map of predicates.
|
||||||
|
* @param agentNames - Set of available agent names to display filters for.
|
||||||
|
* @returns A React component that renders all log filter controls.
|
||||||
|
*/
|
||||||
|
export default function Filters({
|
||||||
|
filterPredicates,
|
||||||
|
setFilterPredicates,
|
||||||
|
agentNames,
|
||||||
|
}: {
|
||||||
|
filterPredicates: Map<string, LogFilterPredicate>;
|
||||||
|
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
|
||||||
|
agentNames: Set<string>;
|
||||||
|
}) {
|
||||||
|
return <div className={"flex-1 flex-row flex-wrap gap-md align-center"}>
|
||||||
|
<GlobalLevelFilter filterPredicates={filterPredicates} setFilterPredicates={setFilterPredicates} />
|
||||||
|
<AgentLevelFilters filterPredicates={filterPredicates} setFilterPredicates={setFilterPredicates} agentNames={agentNames} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
39
src/components/Logging/Logging.module.css
Normal file
39
src/components/Logging/Logging.module.css
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
.logging-container {
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
width: max(30dvw, 500px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
box-shadow: 0 0 1rem black;
|
||||||
|
padding: 1rem 1rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-numbers {
|
||||||
|
list-style-type: none;
|
||||||
|
counter-reset: none;
|
||||||
|
padding-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container {
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
|
||||||
|
.accented-0, .accented-10 {
|
||||||
|
background-color: color-mix(in oklab, canvas, rgb(159, 159, 159) 35%)
|
||||||
|
}
|
||||||
|
.accented-20 {
|
||||||
|
background-color: color-mix(in oklab, canvas, green 35%)
|
||||||
|
}
|
||||||
|
.accented-30 {
|
||||||
|
background-color: color-mix(in oklab, canvas, yellow 35%)
|
||||||
|
}
|
||||||
|
.accented-40, .accented-50 {
|
||||||
|
background-color: color-mix(in oklab, canvas, red 35%)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-button {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
186
src/components/Logging/Logging.tsx
Normal file
186
src/components/Logging/Logging.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import {create} from "zustand";
|
||||||
|
|
||||||
|
import formatDuration from "../../utils/formatDuration.ts";
|
||||||
|
import {type LogFilterPredicate, type LogRecord, useLogs} from "./useLogs.ts";
|
||||||
|
import Filters from "./Filters.tsx";
|
||||||
|
import {type Cell, useCell} from "../../utils/cellStore.ts";
|
||||||
|
|
||||||
|
import styles from "./Logging.module.css";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustand store definition for managing user preferences related to logging.
|
||||||
|
*
|
||||||
|
* Includes flags for toggling relative timestamps and automatic scroll behavior.
|
||||||
|
*/
|
||||||
|
type LoggingSettings = {
|
||||||
|
/** Whether to display log timestamps as relative (e.g., "2m 15s ago") instead of absolute. */
|
||||||
|
showRelativeTime: boolean;
|
||||||
|
/** Updates the `showRelativeTime` setting. */
|
||||||
|
setShowRelativeTime: (showRelativeTime: boolean) => void;
|
||||||
|
/** Whether the log view should automatically scroll to the newest entry. */
|
||||||
|
scrollToBottom: boolean;
|
||||||
|
/** Updates the `scrollToBottom` setting. */
|
||||||
|
setScrollToBottom: (scrollToBottom: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global Zustand store for logging UI preferences.
|
||||||
|
*/
|
||||||
|
const useLoggingSettings = create<LoggingSettings>((set) => ({
|
||||||
|
showRelativeTime: false,
|
||||||
|
setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }),
|
||||||
|
scrollToBottom: true,
|
||||||
|
setScrollToBottom: (scrollToBottom: boolean) => set({ scrollToBottom }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a single log message entry with colored level indicators and timestamp formatting.
|
||||||
|
*
|
||||||
|
* This component automatically re-renders when the underlying log record (`recordCell`)
|
||||||
|
* changes. It also triggers the `onUpdate` callback whenever the record updates (e.g., for auto-scrolling).
|
||||||
|
*
|
||||||
|
* @param recordCell - A reactive `Cell` containing a single `LogRecord`.
|
||||||
|
* @param onUpdate - Optional callback triggered when the log entry updates.
|
||||||
|
* @returns A JSX element displaying a formatted log message.
|
||||||
|
*/
|
||||||
|
function LogMessage({
|
||||||
|
recordCell,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
recordCell: Cell<LogRecord>,
|
||||||
|
onUpdate?: () => void,
|
||||||
|
}) {
|
||||||
|
const { showRelativeTime, setShowRelativeTime } = useLoggingSettings();
|
||||||
|
const record = useCell(recordCell);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes the log level number to a multiple of 10,
|
||||||
|
* for which there are CSS styles. (e.g., INFO = 20, ERROR = 40).
|
||||||
|
*/
|
||||||
|
const normalizedLevelNo = (() => {
|
||||||
|
// By default, the highest level is 50 (CRITICAL). Custom levels can be higher, but we don't have more critical color.
|
||||||
|
if (record.levelno >= 50) return 50;
|
||||||
|
|
||||||
|
return Math.round(record.levelno / 10) * 10;
|
||||||
|
})();
|
||||||
|
|
||||||
|
/** Simplifies the logger name by showing only the last path segment. */
|
||||||
|
const normalizedName = record.name.split(".").pop() || record.name;
|
||||||
|
|
||||||
|
// Notify parent component (e.g. for scroll updates) when this record changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (onUpdate) onUpdate();
|
||||||
|
}, [record, onUpdate]);
|
||||||
|
|
||||||
|
return <div className={`${styles.logContainer} round-md border-lg flex-row gap-md`}>
|
||||||
|
<div className={`${styles[`accented${normalizedLevelNo}`]} flex-col padding-sm justify-between`}>
|
||||||
|
<span className={"mono bold"}>{record.levelname}</span>
|
||||||
|
<span className={"mono clickable font-small"}
|
||||||
|
onClick={() => setShowRelativeTime(!showRelativeTime)}
|
||||||
|
>{showRelativeTime
|
||||||
|
? formatDuration(record.relativeCreated)
|
||||||
|
: new Date(record.created * 1000).toLocaleTimeString()
|
||||||
|
}</span>
|
||||||
|
</div>
|
||||||
|
<div className={"flex-col flex-1 padding-sm"}>
|
||||||
|
<span className={"mono"}>{normalizedName}</span>
|
||||||
|
<span>{record.message}</span>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a scrollable list of log messages.
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - Auto-scrolling when new messages arrive.
|
||||||
|
* - Allowing users to scroll manually and disable auto-scroll.
|
||||||
|
* - A floating "Scroll to bottom" button when not at the bottom.
|
||||||
|
*
|
||||||
|
* @param recordCells - Array of reactive log records to display.
|
||||||
|
* @returns A scrollable log list component.
|
||||||
|
*/
|
||||||
|
function LogMessages({ recordCells }: { recordCells: Cell<LogRecord>[] }) {
|
||||||
|
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lastElementRef = useRef<HTMLLIElement>(null)
|
||||||
|
const { scrollToBottom, setScrollToBottom } = useLoggingSettings();
|
||||||
|
|
||||||
|
// Disable auto-scroll if the user manually scrolls.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollableRef.current) return;
|
||||||
|
const currentScrollableRef = scrollableRef.current;
|
||||||
|
|
||||||
|
const handleScroll = () => setScrollToBottom(false);
|
||||||
|
|
||||||
|
currentScrollableRef.addEventListener("wheel", handleScroll);
|
||||||
|
currentScrollableRef.addEventListener("touchmove", handleScroll);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
currentScrollableRef.removeEventListener("wheel", handleScroll);
|
||||||
|
currentScrollableRef.removeEventListener("touchmove", handleScroll);
|
||||||
|
}
|
||||||
|
}, [scrollableRef, setScrollToBottom]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls the last log message into view if auto-scroll is enabled,
|
||||||
|
* or if forced (e.g., user clicks "Scroll to bottom").
|
||||||
|
*
|
||||||
|
* @param force - If true, forces scrolling even if `scrollToBottom` is false.
|
||||||
|
*/
|
||||||
|
function scrollLastElementIntoView(force = false) {
|
||||||
|
if ((!scrollToBottom && !force) || !lastElementRef.current) return;
|
||||||
|
lastElementRef.current.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div ref={scrollableRef} className={"min-height-0 scroll-y padding-b-md"}>
|
||||||
|
<ol className={`${styles.noNumbers} margin-0 flex-col gap-md`}>
|
||||||
|
{recordCells.map((recordCell, i) => (
|
||||||
|
<li key={`${i}_${recordCell.get().firstRelativeCreated}`}>
|
||||||
|
<LogMessage recordCell={recordCell} onUpdate={scrollLastElementIntoView} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li ref={lastElementRef}></li>
|
||||||
|
</ol>
|
||||||
|
{!scrollToBottom && <button
|
||||||
|
className={styles.floatingButton}
|
||||||
|
onClick={() => {
|
||||||
|
setScrollToBottom(true);
|
||||||
|
scrollLastElementIntoView(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Scroll to bottom
|
||||||
|
</button>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level logging panel component.
|
||||||
|
*
|
||||||
|
* Combines:
|
||||||
|
* - The `Filters` component for adjusting log visibility.
|
||||||
|
* - The `LogMessages` component for displaying filtered logs.
|
||||||
|
* - Zustand-managed UI settings (auto-scroll, timestamp display).
|
||||||
|
*
|
||||||
|
* This component uses the `useLogs` hook to fetch and filter logs based on
|
||||||
|
* active predicates, and re-renders automatically as new logs arrive.
|
||||||
|
*
|
||||||
|
* @returns The complete logging UI as a React element.
|
||||||
|
*/
|
||||||
|
export default function Logging() {
|
||||||
|
const [filterPredicates, setFilterPredicates] = useState(new Map<string, LogFilterPredicate>());
|
||||||
|
const { filteredLogs, distinctNames } = useLogs(filterPredicates)
|
||||||
|
|
||||||
|
return <div className={`flex-col gap-lg min-height-0 ${styles.loggingContainer}`}>
|
||||||
|
<div className={"flex-row gap-lg justify-between align-center"}>
|
||||||
|
<h2 className={"margin-0"}>Logs</h2>
|
||||||
|
<Filters
|
||||||
|
filterPredicates={filterPredicates}
|
||||||
|
setFilterPredicates={setFilterPredicates}
|
||||||
|
agentNames={distinctNames}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<LogMessages recordCells={filteredLogs} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
212
src/components/Logging/useLogs.ts
Normal file
212
src/components/Logging/useLogs.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import {useCallback, useEffect, useRef, useState} from "react";
|
||||||
|
|
||||||
|
import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../utils/priorityFiltering.ts";
|
||||||
|
import {cell, type Cell} from "../../utils/cellStore.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single log record emitted by the backend logging system.
|
||||||
|
*
|
||||||
|
* @property name - The name of the logger or source (e.g., `"agent.core"`).
|
||||||
|
* @property message - The message content of the log record.
|
||||||
|
* @property levelname - The human-readable severity level (e.g., `"INFO"`, `"ERROR"`).
|
||||||
|
* @property levelno - The numeric severity value corresponding to `levelname`.
|
||||||
|
* @property created - The UNIX timestamp (in seconds) when this record was created.
|
||||||
|
* @property relativeCreated - The time (in milliseconds) since the logging system started.
|
||||||
|
* @property reference - (Optional) A reference identifier linking related log messages.
|
||||||
|
* @property firstCreated - Timestamp of the first log in this reference group.
|
||||||
|
* @property firstRelativeCreated - Relative timestamp of the first log in this reference group.
|
||||||
|
*/
|
||||||
|
export type LogRecord = {
|
||||||
|
name: string;
|
||||||
|
message: string;
|
||||||
|
levelname: 'LLM' | 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string;
|
||||||
|
levelno: number;
|
||||||
|
created: number;
|
||||||
|
relativeCreated: number;
|
||||||
|
reference?: string;
|
||||||
|
firstCreated: number;
|
||||||
|
firstRelativeCreated: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A log filter predicate with priority support, used to determine whether
|
||||||
|
* a log record should be displayed.
|
||||||
|
*
|
||||||
|
* This extends a general `PriorityFilterPredicate` and includes an optional
|
||||||
|
* `value` field for UI metadata (e.g., selected log level or agent).
|
||||||
|
*
|
||||||
|
* @template T - The type of record being filtered (here, `LogRecord`).
|
||||||
|
*/
|
||||||
|
export type LogFilterPredicate = PriorityFilterPredicate<LogRecord> & {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
value: any };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook that manages the lifecycle of log records, including:
|
||||||
|
* - Receiving live log messages via Server-Sent Events (SSE),
|
||||||
|
* - Applying priority-based filtering rules,
|
||||||
|
* - Managing distinct logger names and reference-linked messages.
|
||||||
|
*
|
||||||
|
* Returns both the filtered logs (as reactive `Cell<LogRecord>` objects)
|
||||||
|
* and a set of distinct logger names for use in UI components (e.g., Filters).
|
||||||
|
*
|
||||||
|
* @param filterPredicates - A `Map` of log filter predicates, keyed by ID or type.
|
||||||
|
* @returns An object containing:
|
||||||
|
* - `filteredLogs`: The currently visible (filtered) log messages.
|
||||||
|
* - `distinctNames`: A set of all distinct logger names encountered.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const { filteredLogs, distinctNames } = useLogs(activeFilters);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
|
||||||
|
/** Distinct logger names encountered across all logs. */
|
||||||
|
const [distinctNames, setDistinctNames] = useState<Set<string>>(new Set());
|
||||||
|
/** Filtered logs that pass all active predicates, stored as reactive cells. */
|
||||||
|
const [filtered, setFiltered] = useState<Cell<LogRecord>[]>([]);
|
||||||
|
|
||||||
|
/** Persistent reference to the active EventSource connection. */
|
||||||
|
const sseRef = useRef<EventSource | null>(null);
|
||||||
|
/** Keeps a stable reference to the current filter map (avoids re-renders). */
|
||||||
|
const filtersRef = useRef(filterPredicates);
|
||||||
|
/** Stores all received logs (the unfiltered full history). */
|
||||||
|
const logsRef = useRef<LogRecord[]>([]);
|
||||||
|
|
||||||
|
/** Map to store the first message for each reference, instance can be updated to change contents. */
|
||||||
|
const firstByRefRef = useRef<Map<string, Cell<LogRecord>>>(new Map());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply all active filter predicates to a log record.
|
||||||
|
* @param log The log record to apply the filters to.
|
||||||
|
* @returns `true` if the record passes all filters; otherwise `false`.
|
||||||
|
*/
|
||||||
|
const applyFilters = useCallback((log: LogRecord) =>
|
||||||
|
applyPriorityPredicates(log, [...filtersRef.current.values()]), []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully recomputes the filtered log list based on the current
|
||||||
|
* filter predicates and historical logs.
|
||||||
|
*
|
||||||
|
* Should be invoked whenever the filter map changes.
|
||||||
|
*/
|
||||||
|
const recomputeFiltered = useCallback(() => {
|
||||||
|
const newFiltered: Cell<LogRecord>[] = [];
|
||||||
|
firstByRefRef.current = new Map();
|
||||||
|
|
||||||
|
for (const message of logsRef.current) {
|
||||||
|
const messageCell = cell<LogRecord>({
|
||||||
|
...message,
|
||||||
|
firstCreated: message.created,
|
||||||
|
firstRelativeCreated: message.relativeCreated,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle reference grouping: update the first message in the group.
|
||||||
|
if (message.reference) {
|
||||||
|
const first = firstByRefRef.current.get(message.reference);
|
||||||
|
if (first) {
|
||||||
|
// Update the first's contents
|
||||||
|
first.set((prev) => ({
|
||||||
|
...message,
|
||||||
|
firstCreated: prev.firstCreated ?? prev.created,
|
||||||
|
firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated,
|
||||||
|
}));
|
||||||
|
|
||||||
|
continue; // Don't add it to the list again (it's a duplicate).
|
||||||
|
} else {
|
||||||
|
// Add the first message with this reference to the registry
|
||||||
|
firstByRefRef.current.set(message.reference, messageCell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include only if it passes current filters.
|
||||||
|
if (applyFilters(message)) {
|
||||||
|
newFiltered.push(messageCell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiltered(newFiltered);
|
||||||
|
}, [applyFilters, setFiltered]);
|
||||||
|
|
||||||
|
// Re-filter all logs whenever filter predicates change.
|
||||||
|
useEffect(() => {
|
||||||
|
filtersRef.current = filterPredicates;
|
||||||
|
recomputeFiltered();
|
||||||
|
}, [filterPredicates, recomputeFiltered]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a newly received log record.
|
||||||
|
* Updates the full log history, distinct names set, and filtered log list.
|
||||||
|
*
|
||||||
|
* @param message - The new log record to process.
|
||||||
|
*/
|
||||||
|
const handleNewMessage = useCallback((message: LogRecord) => {
|
||||||
|
// Store in complete history for future refiltering.
|
||||||
|
logsRef.current.push(message);
|
||||||
|
|
||||||
|
// Track distinct logger names.
|
||||||
|
setDistinctNames((prev) => {
|
||||||
|
if (prev.has(message.name)) return prev;
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.add(message.name);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap in a reactive cell for UI binding.
|
||||||
|
const messageCell = cell<LogRecord>({
|
||||||
|
...message,
|
||||||
|
firstCreated: message.created,
|
||||||
|
firstRelativeCreated: message.relativeCreated,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle reference-linked updates.
|
||||||
|
if (message.reference) {
|
||||||
|
const first = firstByRefRef.current.get(message.reference);
|
||||||
|
if (first) {
|
||||||
|
// Update the first's contents
|
||||||
|
first.set((prev) => ({
|
||||||
|
...message,
|
||||||
|
firstCreated: prev.firstCreated ?? prev.created,
|
||||||
|
firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return; // Do not duplicate reference group entries.
|
||||||
|
} else {
|
||||||
|
firstByRefRef.current.set(message.reference, messageCell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only append if message passes filters.
|
||||||
|
if (applyFilters(message)) {
|
||||||
|
setFiltered((curr) => [...curr, messageCell]);
|
||||||
|
}
|
||||||
|
}, [applyFilters, setFiltered]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the SSE (Server-Sent Events) stream for real-time logs.
|
||||||
|
*
|
||||||
|
* Subscribes to messages from the backend logging endpoint and
|
||||||
|
* dispatches each message to `handleNewMessage`.
|
||||||
|
*
|
||||||
|
* Cleans up the EventSource connection when the component unmounts.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
// Only create one SSE connection for the lifetime of the hook.
|
||||||
|
if (sseRef.current) return;
|
||||||
|
|
||||||
|
const es = new EventSource("http://localhost:8000/logs/stream");
|
||||||
|
sseRef.current = es;
|
||||||
|
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
const data: LogRecord = JSON.parse(event.data);
|
||||||
|
handleNewMessage(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
es.close();
|
||||||
|
sseRef.current = null;
|
||||||
|
};
|
||||||
|
}, [handleNewMessage]);
|
||||||
|
|
||||||
|
return {filteredLogs: filtered, distinctNames};
|
||||||
|
}
|
||||||
75
src/components/MultilineTextField.tsx
Normal file
75
src/components/MultilineTextField.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import styles from "./TextField.module.css";
|
||||||
|
|
||||||
|
export function MultilineTextField({
|
||||||
|
value = "",
|
||||||
|
setValue,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
ariaLabel,
|
||||||
|
invalid = false,
|
||||||
|
minRows = 3,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
setValue: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
id?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
invalid?: boolean;
|
||||||
|
minRows?: number;
|
||||||
|
}) {
|
||||||
|
const [readOnly, setReadOnly] = useState(true);
|
||||||
|
const [inputValue, setInputValue] = useState(value);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInputValue(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Auto-grow logic
|
||||||
|
useEffect(() => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
}, [inputValue]);
|
||||||
|
|
||||||
|
const onCommit = () => {
|
||||||
|
setReadOnly(true);
|
||||||
|
setValue(inputValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.target as HTMLTextAreaElement).blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
rows={minRows}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onFocus={() => setReadOnly(false)}
|
||||||
|
onBlur={onCommit}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
readOnly={readOnly}
|
||||||
|
id={id}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={`
|
||||||
|
${readOnly ? "drag" : "nodrag"}
|
||||||
|
flex-1
|
||||||
|
${styles.textField}
|
||||||
|
${styles.multiline}
|
||||||
|
${invalid ? styles.invalid : ""}
|
||||||
|
${className ?? ""}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/components/ScrollIntoView.tsx
Normal file
22
src/components/ScrollIntoView.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import {useEffect, useRef} from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A React component that automatically scrolls itself into view whenever rendered.
|
||||||
|
*
|
||||||
|
* This component is especially useful in scrollable containers to keep the most
|
||||||
|
* recent content visible (e.g., chat applications, live logs, or notifications).
|
||||||
|
*
|
||||||
|
* It uses the browser's `Element.scrollIntoView()` API with smooth scrolling behavior.
|
||||||
|
*
|
||||||
|
* @returns A `<div>` element that scrolls into view when mounted or updated.
|
||||||
|
*/
|
||||||
|
export default function ScrollIntoView() {
|
||||||
|
/** Ref to the DOM element that will be scrolled into view. */
|
||||||
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (elementRef.current) elementRef.current.scrollIntoView({ behavior: "smooth" });
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div ref={elementRef} />;
|
||||||
|
}
|
||||||
39
src/components/TextField.module.css
Normal file
39
src/components/TextField.module.css
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
.text-field {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 5pt;
|
||||||
|
padding: 4px 8px;
|
||||||
|
max-width: 50vw;
|
||||||
|
min-width: 10vw;
|
||||||
|
outline: none;
|
||||||
|
background-color: canvas;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field.invalid {
|
||||||
|
border-color: red;
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field:focus:not(.invalid) {
|
||||||
|
border-color: color-mix(in srgb, canvas, #777 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field:read-only {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: color-mix(in srgb, canvas, #777 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field:read-only:hover:not(.invalid) {
|
||||||
|
border-color: color-mix(in srgb, canvas, #777 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiline {
|
||||||
|
resize: none; /* no manual resizing */
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow: hidden; /* needed for auto-grow */
|
||||||
|
max-width: 100%;
|
||||||
|
width: 95%;
|
||||||
|
min-width: 95%;
|
||||||
|
}
|
||||||
124
src/components/TextField.tsx
Normal file
124
src/components/TextField.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import styles from "./TextField.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A styled text input that updates its value **in real time** at every keystroke.
|
||||||
|
*
|
||||||
|
* Automatically toggles between read-only and editable modes to integrate with
|
||||||
|
* drag-based UIs (like React Flow). Calls `onCommit` when editing is completed.
|
||||||
|
*
|
||||||
|
* @param props - Component properties.
|
||||||
|
* @param props.value - The current text input value.
|
||||||
|
* @param props.setValue - Callback invoked on every keystroke to update the value.
|
||||||
|
* @param props.onCommit - Callback invoked when editing is finalized (on blur or Enter).
|
||||||
|
* @param props.placeholder - Optional placeholder text displayed when the input is empty.
|
||||||
|
* @param props.className - Optional additional CSS class names.
|
||||||
|
* @param props.id - Optional unique HTML `id` for the input element.
|
||||||
|
* @param props.ariaLabel - Optional ARIA label for accessibility.
|
||||||
|
* @param props.invalid - If true, applies error styling to indicate invalid input.
|
||||||
|
*
|
||||||
|
* @returns A styled `<input>` element that updates its value in real time.
|
||||||
|
*/
|
||||||
|
export function RealtimeTextField({
|
||||||
|
value = "",
|
||||||
|
setValue,
|
||||||
|
onCommit,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
ariaLabel,
|
||||||
|
invalid = false,
|
||||||
|
} : {
|
||||||
|
value: string,
|
||||||
|
setValue: (value: string) => void,
|
||||||
|
onCommit: () => void,
|
||||||
|
placeholder?: string,
|
||||||
|
className?: string,
|
||||||
|
id?: string,
|
||||||
|
ariaLabel?: string,
|
||||||
|
invalid?: boolean,
|
||||||
|
}) {
|
||||||
|
/** Tracks whether the input is currently read-only (for drag compatibility). */
|
||||||
|
const [readOnly, setReadOnly] = useState(true);
|
||||||
|
|
||||||
|
/** Finalizes editing and calls `onCommit` when the user exits the field. */
|
||||||
|
const updateData = () => {
|
||||||
|
setReadOnly(true);
|
||||||
|
onCommit();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Handles the Enter key — commits the input by triggering a blur event. */
|
||||||
|
const updateOnEnter = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === "Enter")
|
||||||
|
(event.target as HTMLInputElement).blur(); };
|
||||||
|
|
||||||
|
return <input
|
||||||
|
type={"text"}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onFocus={() => setReadOnly(false)}
|
||||||
|
onBlur={updateData}
|
||||||
|
onKeyDown={updateOnEnter}
|
||||||
|
readOnly={readOnly}
|
||||||
|
id={id}
|
||||||
|
// ReactFlow uses the "drag" / "nodrag" classes to enable / disable dragging of nodes
|
||||||
|
className={`${readOnly ? "drag" : "nodrag"} flex-1 ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A styled text input that updates its value **only on commit** (when the user
|
||||||
|
* presses Enter or clicks outside the input).
|
||||||
|
*
|
||||||
|
* Internally wraps `RealtimeTextField` and buffers input changes locally,
|
||||||
|
* calling `setValue` only once editing is complete.
|
||||||
|
*
|
||||||
|
* @param props - Component properties.
|
||||||
|
* @param props.value - The current text input value.
|
||||||
|
* @param props.setValue - Callback invoked when the user commits the change.
|
||||||
|
* @param props.placeholder - Optional placeholder text displayed when the input is empty.
|
||||||
|
* @param props.className - Optional additional CSS class names.
|
||||||
|
* @param props.id - Optional unique HTML `id` for the input element.
|
||||||
|
* @param props.ariaLabel - Optional ARIA label for accessibility.
|
||||||
|
* @param props.invalid - If true, applies error styling to indicate invalid input.
|
||||||
|
*
|
||||||
|
* @returns A styled `<input>` element that updates its parent state only on commit.
|
||||||
|
*/
|
||||||
|
export function TextField({
|
||||||
|
value = "",
|
||||||
|
setValue,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
ariaLabel,
|
||||||
|
invalid = false,
|
||||||
|
} : {
|
||||||
|
value: string,
|
||||||
|
setValue: (value: string) => void,
|
||||||
|
placeholder?: string,
|
||||||
|
className?: string,
|
||||||
|
id?: string,
|
||||||
|
ariaLabel?: string,
|
||||||
|
invalid?: boolean,
|
||||||
|
}) {
|
||||||
|
const [inputValue, setInputValue] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInputValue(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const onCommit = () => setValue(inputValue);
|
||||||
|
|
||||||
|
return <RealtimeTextField
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={inputValue}
|
||||||
|
setValue={setInputValue}
|
||||||
|
onCommit={onCommit}
|
||||||
|
id={id}
|
||||||
|
className={className}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
invalid={invalid}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { useState } from 'react'
|
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() {
|
function Counter() {
|
||||||
|
/** The current counter value. */
|
||||||
const [count, setCount] = useState(0)
|
const [count, setCount] = useState(0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,13 +7,15 @@
|
|||||||
color: rgba(255, 255, 255, 0.87);
|
color: rgba(255, 255, 255, 0.87);
|
||||||
background-color: #242424;
|
background-color: #242424;
|
||||||
|
|
||||||
|
--accent-color: #008080;
|
||||||
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body, #root {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
@@ -24,12 +26,7 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
font-weight: 500;
|
color: canvastext;
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -49,7 +46,7 @@ button {
|
|||||||
transition: border-color 0.25s;
|
transition: border-color 0.25s;
|
||||||
}
|
}
|
||||||
button:hover {
|
button:hover {
|
||||||
border-color: #646cff;
|
border-color: var(--accent-color);
|
||||||
}
|
}
|
||||||
button:focus,
|
button:focus,
|
||||||
button:focus-visible {
|
button:focus-visible {
|
||||||
@@ -60,11 +57,23 @@ button:focus-visible {
|
|||||||
:root {
|
:root {
|
||||||
color: #213547;
|
color: #213547;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
}
|
|
||||||
a:hover {
|
--accent-color: #00AAAA;
|
||||||
color: #747bff;
|
--select-color: rgba(gray);
|
||||||
|
|
||||||
|
--dropdown-menu-background-color: rgb(247, 247, 247);
|
||||||
|
--dropdown-menu-border: rgba(207, 207, 207, 0.986);
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
background-color: #f9f9f9;
|
background-color: #f9f9f9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
color: #ffffff;
|
||||||
|
--select-color: rgba(gray);
|
||||||
|
--dropdown-menu-background-color: rgba(39, 39, 39, 0.986);
|
||||||
|
--dropdown-menu-border: rgba(65, 65, 65, 0.986);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 pepperLogo from '../../assets/pepper_transp2_small.svg'
|
||||||
import styles from './Home.module.css'
|
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() {
|
function Home() {
|
||||||
return (
|
return (
|
||||||
<div className={`flex-col ${styles.gapXl}`}>
|
<div className={`flex-col ${styles.gapXl}`}>
|
||||||
@@ -14,6 +22,7 @@ function Home() {
|
|||||||
<Link to={"/robot"}>Robot Interaction →</Link>
|
<Link to={"/robot"}>Robot Interaction →</Link>
|
||||||
<Link to={"/editor"}>Editor →</Link>
|
<Link to={"/editor"}>Editor →</Link>
|
||||||
<Link to={"/template"}>Template →</Link>
|
<Link to={"/template"}>Template →</Link>
|
||||||
|
<Link to={"/ConnectedRobots"}>Connected Robots →</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,34 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
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() {
|
export default function Robot() {
|
||||||
|
/** The text message currently entered by the user. */
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
/** Whether the robot’s microphone or listening mode is currently active. */
|
||||||
const [listening, setListening] = useState(false);
|
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);
|
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);
|
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 () => {
|
const sendMessage = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("http://localhost:8000/message", {
|
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(() => {
|
useEffect(() => {
|
||||||
const eventSource = new EventSource("http://localhost:8000/sse");
|
const eventSource = new EventSource("http://localhost:8000/sse");
|
||||||
|
|
||||||
@@ -43,6 +75,10 @@ export default function Robot() {
|
|||||||
};
|
};
|
||||||
}, [conversationIndex]);
|
}, [conversationIndex]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically scrolls the conversation view to the bottom
|
||||||
|
* whenever a new message is added.
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversationRef || !conversationRef.current) return;
|
if (!conversationRef || !conversationRef.current) return;
|
||||||
conversationRef.current.scrollTop = conversationRef.current.scrollHeight;
|
conversationRef.current.scrollTop = conversationRef.current.scrollHeight;
|
||||||
|
|||||||
@@ -1,26 +1,12 @@
|
|||||||
/* editor UI */
|
/* editor UI */
|
||||||
|
|
||||||
.outer-editor-container {
|
|
||||||
margin-inline: auto;
|
|
||||||
display: flex;
|
|
||||||
justify-self: center;
|
|
||||||
padding: 10px;
|
|
||||||
align-items: center;
|
|
||||||
width: 80vw;
|
|
||||||
height: 80vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inner-editor-container {
|
.inner-editor-container {
|
||||||
outline-style: solid;
|
box-sizing: border-box;
|
||||||
border-radius: 10pt;
|
margin: 1rem;
|
||||||
width: 90%;
|
width: calc(100% - 2rem);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.dnd-panel {
|
.dnd-panel {
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
margin-inline-end: auto;
|
margin-inline-end: auto;
|
||||||
@@ -47,6 +33,12 @@
|
|||||||
|
|
||||||
/* Node Styles */
|
/* Node Styles */
|
||||||
|
|
||||||
|
:global(.react-flow__node.selected) {
|
||||||
|
outline: 1px dashed blue !important;
|
||||||
|
border-radius: 5pt;
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.default-node {
|
.default-node {
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
background-color: canvas;
|
background-color: canvas;
|
||||||
@@ -55,42 +47,53 @@
|
|||||||
filter: drop-shadow(0 0 0.75rem black);
|
filter: drop-shadow(0 0 0.75rem black);
|
||||||
}
|
}
|
||||||
|
|
||||||
.default-node-norm {
|
|
||||||
padding: 10px 15px;
|
|
||||||
background-color: canvas;
|
.node-norm {
|
||||||
border-radius: 5pt;
|
outline: rgb(0, 149, 25) solid 2pt;
|
||||||
outline: forestgreen solid 2pt;
|
|
||||||
filter: drop-shadow(0 0 0.25rem forestgreen);
|
filter: drop-shadow(0 0 0.25rem forestgreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
.default-node-phase {
|
.node-goal {
|
||||||
padding: 10px 15px;
|
outline: yellow solid 2pt;
|
||||||
background-color: canvas;
|
filter: drop-shadow(0 0 0.25rem yellow);
|
||||||
border-radius: 5pt;
|
}
|
||||||
|
|
||||||
|
.node-trigger {
|
||||||
|
outline: teal solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-phase {
|
||||||
outline: dodgerblue solid 2pt;
|
outline: dodgerblue solid 2pt;
|
||||||
filter: drop-shadow(0 0 0.25rem dodgerblue);
|
filter: drop-shadow(0 0 0.25rem dodgerblue);
|
||||||
}
|
}
|
||||||
|
|
||||||
.default-node-start {
|
.node-start {
|
||||||
padding: 10px 15px;
|
|
||||||
background-color: canvas;
|
|
||||||
border-radius: 5pt;
|
|
||||||
outline: orange solid 2pt;
|
outline: orange solid 2pt;
|
||||||
filter: drop-shadow(0 0 0.25rem orange);
|
filter: drop-shadow(0 0 0.25rem orange);
|
||||||
}
|
}
|
||||||
|
|
||||||
.default-node-end {
|
.node-end {
|
||||||
padding: 10px 15px;
|
|
||||||
background-color: canvas;
|
|
||||||
border-radius: 5pt;
|
|
||||||
outline: red solid 2pt;
|
outline: red solid 2pt;
|
||||||
filter: drop-shadow(0 0 0.25rem red);
|
filter: drop-shadow(0 0 0.25rem red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-basic_belief {
|
||||||
|
outline: plum solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem plum);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inferred_belief {
|
||||||
|
outline: mediumpurple solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem mediumpurple);
|
||||||
|
}
|
||||||
|
|
||||||
.draggable-node {
|
.draggable-node {
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
background-color: canvas;
|
background-color: canvas;
|
||||||
border-radius: 5pt;
|
border-radius: 5pt;
|
||||||
|
cursor: move;
|
||||||
outline: black solid 2pt;
|
outline: black solid 2pt;
|
||||||
filter: drop-shadow(0 0 0.75rem black);
|
filter: drop-shadow(0 0 0.75rem black);
|
||||||
}
|
}
|
||||||
@@ -99,14 +102,34 @@
|
|||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
background-color: canvas;
|
background-color: canvas;
|
||||||
border-radius: 5pt;
|
border-radius: 5pt;
|
||||||
|
cursor: move;
|
||||||
outline: forestgreen solid 2pt;
|
outline: forestgreen solid 2pt;
|
||||||
filter: drop-shadow(0 0 0.25rem forestgreen);
|
filter: drop-shadow(0 0 0.25rem forestgreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.draggable-node-goal {
|
||||||
|
padding: 3px 10px;
|
||||||
|
background-color: canvas;
|
||||||
|
border-radius: 5pt;
|
||||||
|
cursor: move;
|
||||||
|
outline: yellow solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-node-trigger {
|
||||||
|
padding: 3px 10px;
|
||||||
|
background-color: canvas;
|
||||||
|
border-radius: 5pt;
|
||||||
|
cursor: move;
|
||||||
|
outline: teal solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem teal);
|
||||||
|
}
|
||||||
|
|
||||||
.draggable-node-phase {
|
.draggable-node-phase {
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
background-color: canvas;
|
background-color: canvas;
|
||||||
border-radius: 5pt;
|
border-radius: 5pt;
|
||||||
|
cursor: move;
|
||||||
outline: dodgerblue solid 2pt;
|
outline: dodgerblue solid 2pt;
|
||||||
filter: drop-shadow(0 0 0.25rem dodgerblue);
|
filter: drop-shadow(0 0 0.25rem dodgerblue);
|
||||||
}
|
}
|
||||||
@@ -115,6 +138,7 @@
|
|||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
background-color: canvas;
|
background-color: canvas;
|
||||||
border-radius: 5pt;
|
border-radius: 5pt;
|
||||||
|
cursor: move;
|
||||||
outline: orange solid 2pt;
|
outline: orange solid 2pt;
|
||||||
filter: drop-shadow(0 0 0.25rem orange);
|
filter: drop-shadow(0 0 0.25rem orange);
|
||||||
}
|
}
|
||||||
@@ -123,7 +147,87 @@
|
|||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
background-color: canvas;
|
background-color: canvas;
|
||||||
border-radius: 5pt;
|
border-radius: 5pt;
|
||||||
|
cursor: move;
|
||||||
outline: red solid 2pt;
|
outline: red solid 2pt;
|
||||||
filter: drop-shadow(0 0 0.25rem red);
|
filter: drop-shadow(0 0 0.25rem red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.draggable-node-basic_belief {
|
||||||
|
padding: 3px 10px;
|
||||||
|
background-color: canvas;
|
||||||
|
border-radius: 5pt;
|
||||||
|
cursor: move;
|
||||||
|
outline: plum solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem plum);
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-node-inferred_belief {
|
||||||
|
padding: 3px 10px;
|
||||||
|
background-color: canvas;
|
||||||
|
border-radius: 5pt;
|
||||||
|
outline: mediumpurple solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem mediumpurple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planNoIterate {
|
||||||
|
opacity: 0.5;
|
||||||
|
font-style: italic;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomLeftHandle {
|
||||||
|
left: 40% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomRightHandle {
|
||||||
|
left: 60% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-toolbar-tooltip {
|
||||||
|
background-color: darkgray;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: help;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tooltip {
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: Canvas;
|
||||||
|
color: CanvasText;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
outline: CanvasText solid 2px;
|
||||||
|
font-size: 14px;
|
||||||
|
filter: drop-shadow(0 0 0.25rem CanvasText);
|
||||||
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tooltip-header {
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: CanvasText;
|
||||||
|
color: Canvas;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px 0 0 6px;
|
||||||
|
outline: CanvasText solid 2px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-variant-caps: small-caps;
|
||||||
|
filter: drop-shadow(0 0 0.25rem CanvasText);
|
||||||
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(5px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
@@ -4,34 +4,25 @@ import {
|
|||||||
Panel,
|
Panel,
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
MarkerType,
|
MarkerType, getOutgoers
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import {graphReducer, runProgram} from "./VisProgLogic.tsx";
|
||||||
|
import warningStyles from './visualProgrammingUI/components/WarningSidebar.module.css'
|
||||||
|
import {type CSSProperties, useEffect, useState} from "react";
|
||||||
import {useShallow} from 'zustand/react/shallow';
|
import {useShallow} from 'zustand/react/shallow';
|
||||||
|
import useProgramStore from "../../utils/programStore.ts";
|
||||||
import {
|
|
||||||
StartNodeComponent,
|
|
||||||
EndNodeComponent,
|
|
||||||
PhaseNodeComponent,
|
|
||||||
NormNodeComponent
|
|
||||||
} from './visualProgrammingUI/components/NodeDefinitions.tsx';
|
|
||||||
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
||||||
import graphReducer from "./visualProgrammingUI/GraphReducer.ts";
|
import {type EditorWarning, globalWarning} from "./visualProgrammingUI/components/EditorWarnings.tsx";
|
||||||
|
import {WarningsSidebar} from "./visualProgrammingUI/components/WarningSidebar.tsx";
|
||||||
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
||||||
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
||||||
import styles from './VisProg.module.css'
|
import styles from './VisProg.module.css'
|
||||||
|
import { NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
|
||||||
|
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
|
||||||
|
|
||||||
// --| config starting params for flow |--
|
// --| config starting params for flow |--
|
||||||
|
|
||||||
/**
|
|
||||||
* contains the types of all nodes that are available in the editor
|
|
||||||
*/
|
|
||||||
const NODE_TYPES = {
|
|
||||||
start: StartNodeComponent,
|
|
||||||
end: EndNodeComponent,
|
|
||||||
phase: PhaseNodeComponent,
|
|
||||||
norm: NormNodeComponent
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* defines how the default edge looks inside the editor
|
* defines how the default edge looks inside the editor
|
||||||
@@ -53,11 +44,18 @@ const selector = (state: FlowState) => ({
|
|||||||
nodes: state.nodes,
|
nodes: state.nodes,
|
||||||
edges: state.edges,
|
edges: state.edges,
|
||||||
onNodesChange: state.onNodesChange,
|
onNodesChange: state.onNodesChange,
|
||||||
|
onNodesDelete: state.onNodesDelete,
|
||||||
|
onEdgesDelete: state.onEdgesDelete,
|
||||||
onEdgesChange: state.onEdgesChange,
|
onEdgesChange: state.onEdgesChange,
|
||||||
onConnect: state.onConnect,
|
onConnect: state.onConnect,
|
||||||
onReconnectStart: state.onReconnectStart,
|
onReconnectStart: state.onReconnectStart,
|
||||||
onReconnectEnd: state.onReconnectEnd,
|
onReconnectEnd: state.onReconnectEnd,
|
||||||
onReconnect: state.onReconnect
|
onReconnect: state.onReconnect,
|
||||||
|
undo: state.undo,
|
||||||
|
redo: state.redo,
|
||||||
|
beginBatchAction: state.beginBatchAction,
|
||||||
|
endBatchAction: state.endBatchAction,
|
||||||
|
scrollable: state.scrollable
|
||||||
});
|
});
|
||||||
|
|
||||||
// --| define ReactFlow editor |--
|
// --| define ReactFlow editor |--
|
||||||
@@ -72,43 +70,96 @@ const VisProgUI = () => {
|
|||||||
const {
|
const {
|
||||||
nodes, edges,
|
nodes, edges,
|
||||||
onNodesChange,
|
onNodesChange,
|
||||||
|
onNodesDelete,
|
||||||
|
onEdgesDelete,
|
||||||
onEdgesChange,
|
onEdgesChange,
|
||||||
onConnect,
|
onConnect,
|
||||||
onReconnect,
|
onReconnect,
|
||||||
onReconnectStart,
|
onReconnectStart,
|
||||||
onReconnectEnd
|
onReconnectEnd,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
beginBatchAction,
|
||||||
|
endBatchAction,
|
||||||
|
scrollable
|
||||||
} = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore
|
} = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
// adds ctrl+z and ctrl+y support to respectively undo and redo actions
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.ctrlKey && e.key === 'z') undo();
|
||||||
|
if (e.ctrlKey && e.key === 'y') redo();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
});
|
||||||
|
const {unregisterWarning, registerWarning} = useFlowStore();
|
||||||
|
useEffect(() => {
|
||||||
|
|
||||||
|
if (checkPhaseChain()) {
|
||||||
|
unregisterWarning(globalWarning,'INCOMPLETE_PROGRAM');
|
||||||
|
} else {
|
||||||
|
// create global warning for incomplete program chain
|
||||||
|
const incompleteProgramWarning : EditorWarning = {
|
||||||
|
scope: {
|
||||||
|
id: globalWarning,
|
||||||
|
handleId: undefined
|
||||||
|
},
|
||||||
|
type: 'INCOMPLETE_PROGRAM',
|
||||||
|
severity: "ERROR",
|
||||||
|
description: "there is no complete phase chain from the startNode to the EndNode"
|
||||||
|
}
|
||||||
|
|
||||||
|
registerWarning(incompleteProgramWarning);
|
||||||
|
}
|
||||||
|
},[edges, registerWarning, unregisterWarning])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.outerEditorContainer}>
|
<div className={`${styles.innerEditorContainer} round-lg border-lg flex-row`} style={({'--flow-zoom': zoom} as CSSProperties)}>
|
||||||
<div className={styles.innerEditorContainer}>
|
<ReactFlow
|
||||||
<ReactFlow
|
nodes={nodes}
|
||||||
nodes={nodes}
|
edges={edges}
|
||||||
edges={edges}
|
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
||||||
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
nodeTypes={NodeTypes}
|
||||||
nodeTypes={NODE_TYPES}
|
onNodesChange={onNodesChange}
|
||||||
onNodesChange={onNodesChange}
|
onNodesDelete={onNodesDelete}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesDelete={onEdgesDelete}
|
||||||
onReconnect={onReconnect}
|
onEdgesChange={onEdgesChange}
|
||||||
onReconnectStart={onReconnectStart}
|
onReconnect={onReconnect}
|
||||||
onReconnectEnd={onReconnectEnd}
|
onReconnectStart={onReconnectStart}
|
||||||
onConnect={onConnect}
|
onReconnectEnd={onReconnectEnd}
|
||||||
snapToGrid
|
onConnect={onConnect}
|
||||||
fitView
|
onNodeDragStart={beginBatchAction}
|
||||||
proOptions={{hideAttribution: true}}
|
onNodeDragStop={endBatchAction}
|
||||||
>
|
preventScrolling={scrollable}
|
||||||
<Panel position="top-center" className={styles.dndPanel}>
|
onMove={(_, viewport) => setZoom(viewport.zoom)}
|
||||||
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
|
reconnectRadius={15}
|
||||||
|
snapToGrid
|
||||||
|
fitView
|
||||||
|
proOptions={{hideAttribution: true}}
|
||||||
|
style={{flexGrow: 3}}
|
||||||
|
>
|
||||||
|
<Panel position="top-center" className={styles.dndPanel}>
|
||||||
|
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
|
||||||
</Panel>
|
</Panel>
|
||||||
<Controls/>
|
<Panel position = "bottom-left" className={styles.saveLoadPanel}>
|
||||||
<Background/>
|
<SaveLoadPanel></SaveLoadPanel>
|
||||||
</ReactFlow>
|
</Panel>
|
||||||
</div>
|
<Panel position="bottom-center">
|
||||||
|
<button onClick={() => undo()}>Undo</button>
|
||||||
|
<button onClick={() => redo()}>Redo</button>
|
||||||
|
</Panel>
|
||||||
|
<Panel position="center-right" className={warningStyles.warningsSidebar}>
|
||||||
|
<WarningsSidebar/>
|
||||||
|
</Panel>
|
||||||
|
<Controls/>
|
||||||
|
<Background/>
|
||||||
|
</ReactFlow>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Places the VisProgUI component inside a ReactFlowProvider
|
* Places the VisProgUI component inside a ReactFlowProvider
|
||||||
*
|
*
|
||||||
@@ -124,11 +175,23 @@ function VisualProgrammingUI() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// currently outputs the prepared program to the console
|
const checkPhaseChain = (): boolean => {
|
||||||
function runProgram() {
|
const {nodes, edges} = useFlowStore.getState();
|
||||||
const program = graphReducer();
|
|
||||||
console.log(program);
|
function checkForCompleteChain(currentNodeId: string): boolean {
|
||||||
}
|
const outgoingPhases = getOutgoers({id: currentNodeId}, nodes, edges)
|
||||||
|
.filter(node => ["end", "phase"].includes(node.type!));
|
||||||
|
|
||||||
|
if (outgoingPhases.length === 0) return false;
|
||||||
|
if (outgoingPhases.some(node => node.type === "end" )) return true;
|
||||||
|
|
||||||
|
const next = outgoingPhases.map(node => checkForCompleteChain(node.id))
|
||||||
|
.find(result => result);
|
||||||
|
return !!next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkForCompleteChain('start');
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* houses the entire page, so also UI elements
|
* houses the entire page, so also UI elements
|
||||||
@@ -136,12 +199,31 @@ function runProgram() {
|
|||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
function VisProgPage() {
|
function VisProgPage() {
|
||||||
|
const [programValidity, setProgramValidity] = useState<boolean>(true);
|
||||||
|
const {isProgramValid, severityIndex} = useFlowStore();
|
||||||
|
|
||||||
|
const validity = () => {return isProgramValid();}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setProgramValidity(validity);
|
||||||
|
// the following eslint disable is required as it wants us to use all possible dependencies for the useEffect statement,
|
||||||
|
// however this would cause unneeded updates
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [severityIndex]);
|
||||||
|
|
||||||
|
const setProgramState = useProgramStore((state) => state.setProgramState);
|
||||||
|
|
||||||
|
const processProgram = () => {
|
||||||
|
const phases = graphReducer(); // reduce graph
|
||||||
|
setProgramState({ phases }); // <-- save to store
|
||||||
|
runProgram(); // send to backend if needed
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<VisualProgrammingUI/>
|
<VisualProgrammingUI/>
|
||||||
<button onClick={runProgram}>run program</button>
|
<button onClick={processProgram} disabled={!programValidity}>Run Program</button>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default VisProgPage
|
export default VisProgPage
|
||||||
|
|||||||
43
src/pages/VisProgPage/VisProgLogic.tsx
Normal file
43
src/pages/VisProgPage/VisProgLogic.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import useProgramStore from "../../utils/programStore";
|
||||||
|
import orderPhaseNodeArray from "../../utils/orderPhaseNodes";
|
||||||
|
import useFlowStore from './visualProgrammingUI/VisProgStores';
|
||||||
|
import { NodeReduces } from './visualProgrammingUI/NodeRegistry';
|
||||||
|
import type { PhaseNode } from "./visualProgrammingUI/nodes/PhaseNode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces the graph into its phases' information and recursively calls their reducing function
|
||||||
|
*/
|
||||||
|
export function graphReducer() {
|
||||||
|
const { nodes } = useFlowStore.getState();
|
||||||
|
return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode [])
|
||||||
|
.map((n) => {
|
||||||
|
const reducer = NodeReduces['phase'];
|
||||||
|
return reducer(n, nodes)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outputs the prepared program to the console and sends it to the backend
|
||||||
|
*/
|
||||||
|
export function runProgram() {
|
||||||
|
const phases = graphReducer();
|
||||||
|
const program = {phases}
|
||||||
|
console.log(JSON.stringify(program, null, 2));
|
||||||
|
fetch(
|
||||||
|
"http://localhost:8000/program",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify(program),
|
||||||
|
}
|
||||||
|
).then((res) => {
|
||||||
|
if (!res.ok) throw new Error("Failed communicating with the backend.")
|
||||||
|
console.log("Successfully sent the program to the backend.");
|
||||||
|
|
||||||
|
// store reduced program in global program store for further use in the UI
|
||||||
|
// when the program was sent to the backend successfully:
|
||||||
|
useProgramStore.getState().setProgramState(structuredClone(program));
|
||||||
|
}).catch(() => console.log("Failed to send program to the backend."));
|
||||||
|
console.log(program);
|
||||||
|
}
|
||||||
145
src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts
Normal file
145
src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import type {Edge, Node} from "@xyflow/react";
|
||||||
|
import type {StateCreator, StoreApi } from 'zustand/vanilla';
|
||||||
|
import type {
|
||||||
|
SeverityIndex,
|
||||||
|
WarningRegistry
|
||||||
|
} from "./components/EditorWarnings.tsx";
|
||||||
|
import type {FlowState} from "./VisProgTypes.tsx";
|
||||||
|
|
||||||
|
export type FlowSnapshot = {
|
||||||
|
nodes: Node[];
|
||||||
|
edges: Edge[];
|
||||||
|
warnings: {
|
||||||
|
warningRegistry: WarningRegistry;
|
||||||
|
severityIndex: SeverityIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reduced version of the flowState type,
|
||||||
|
* This removes the functions that are provided by UndoRedo from the expected input type
|
||||||
|
*/
|
||||||
|
type BaseFlowState = Omit<FlowState, 'undo' | 'redo' | 'pushSnapshot' | 'beginBatchAction' | 'endBatchAction'>;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UndoRedo is implemented as a middleware for the FlowState store,
|
||||||
|
* this allows us to keep the undo redo logic separate from the flowState,
|
||||||
|
* and thus from the internal editor logic
|
||||||
|
*
|
||||||
|
* Allows users to undo and redo actions in the visual programming editor
|
||||||
|
*
|
||||||
|
* @param {(set: StoreApi<FlowState>["setState"], get: () => FlowState, api: StoreApi<FlowState>) => BaseFlowState} config
|
||||||
|
* @returns {StateCreator<FlowState>}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export const UndoRedo = (
|
||||||
|
config: (
|
||||||
|
set: StoreApi<FlowState>['setState'],
|
||||||
|
get: () => FlowState,
|
||||||
|
api: StoreApi<FlowState>
|
||||||
|
) => BaseFlowState ) : StateCreator<FlowState> => (set, get, api) => {
|
||||||
|
let batchTimeout: number | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures the current state for
|
||||||
|
*
|
||||||
|
* @param {BaseFlowState} state - the current state of the editor
|
||||||
|
* @returns {FlowSnapshot} - returns a snapshot of the current editor state
|
||||||
|
*/
|
||||||
|
const getSnapshot = (state : BaseFlowState) : FlowSnapshot => (structuredClone({
|
||||||
|
nodes: state.nodes,
|
||||||
|
edges: state.edges,
|
||||||
|
warnings: {
|
||||||
|
warningRegistry: state.editorWarningRegistry,
|
||||||
|
severityIndex: state.severityIndex,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
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,
|
||||||
|
editorWarningRegistry: snapshot.warnings.warningRegistry,
|
||||||
|
severityIndex: snapshot.warnings.severityIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
editorWarningRegistry: snapshot.warnings.warningRegistry,
|
||||||
|
severityIndex: snapshot.warnings.severityIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,188 +0,0 @@
|
|||||||
import {
|
|
||||||
type Edge,
|
|
||||||
getIncomers,
|
|
||||||
getOutgoers
|
|
||||||
} from '@xyflow/react';
|
|
||||||
import useFlowStore from "./VisProgStores.tsx";
|
|
||||||
import type {
|
|
||||||
BehaviorProgram,
|
|
||||||
GoalData,
|
|
||||||
GoalReducer,
|
|
||||||
GraphPreprocessor,
|
|
||||||
NormData,
|
|
||||||
NormReducer,
|
|
||||||
OrderedPhases,
|
|
||||||
Phase,
|
|
||||||
PhaseReducer,
|
|
||||||
PreparedGraph,
|
|
||||||
PreparedPhase
|
|
||||||
} from "./GraphReducerTypes.ts";
|
|
||||||
import type {
|
|
||||||
AppNode,
|
|
||||||
GoalNode,
|
|
||||||
NormNode,
|
|
||||||
PhaseNode
|
|
||||||
} from "./VisProgTypes.tsx";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reduces the current graph inside the visual programming editor into a BehaviorProgram
|
|
||||||
*
|
|
||||||
* @param {GraphPreprocessor} graphPreprocessor
|
|
||||||
* @param {PhaseReducer} phaseReducer
|
|
||||||
* @param {NormReducer} normReducer
|
|
||||||
* @param {GoalReducer} goalReducer
|
|
||||||
* @returns {BehaviorProgram}
|
|
||||||
*/
|
|
||||||
export default function graphReducer(
|
|
||||||
graphPreprocessor: GraphPreprocessor = defaultGraphPreprocessor,
|
|
||||||
phaseReducer: PhaseReducer = defaultPhaseReducer,
|
|
||||||
normReducer: NormReducer = defaultNormReducer,
|
|
||||||
goalReducer: GoalReducer = defaultGoalReducer
|
|
||||||
) : BehaviorProgram {
|
|
||||||
const nodes: AppNode[] = useFlowStore.getState().nodes;
|
|
||||||
const edges: Edge[] = useFlowStore.getState().edges;
|
|
||||||
const preparedGraph: PreparedGraph = graphPreprocessor(nodes, edges);
|
|
||||||
|
|
||||||
return preparedGraph.map((preparedPhase: PreparedPhase) : Phase =>
|
|
||||||
phaseReducer(
|
|
||||||
preparedPhase,
|
|
||||||
normReducer,
|
|
||||||
goalReducer
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* reduces a single preparedPhase to a Phase object
|
|
||||||
* the Phase object describes a single phase in a BehaviorProgram
|
|
||||||
*
|
|
||||||
* @param {PreparedPhase} phase
|
|
||||||
* @param {NormReducer} normReducer
|
|
||||||
* @param {GoalReducer} goalReducer
|
|
||||||
* @returns {Phase}
|
|
||||||
*/
|
|
||||||
export function defaultPhaseReducer(
|
|
||||||
phase: PreparedPhase,
|
|
||||||
normReducer: NormReducer = defaultNormReducer,
|
|
||||||
goalReducer: GoalReducer = defaultGoalReducer
|
|
||||||
) : Phase {
|
|
||||||
return {
|
|
||||||
id: phase.phaseNode.id,
|
|
||||||
name: phase.phaseNode.data.label,
|
|
||||||
nextPhaseId: phase.nextPhaseId,
|
|
||||||
phaseData: {
|
|
||||||
norms: phase.connectedNorms.map(normReducer),
|
|
||||||
goals: phase.connectedGoals.map(goalReducer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* the default implementation of the goalNode reducer function
|
|
||||||
*
|
|
||||||
* @param {GoalNode} node
|
|
||||||
* @returns {GoalData}
|
|
||||||
*/
|
|
||||||
function defaultGoalReducer(node: GoalNode) : GoalData {
|
|
||||||
return {
|
|
||||||
id: node.id,
|
|
||||||
name: node.data.label,
|
|
||||||
value: node.data.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* the default implementation of the normNode reducer function
|
|
||||||
*
|
|
||||||
* @param {NormNode} node
|
|
||||||
* @returns {NormData}
|
|
||||||
*/
|
|
||||||
function defaultNormReducer(node: NormNode) :NormData {
|
|
||||||
return {
|
|
||||||
id: node.id,
|
|
||||||
name: node.data.label,
|
|
||||||
value: node.data.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graph preprocessing functions:
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preprocesses the provide state of the behavior editor graph, preparing it for further processing in
|
|
||||||
* the graphReducer function
|
|
||||||
*
|
|
||||||
* @param {AppNode[]} nodes
|
|
||||||
* @param {Edge[]} edges
|
|
||||||
* @returns {PreparedGraph}
|
|
||||||
*/
|
|
||||||
export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : PreparedGraph {
|
|
||||||
const norms : NormNode[] = nodes.filter((node) => node.type === 'norm') as NormNode[];
|
|
||||||
const goals : GoalNode[] = nodes.filter((node) => node.type === 'goal') as GoalNode[];
|
|
||||||
const orderedPhases : OrderedPhases = orderPhases(nodes, edges);
|
|
||||||
|
|
||||||
return orderedPhases.phaseNodes.map((phase: PhaseNode) : PreparedPhase => {
|
|
||||||
const nextPhase = orderedPhases.connections.get(phase.id);
|
|
||||||
return {
|
|
||||||
phaseNode: phase,
|
|
||||||
nextPhaseId: nextPhase as string,
|
|
||||||
connectedNorms: getIncomers({id: phase.id}, norms,edges),
|
|
||||||
connectedGoals: getIncomers({id: phase.id}, goals,edges)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* orderPhases takes the state of the graph created by the editor and turns it into an OrderedPhases object.
|
|
||||||
*
|
|
||||||
* @param {AppNode[]} nodes
|
|
||||||
* @param {Edge[]} edges
|
|
||||||
* @returns {OrderedPhases}
|
|
||||||
*/
|
|
||||||
export function orderPhases(nodes: AppNode[],edges: Edge[]) : OrderedPhases {
|
|
||||||
// find the first Phase node
|
|
||||||
const phaseNodes : PhaseNode[] = nodes.filter((node) => node.type === 'phase') as PhaseNode[];
|
|
||||||
const startNodeIndex = nodes.findIndex((node : AppNode):boolean => {return (node.type === 'start');});
|
|
||||||
const firstPhaseNode = getOutgoers({ id: nodes[startNodeIndex].id },phaseNodes,edges);
|
|
||||||
|
|
||||||
// recursively adds the phase nodes to a list in the order they are connected in the graph
|
|
||||||
const nextPhase = (
|
|
||||||
currentIndex: number,
|
|
||||||
{ phaseNodes: phases, connections: connections} : OrderedPhases
|
|
||||||
) : OrderedPhases => {
|
|
||||||
// get the current phase and the next phases;
|
|
||||||
const currentPhase = phases[currentIndex];
|
|
||||||
const nextPhaseNodes = getOutgoers(currentPhase,phaseNodes,edges);
|
|
||||||
const nextNodes = getOutgoers(currentPhase,nodes, edges);
|
|
||||||
|
|
||||||
// handles adding of the next phase to the chain, and error handle if an invalid state is received
|
|
||||||
if (nextPhaseNodes.length === 1 && nextNodes.length === 1) {
|
|
||||||
connections.set(currentPhase.id, nextPhaseNodes[0].id);
|
|
||||||
return nextPhase(phases.push(nextPhaseNodes[0] as PhaseNode) - 1, {phaseNodes: phases, connections: connections});
|
|
||||||
} else {
|
|
||||||
// handle erroneous states
|
|
||||||
if (nextNodes.length === 0){
|
|
||||||
throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" doesn't have any outgoing connections`);
|
|
||||||
} else {
|
|
||||||
if (nextNodes.length > 1) {
|
|
||||||
throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" connects to too many targets`);
|
|
||||||
} else {
|
|
||||||
if (nextNodes[0].type === "end"){
|
|
||||||
connections.set(currentPhase.id, "end");
|
|
||||||
// returns the final output of the function
|
|
||||||
return { phaseNodes: phases, connections: connections};
|
|
||||||
} else {
|
|
||||||
throw new Error(`| INVALID PROGRAM | the node "${nextNodes[0].id}" that "${currentPhase.id}" connects to is not a phase or end node`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// initializes the Map describing the connections between phase nodes
|
|
||||||
// we need this Map to make sure we preserve this information,
|
|
||||||
// so we don't need to do checks on the entire set of edges in further stages of the reduction algorithm
|
|
||||||
const connections : Map<string, string> = new Map();
|
|
||||||
|
|
||||||
// returns an empty list if no phase nodes are present, otherwise returns an ordered list of phaseNodes
|
|
||||||
if (firstPhaseNode.length > 0) {
|
|
||||||
return nextPhase(0, {phaseNodes: [firstPhaseNode[0] as PhaseNode], connections: connections})
|
|
||||||
} else { return {phaseNodes: [], connections: connections} }
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import type {Edge} from "@xyflow/react";
|
|
||||||
import type {AppNode, GoalNode, NormNode, PhaseNode} from "./VisProgTypes.tsx";
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* defines how a norm is represented in the simplified behavior program
|
|
||||||
*/
|
|
||||||
export type NormData = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* defines how a goal is represented in the simplified behavior program
|
|
||||||
*/
|
|
||||||
export type GoalData = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* definition of a PhaseData object, it contains all phaseData that is relevant
|
|
||||||
* for further processing and execution of a phase.
|
|
||||||
*/
|
|
||||||
export type PhaseData = {
|
|
||||||
norms: NormData[];
|
|
||||||
goals: GoalData[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Describes a single phase within the simplified representation of a behavior program,
|
|
||||||
*
|
|
||||||
* Contains:
|
|
||||||
* - the id of the described phase,
|
|
||||||
* - the name of the described phase,
|
|
||||||
* - the id of the next phase in the user defined behavior program
|
|
||||||
* - the data property of the described phase node
|
|
||||||
*
|
|
||||||
* @NOTE at the moment the type definitions do not support branching programs,
|
|
||||||
* if branching of phases is to be supported in the future, the type definition for Phase has to be updated
|
|
||||||
*/
|
|
||||||
export type Phase = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
nextPhaseId: string;
|
|
||||||
phaseData: PhaseData;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Describes a simplified behavior program as a list of Phase objects
|
|
||||||
*/
|
|
||||||
export type BehaviorProgram = Phase[];
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type NormReducer = (node: NormNode) => NormData;
|
|
||||||
export type GoalReducer = (node: GoalNode) => GoalData;
|
|
||||||
export type PhaseReducer = (
|
|
||||||
preparedPhase: PreparedPhase,
|
|
||||||
normReducer: NormReducer,
|
|
||||||
goalReducer: GoalReducer
|
|
||||||
) => Phase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* contains:
|
|
||||||
*
|
|
||||||
* - list of phases, sorted based on position in chain between the start and end node
|
|
||||||
* - a dictionary containing all outgoing connections,
|
|
||||||
* to other phase or end nodes, for each phase node uses the id of the source node as key
|
|
||||||
* and the id of the target node as value
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export type OrderedPhases = {
|
|
||||||
phaseNodes: PhaseNode[];
|
|
||||||
connections: Map<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single prepared phase,
|
|
||||||
* contains:
|
|
||||||
* - the described phaseNode,
|
|
||||||
* - the id of the next phaseNode or "end" for the end node
|
|
||||||
* - a list of the normNodes that are connected to the described phase
|
|
||||||
* - a list of the goalNodes that are connected to the described phase
|
|
||||||
*/
|
|
||||||
export type PreparedPhase = {
|
|
||||||
phaseNode: PhaseNode;
|
|
||||||
nextPhaseId: string;
|
|
||||||
connectedNorms: NormNode[];
|
|
||||||
connectedGoals: GoalNode[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* a list of PreparedPhase objects,
|
|
||||||
* describes the preprocessed state of a program,
|
|
||||||
* before the contents of the node
|
|
||||||
*/
|
|
||||||
export type PreparedGraph = PreparedPhase[];
|
|
||||||
|
|
||||||
export type GraphPreprocessor = (nodes: AppNode[], edges: Edge[]) => PreparedGraph;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
122
src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts
Normal file
122
src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import {type Connection} from "@xyflow/react";
|
||||||
|
import {useEffect} from "react";
|
||||||
|
import useFlowStore from "./VisProgStores.tsx";
|
||||||
|
|
||||||
|
export type ConnectionContext = {
|
||||||
|
connectionCount: number;
|
||||||
|
source: {
|
||||||
|
id: string;
|
||||||
|
handleId: string;
|
||||||
|
}
|
||||||
|
target: {
|
||||||
|
id: string;
|
||||||
|
handleId: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HandleRule = (
|
||||||
|
connection: Connection,
|
||||||
|
context: ConnectionContext
|
||||||
|
) => RuleResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A RuleResult describes the outcome of validating a HandleRule
|
||||||
|
*
|
||||||
|
* if a rule is not satisfied, the RuleResult includes a message that is used inside a tooltip
|
||||||
|
* that tells the user why their attempted connection is not possible
|
||||||
|
*/
|
||||||
|
export type RuleResult =
|
||||||
|
| { isSatisfied: true }
|
||||||
|
| { isSatisfied: false, message: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* default RuleResults, can be used to create more readable handleRule definitions
|
||||||
|
*/
|
||||||
|
export const ruleResult = {
|
||||||
|
satisfied: { isSatisfied: true } as RuleResult,
|
||||||
|
unknownError: {isSatisfied: false, message: "Unknown Error" } as RuleResult,
|
||||||
|
notSatisfied: (message: string) : RuleResult => { return {isSatisfied: false, message: message } }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const evaluateRules = (
|
||||||
|
rules: HandleRule[],
|
||||||
|
connection: Connection,
|
||||||
|
context: ConnectionContext
|
||||||
|
) : RuleResult => {
|
||||||
|
// evaluate the rules and check if there is at least one unsatisfied rule
|
||||||
|
const failedRule = rules
|
||||||
|
.map(rule => rule(connection, context))
|
||||||
|
.find(result => !result.isSatisfied);
|
||||||
|
|
||||||
|
return failedRule ? ruleResult.notSatisfied(failedRule.message) : ruleResult.satisfied;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* !DOCUMENTATION NOT FINISHED!
|
||||||
|
*
|
||||||
|
* - The output is a single RuleResult, meaning we only show one error message.
|
||||||
|
* Error messages are prioritised by listOrder; Thus, if multiple HandleRules evaluate to false,
|
||||||
|
* we only send the error message of the first failed rule in the target's registered list of rules.
|
||||||
|
*
|
||||||
|
* @param {string} nodeId
|
||||||
|
* @param {string} handleId
|
||||||
|
* @param type
|
||||||
|
* @param {HandleRule[]} rules
|
||||||
|
* @returns {(c: Connection) => RuleResult} a function that validates an attempted connection
|
||||||
|
*/
|
||||||
|
export function useHandleRules(
|
||||||
|
nodeId: string,
|
||||||
|
handleId: string,
|
||||||
|
type: "source" | "target",
|
||||||
|
rules: HandleRule[],
|
||||||
|
) : (c: Connection) => RuleResult {
|
||||||
|
const edges = useFlowStore.getState().edges;
|
||||||
|
const registerRules = useFlowStore((state) => state.registerRules);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerRules(nodeId, handleId, rules);
|
||||||
|
// the following eslint disable is required as it wants us to use all possible dependencies for the useEffect statement,
|
||||||
|
// however this would result in an infinite loop because it would change one of its own dependencies
|
||||||
|
// so we only use those dependencies that we don't change ourselves
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [handleId, nodeId, registerRules]);
|
||||||
|
|
||||||
|
return (connection: Connection) => {
|
||||||
|
// inside this function we consider the target to be the target of the isValidConnection event
|
||||||
|
// and not the target in the actual connection
|
||||||
|
const { target, targetHandle } = type === "source"
|
||||||
|
? connection
|
||||||
|
: { target: connection.source, targetHandle: connection.sourceHandle };
|
||||||
|
|
||||||
|
if (!targetHandle) {throw new Error("No target handle was provided");}
|
||||||
|
|
||||||
|
const targetConnections = edges.filter(edge => edge.target === target && edge.targetHandle === targetHandle);
|
||||||
|
|
||||||
|
|
||||||
|
// we construct the connectionContext
|
||||||
|
const context: ConnectionContext = {
|
||||||
|
connectionCount: targetConnections.length,
|
||||||
|
source: {id: nodeId, handleId: handleId},
|
||||||
|
target: {id: target, handleId: targetHandle},
|
||||||
|
};
|
||||||
|
const targetRules = useFlowStore.getState().getTargetRules(target, targetHandle);
|
||||||
|
|
||||||
|
// finally we return a function that evaluates all rules using the created context
|
||||||
|
return evaluateRules(targetRules, connection, context);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateConnectionWithRules(
|
||||||
|
connection: Connection,
|
||||||
|
context: ConnectionContext
|
||||||
|
): RuleResult {
|
||||||
|
const rules = useFlowStore.getState().getTargetRules(
|
||||||
|
connection.target!,
|
||||||
|
connection.targetHandle!
|
||||||
|
);
|
||||||
|
|
||||||
|
return evaluateRules(rules,connection, context);
|
||||||
|
}
|
||||||
46
src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts
Normal file
46
src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
type HandleRule,
|
||||||
|
ruleResult
|
||||||
|
} from "./HandleRuleLogic.ts";
|
||||||
|
import useFlowStore from "./VisProgStores.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* this specifies what types of nodes can make a connection to a handle that uses this rule
|
||||||
|
*/
|
||||||
|
export function allowOnlyConnectionsFromType(nodeTypes: string[]) : HandleRule {
|
||||||
|
return ((_, {source}) => {
|
||||||
|
const sourceType = useFlowStore.getState().nodes.find(node => node.id === source.id)!.type!;
|
||||||
|
return nodeTypes.find(type => sourceType === type)
|
||||||
|
? ruleResult.satisfied
|
||||||
|
: ruleResult.notSatisfied(`the target doesn't allow connections from nodes with type: ${sourceType}`);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* similar to allowOnlyConnectionsFromType,
|
||||||
|
* this is a more specific variant that allows you to restrict connections to specific handles on each nodeType
|
||||||
|
*/
|
||||||
|
//
|
||||||
|
export function allowOnlyConnectionsFromHandle(handles: {nodeType: string, handleId: string}[]) : HandleRule {
|
||||||
|
return ((_, {source}) => {
|
||||||
|
const sourceNode = useFlowStore.getState().nodes.find(node => node.id === source.id)!;
|
||||||
|
return handles.find(handle => sourceNode.type === handle.nodeType && source.handleId === handle.handleId)
|
||||||
|
? ruleResult.satisfied
|
||||||
|
: ruleResult.notSatisfied(`the target doesn't allow connections from nodes with type: ${sourceNode.type}`);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This rule prevents a node from making a connection between its own handles
|
||||||
|
*/
|
||||||
|
export const noSelfConnections : HandleRule =
|
||||||
|
(connection, _) => {
|
||||||
|
return connection.source !== connection.target
|
||||||
|
? ruleResult.satisfied
|
||||||
|
: ruleResult.notSatisfied("nodes are not allowed to connect to themselves");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
220
src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts
Normal file
220
src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import EndNode, {
|
||||||
|
EndConnectionTarget,
|
||||||
|
EndConnectionSource,
|
||||||
|
EndDisconnectionTarget,
|
||||||
|
EndDisconnectionSource,
|
||||||
|
EndReduce,
|
||||||
|
EndTooltip
|
||||||
|
} from "./nodes/EndNode";
|
||||||
|
import { EndNodeDefaults } from "./nodes/EndNode.default";
|
||||||
|
import StartNode, {
|
||||||
|
StartConnectionTarget,
|
||||||
|
StartConnectionSource,
|
||||||
|
StartDisconnectionTarget,
|
||||||
|
StartDisconnectionSource,
|
||||||
|
StartReduce,
|
||||||
|
StartTooltip
|
||||||
|
} from "./nodes/StartNode";
|
||||||
|
import { StartNodeDefaults } from "./nodes/StartNode.default";
|
||||||
|
import PhaseNode, {
|
||||||
|
PhaseConnectionTarget,
|
||||||
|
PhaseConnectionSource,
|
||||||
|
PhaseDisconnectionTarget,
|
||||||
|
PhaseDisconnectionSource,
|
||||||
|
PhaseReduce,
|
||||||
|
PhaseTooltip
|
||||||
|
} from "./nodes/PhaseNode";
|
||||||
|
import { PhaseNodeDefaults } from "./nodes/PhaseNode.default";
|
||||||
|
import NormNode, {
|
||||||
|
NormConnectionTarget,
|
||||||
|
NormConnectionSource,
|
||||||
|
NormDisconnectionTarget,
|
||||||
|
NormDisconnectionSource,
|
||||||
|
NormReduce,
|
||||||
|
NormTooltip
|
||||||
|
} from "./nodes/NormNode";
|
||||||
|
import { NormNodeDefaults } from "./nodes/NormNode.default";
|
||||||
|
import GoalNode, {
|
||||||
|
GoalConnectionTarget,
|
||||||
|
GoalConnectionSource,
|
||||||
|
GoalDisconnectionTarget,
|
||||||
|
GoalDisconnectionSource,
|
||||||
|
GoalReduce,
|
||||||
|
GoalTooltip
|
||||||
|
} from "./nodes/GoalNode";
|
||||||
|
import { GoalNodeDefaults } from "./nodes/GoalNode.default";
|
||||||
|
import TriggerNode, {
|
||||||
|
TriggerConnectionTarget,
|
||||||
|
TriggerConnectionSource,
|
||||||
|
TriggerDisconnectionTarget,
|
||||||
|
TriggerDisconnectionSource,
|
||||||
|
TriggerReduce,
|
||||||
|
TriggerTooltip
|
||||||
|
} from "./nodes/TriggerNode";
|
||||||
|
import { TriggerNodeDefaults } from "./nodes/TriggerNode.default";
|
||||||
|
import InferredBeliefNode, {
|
||||||
|
InferredBeliefConnectionTarget,
|
||||||
|
InferredBeliefConnectionSource,
|
||||||
|
InferredBeliefDisconnectionTarget,
|
||||||
|
InferredBeliefDisconnectionSource,
|
||||||
|
InferredBeliefReduce, InferredBeliefTooltip
|
||||||
|
} from "./nodes/InferredBeliefNode";
|
||||||
|
import { InferredBeliefNodeDefaults } from "./nodes/InferredBeliefNode.default";
|
||||||
|
import BasicBeliefNode, {
|
||||||
|
BasicBeliefConnectionSource,
|
||||||
|
BasicBeliefConnectionTarget,
|
||||||
|
BasicBeliefDisconnectionSource,
|
||||||
|
BasicBeliefDisconnectionTarget,
|
||||||
|
BasicBeliefReduce
|
||||||
|
,
|
||||||
|
BasicBeliefTooltip
|
||||||
|
} from "./nodes/BasicBeliefNode.tsx";
|
||||||
|
import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registered node types in the visual programming system.
|
||||||
|
*
|
||||||
|
* Key: the node type string used in the flow graph.
|
||||||
|
* Value: the corresponding React component for rendering the node.
|
||||||
|
*/
|
||||||
|
export const NodeTypes = {
|
||||||
|
start: StartNode,
|
||||||
|
end: EndNode,
|
||||||
|
phase: PhaseNode,
|
||||||
|
norm: NormNode,
|
||||||
|
goal: GoalNode,
|
||||||
|
trigger: TriggerNode,
|
||||||
|
basic_belief: BasicBeliefNode,
|
||||||
|
inferred_belief: InferredBeliefNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default data and settings for each node type.
|
||||||
|
* These are defined in the <node>.default.ts files.
|
||||||
|
* These defaults are used when a new node is created to initialize its properties.
|
||||||
|
*/
|
||||||
|
export const NodeDefaults = {
|
||||||
|
start: StartNodeDefaults,
|
||||||
|
end: EndNodeDefaults,
|
||||||
|
phase: PhaseNodeDefaults,
|
||||||
|
norm: NormNodeDefaults,
|
||||||
|
goal: GoalNodeDefaults,
|
||||||
|
trigger: TriggerNodeDefaults,
|
||||||
|
basic_belief: BasicBeliefNodeDefaults,
|
||||||
|
inferred_belief: InferredBeliefNodeDefaults,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce functions for each node type.
|
||||||
|
*
|
||||||
|
* A reduce function extracts the relevant data from a node and its children.
|
||||||
|
* Used during graph evaluation or export.
|
||||||
|
*/
|
||||||
|
export const NodeReduces = {
|
||||||
|
start: StartReduce,
|
||||||
|
end: EndReduce,
|
||||||
|
phase: PhaseReduce,
|
||||||
|
norm: NormReduce,
|
||||||
|
goal: GoalReduce,
|
||||||
|
trigger: TriggerReduce,
|
||||||
|
basic_belief: BasicBeliefReduce,
|
||||||
|
inferred_belief: InferredBeliefReduce,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection functions for each node type.
|
||||||
|
*
|
||||||
|
* These functions define any additional actions a node may perform
|
||||||
|
* when a new connection is made
|
||||||
|
*/
|
||||||
|
export const NodeConnections = {
|
||||||
|
Targets: {
|
||||||
|
start: StartConnectionTarget,
|
||||||
|
end: EndConnectionTarget,
|
||||||
|
phase: PhaseConnectionTarget,
|
||||||
|
norm: NormConnectionTarget,
|
||||||
|
goal: GoalConnectionTarget,
|
||||||
|
trigger: TriggerConnectionTarget,
|
||||||
|
basic_belief: BasicBeliefConnectionTarget,
|
||||||
|
inferred_belief: InferredBeliefConnectionTarget,
|
||||||
|
},
|
||||||
|
Sources: {
|
||||||
|
start: StartConnectionSource,
|
||||||
|
end: EndConnectionSource,
|
||||||
|
phase: PhaseConnectionSource,
|
||||||
|
norm: NormConnectionSource,
|
||||||
|
goal: GoalConnectionSource,
|
||||||
|
trigger: TriggerConnectionSource,
|
||||||
|
basic_belief: BasicBeliefConnectionSource,
|
||||||
|
inferred_belief: InferredBeliefConnectionSource,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnection functions for each node type.
|
||||||
|
*
|
||||||
|
* These functions define any additional actions a node may perform
|
||||||
|
* when a connection is disconnected
|
||||||
|
*/
|
||||||
|
export const NodeDisconnections = {
|
||||||
|
Targets: {
|
||||||
|
start: StartDisconnectionTarget,
|
||||||
|
end: EndDisconnectionTarget,
|
||||||
|
phase: PhaseDisconnectionTarget,
|
||||||
|
norm: NormDisconnectionTarget,
|
||||||
|
goal: GoalDisconnectionTarget,
|
||||||
|
trigger: TriggerDisconnectionTarget,
|
||||||
|
basic_belief: BasicBeliefDisconnectionTarget,
|
||||||
|
inferred_belief: InferredBeliefDisconnectionTarget,
|
||||||
|
},
|
||||||
|
Sources: {
|
||||||
|
start: StartDisconnectionSource,
|
||||||
|
end: EndDisconnectionSource,
|
||||||
|
phase: PhaseDisconnectionSource,
|
||||||
|
norm: NormDisconnectionSource,
|
||||||
|
goal: GoalDisconnectionSource,
|
||||||
|
trigger: TriggerDisconnectionSource,
|
||||||
|
basic_belief: BasicBeliefDisconnectionSource,
|
||||||
|
inferred_belief: InferredBeliefDisconnectionSource,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines whether a node type can be deleted.
|
||||||
|
*
|
||||||
|
* Returns a function per node type. Nodes not explicitly listed are deletable by default.
|
||||||
|
*/
|
||||||
|
export const NodeDeletes = {
|
||||||
|
start: () => false,
|
||||||
|
end: () => false,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines which node types are considered variables in a phase node.
|
||||||
|
*
|
||||||
|
* Any node type not listed here is automatically treated as part of a phase.
|
||||||
|
* This allows the system to dynamically group nodes under a phase node.
|
||||||
|
*/
|
||||||
|
export const NodesInPhase = {
|
||||||
|
start: () => false,
|
||||||
|
end: () => false,
|
||||||
|
phase: () => false,
|
||||||
|
basic_belief: () => false,
|
||||||
|
inferred_belief: () => false,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects the tooltips for all nodeTypes so they can be accessed by the tooltip component
|
||||||
|
*/
|
||||||
|
export const NodeTooltips = {
|
||||||
|
start: StartTooltip,
|
||||||
|
end: EndTooltip,
|
||||||
|
phase: PhaseTooltip,
|
||||||
|
norm: NormTooltip,
|
||||||
|
goal: GoalTooltip,
|
||||||
|
trigger: TriggerTooltip,
|
||||||
|
basic_belief: BasicBeliefTooltip,
|
||||||
|
inferred_belief: InferredBeliefTooltip,
|
||||||
|
}
|
||||||
@@ -1,116 +1,367 @@
|
|||||||
import {create} from 'zustand';
|
import { create } from 'zustand';
|
||||||
import {
|
import {
|
||||||
applyNodeChanges,
|
applyNodeChanges,
|
||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
addEdge,
|
addEdge,
|
||||||
reconnectEdge, type Edge, type Connection
|
reconnectEdge,
|
||||||
|
type Node,
|
||||||
|
type Edge,
|
||||||
|
type XYPosition,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import {type ConnectionContext, validateConnectionWithRules} from "./HandleRuleLogic.ts";
|
||||||
|
import {editorWarningRegistry} from "./components/EditorWarnings.tsx";
|
||||||
|
import type { FlowState } from './VisProgTypes';
|
||||||
|
import {
|
||||||
|
NodeDefaults,
|
||||||
|
NodeConnections as NodeCs,
|
||||||
|
NodeDisconnections as NodeDs,
|
||||||
|
NodeDeletes
|
||||||
|
} from './NodeRegistry';
|
||||||
|
import { UndoRedo } from "./EditorUndoRedo.ts";
|
||||||
|
|
||||||
import {type FlowState} from './VisProgTypes.tsx';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* contains the nodes that are created when the editor is loaded,
|
* A Function to create a new node with the correct default data and properties.
|
||||||
* should contain at least a start and an end node
|
*
|
||||||
|
* @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 = [
|
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable?: boolean) {
|
||||||
{
|
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
|
||||||
id: 'start',
|
return {
|
||||||
type: 'start',
|
id,
|
||||||
position: {x: 0, y: 0},
|
type,
|
||||||
data: {label: 'start'}
|
position,
|
||||||
},
|
deletable,
|
||||||
{
|
data: {
|
||||||
id: 'phase-1',
|
...JSON.parse(JSON.stringify(defaultData)),
|
||||||
type: 'phase',
|
...data,
|
||||||
position: {x: 0, y: 150},
|
},
|
||||||
data: {label: 'Generic Phase', number: 1},
|
}
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: 'end',
|
//* Initial nodes, created by using createNode. */
|
||||||
type: 'end',
|
// Start and End don't need to apply the UUID, since they are technically never compiled into a program.
|
||||||
position: {x: 0, y: 300},
|
const startNode = createNode('start', 'start', {x: 110, y: 100}, {label: "Start"}, false)
|
||||||
data: {label: 'End'}
|
const endNode = createNode('end', 'end', {x: 590, y: 100}, {label: "End"}, false)
|
||||||
}
|
const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:235, y:100}, {label: "Phase 1", children : [], isFirstPhase: false, nextPhaseId: null})
|
||||||
];
|
|
||||||
|
const initialNodes : Node[] = [startNode, endNode, initialPhaseNode];
|
||||||
|
|
||||||
|
// Initial edges, leave empty as setting initial edges...
|
||||||
|
// ...breaks logic that is dependent on connection events
|
||||||
|
const initialEdges: Edge[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* contains the initial edges that are created when the editor is loaded
|
* 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 initialEdges = [
|
const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
|
||||||
{
|
nodes: initialNodes,
|
||||||
id: 'start-phase-1',
|
edges: initialEdges,
|
||||||
source: 'start',
|
edgeReconnectSuccessful: true,
|
||||||
target: 'phase-1',
|
scrollable: true,
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1-end',
|
|
||||||
source: 'phase-1',
|
|
||||||
target: 'end',
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The useFlowStore hook contains the implementation for editor functionality and state
|
* handles changing the scrollable state of the editor,
|
||||||
* we can use this inside our editor component to access the current state
|
* this is used to control if scrolling is captured by the editor
|
||||||
* and use any implemented functionality
|
* or if it's available to other components within the reactFlowProvider
|
||||||
*/
|
* @param {boolean} val - the desired state
|
||||||
const useFlowStore = create<FlowState>((set, get) => ({
|
*/
|
||||||
nodes: initialNodes,
|
setScrollable: (val) => set({scrollable: val}),
|
||||||
edges: initialEdges,
|
|
||||||
edgeReconnectSuccessful: true,
|
/**
|
||||||
onNodesChange: (changes) => {
|
* Handles changes to nodes triggered by ReactFlow.
|
||||||
set({
|
*/
|
||||||
nodes: applyNodeChanges(changes, get().nodes)
|
onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}),
|
||||||
});
|
|
||||||
},
|
onNodesDelete: (nodes) => nodes.forEach((_node) => {
|
||||||
onEdgesChange: (changes) => {
|
return;
|
||||||
set({
|
|
||||||
edges: applyEdgeChanges(changes, get().edges)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// handles connection of newly created edges
|
|
||||||
onConnect: (connection) => {
|
|
||||||
set({
|
|
||||||
edges: addEdge(connection, get().edges)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// handles attempted reconnections of a previously disconnected edge
|
|
||||||
onReconnect: (oldEdge: Edge, newConnection: Connection) => {
|
|
||||||
get().edgeReconnectSuccessful = true;
|
|
||||||
set({
|
|
||||||
edges: reconnectEdge(oldEdge, newConnection, get().edges)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// Handles initiation of reconnection of edges that are manually disconnected from a node
|
|
||||||
onReconnectStart: () => {
|
|
||||||
set({
|
|
||||||
edgeReconnectSuccessful: false
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// Drops the edge from the set of edges, removing it from the flow, if no successful reconnection occurred
|
|
||||||
onReconnectEnd: (_: unknown, edge: { id: string; }) => {
|
|
||||||
if (!get().edgeReconnectSuccessful) {
|
|
||||||
set({
|
|
||||||
edges: get().edges.filter((e) => e.id !== edge.id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
set({
|
|
||||||
edgeReconnectSuccessful: true
|
|
||||||
});
|
|
||||||
},
|
|
||||||
deleteNode: (nodeId: string) => {
|
|
||||||
set({
|
|
||||||
nodes: get().nodes.filter((n) => n.id !== nodeId),
|
|
||||||
edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId)
|
|
||||||
});
|
|
||||||
},
|
|
||||||
setNodes: (nodes) => {
|
|
||||||
set({nodes});
|
|
||||||
},
|
|
||||||
setEdges: (edges) => {
|
|
||||||
set({edges});
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
|
||||||
|
function createContext(
|
||||||
|
source: {id: string, handleId: string},
|
||||||
|
target: {id: string, handleId: string}
|
||||||
|
) : ConnectionContext {
|
||||||
|
const edges = get().edges;
|
||||||
|
const targetConnections = edges.filter(edge => edge.target === target.id && edge.targetHandle === target.handleId).length
|
||||||
|
return {
|
||||||
|
connectionCount: targetConnections,
|
||||||
|
source: source,
|
||||||
|
target: target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// connection validation
|
||||||
|
const context: ConnectionContext = oldEdge.source === newConnection.source
|
||||||
|
? createContext({id: newConnection.source, handleId: newConnection.sourceHandle!}, {id: newConnection.target, handleId: newConnection.targetHandle!})
|
||||||
|
: createContext({id: newConnection.target, handleId: newConnection.targetHandle!}, {id: newConnection.source, handleId: newConnection.sourceHandle!});
|
||||||
|
|
||||||
|
const result = validateConnectionWithRules(
|
||||||
|
newConnection,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.isSatisfied) {
|
||||||
|
set({
|
||||||
|
edges: get().edges.map(e =>
|
||||||
|
e.id === oldEdge.id ? oldEdge : e
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// further reconnect logic
|
||||||
|
set({ edgeReconnectSuccessful: true });
|
||||||
|
set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) });
|
||||||
|
|
||||||
|
// We make sure to perform any required data updates on the newly reconnected nodes
|
||||||
|
const nodes = get().nodes;
|
||||||
|
|
||||||
|
const oldSourceNode = nodes.find((n) => n.id == oldEdge.source)!;
|
||||||
|
const oldTargetNode = nodes.find((n) => n.id == oldEdge.target)!;
|
||||||
|
const newSourceNode = nodes.find((n) => n.id == newConnection.source)!;
|
||||||
|
const newTargetNode = nodes.find((n) => n.id == newConnection.target)!;
|
||||||
|
|
||||||
|
if (oldSourceNode === newSourceNode && oldTargetNode === newTargetNode) return;
|
||||||
|
|
||||||
|
NodeCs.Sources[newSourceNode.type as keyof typeof NodeCs.Sources](newSourceNode, newConnection.target);
|
||||||
|
NodeCs.Targets[newTargetNode.type as keyof typeof NodeCs.Targets](newTargetNode, newConnection.source);
|
||||||
|
|
||||||
|
NodeDs.Sources[oldSourceNode.type as keyof typeof NodeDs.Sources](oldSourceNode, oldEdge.target);
|
||||||
|
NodeDs.Targets[oldTargetNode.type as keyof typeof NodeDs.Targets](oldTargetNode, oldEdge.source);
|
||||||
|
},
|
||||||
|
|
||||||
|
onReconnectStart: () => {
|
||||||
|
get().pushSnapshot();
|
||||||
|
set({ edgeReconnectSuccessful: false })
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* handles potential dropping (deleting) of an edge
|
||||||
|
* if it is not reconnected to a node after detaching it
|
||||||
|
*
|
||||||
|
* @param _evt - the event
|
||||||
|
* @param edge - the described edge
|
||||||
|
*/
|
||||||
|
onReconnectEnd: (_evt, edge) => {
|
||||||
|
if (!get().edgeReconnectSuccessful) {
|
||||||
|
// delete the edge from the flowState
|
||||||
|
set({ edges: get().edges.filter((e) => e.id !== edge.id) });
|
||||||
|
|
||||||
|
// update node data to reflect the dropped edge
|
||||||
|
const nodes = get().nodes;
|
||||||
|
|
||||||
|
const sourceNode = nodes.find((n) => n.id == edge.source)!;
|
||||||
|
const targetNode = nodes.find((n) => n.id == edge.target)!;
|
||||||
|
|
||||||
|
NodeDs.Sources[sourceNode.type as keyof typeof NodeDs.Sources](sourceNode, edge.target);
|
||||||
|
NodeDs.Targets[targetNode.type as keyof typeof NodeDs.Targets](targetNode, edge.source);
|
||||||
|
}
|
||||||
|
set({ edgeReconnectSuccessful: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a node by ID, respecting NodeDeletes rules.
|
||||||
|
* Also removes all edges connected to that node.
|
||||||
|
*/
|
||||||
|
deleteNode: (nodeId, deleteElements) => {
|
||||||
|
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()) {
|
||||||
|
if (deleteElements){
|
||||||
|
deleteElements({
|
||||||
|
nodes: get().nodes.filter((n) => n.id === nodeId),
|
||||||
|
edges: get().edges.filter((e) => e.source !== nodeId && e.target === nodeId)}
|
||||||
|
).then(() => {
|
||||||
|
get().unregisterNodeRules(nodeId);
|
||||||
|
get().unregisterWarningsForId(nodeId);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
set({
|
||||||
|
nodes: get().nodes.filter((n) => n.id !== nodeId),
|
||||||
|
edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the entire nodes array in the store.
|
||||||
|
*/
|
||||||
|
setNodes: (nodes) => set({ nodes }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the entire edges array in the store.
|
||||||
|
*/
|
||||||
|
setEdges: (edges) => set({ edges }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the data of a node by merging new data with existing data.
|
||||||
|
*/
|
||||||
|
updateNodeData: (nodeId, data) => {
|
||||||
|
get().pushSnapshot();
|
||||||
|
set({
|
||||||
|
nodes: get().nodes.map((node) => {
|
||||||
|
if (node.id === nodeId) {
|
||||||
|
node = { ...node, data: { ...node.data, ...data }};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new node to the flow store.
|
||||||
|
*/
|
||||||
|
addNode: (node: Node) => {
|
||||||
|
get().pushSnapshot();
|
||||||
|
set({ nodes: [...get().nodes, node] });
|
||||||
|
},
|
||||||
|
|
||||||
|
// undo redo default values
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
isBatchAction: false,
|
||||||
|
|
||||||
|
// handleRuleRegistry definitions
|
||||||
|
/**
|
||||||
|
* stores registered rules for handle connection validation
|
||||||
|
*/
|
||||||
|
ruleRegistry: new Map(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gets the rules registered by that handle described by the given node and handle ids
|
||||||
|
*
|
||||||
|
* @param {string} targetNodeId
|
||||||
|
* @param {string} targetHandleId
|
||||||
|
* @returns {HandleRule[]}
|
||||||
|
*/
|
||||||
|
getTargetRules: (targetNodeId, targetHandleId) => {
|
||||||
|
const key = `${targetNodeId}:${targetHandleId}`;
|
||||||
|
const rules = get().ruleRegistry.get(key);
|
||||||
|
|
||||||
|
// helper function that handles a situation where no rules were registered
|
||||||
|
const missingRulesResponse = () => {
|
||||||
|
console.warn(
|
||||||
|
`No rules were registered for the following handle "${key}"!
|
||||||
|
returning and empty handleRule[] to avoid crashing`);
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules
|
||||||
|
? rules
|
||||||
|
: missingRulesResponse()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* registers a handle's connection rules
|
||||||
|
*
|
||||||
|
* @param {string} nodeId
|
||||||
|
* @param {string} handleId
|
||||||
|
* @param {HandleRule[]} rules
|
||||||
|
*/
|
||||||
|
registerRules: (nodeId, handleId, rules) => {
|
||||||
|
const registry = get().ruleRegistry;
|
||||||
|
registry.set(`${nodeId}:${handleId}`, rules);
|
||||||
|
set({ ruleRegistry: registry }) ;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* unregisters a handles connection rules
|
||||||
|
*
|
||||||
|
* @param {string} nodeId
|
||||||
|
* @param {string} handleId
|
||||||
|
*/
|
||||||
|
unregisterHandleRules: (nodeId, handleId) => {
|
||||||
|
set( () => {
|
||||||
|
const registry = get().ruleRegistry;
|
||||||
|
registry.delete(`${nodeId}:${handleId}`);
|
||||||
|
return { ruleRegistry: registry };
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* unregisters connection rules for all handles on the given node
|
||||||
|
* used for cleaning up rules on node deletion
|
||||||
|
*
|
||||||
|
* @param {string} nodeId
|
||||||
|
*/
|
||||||
|
unregisterNodeRules: (nodeId) => {
|
||||||
|
set(() => {
|
||||||
|
const registry = get().ruleRegistry;
|
||||||
|
registry.forEach((_,key) => {
|
||||||
|
if (key.startsWith(`${nodeId}:`)) registry.delete(key)
|
||||||
|
})
|
||||||
|
return { ruleRegistry: registry };
|
||||||
|
})
|
||||||
|
},
|
||||||
|
...editorWarningRegistry(get, set),
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
export default useFlowStore;
|
|
||||||
|
|
||||||
|
export default useFlowStore;
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,139 @@
|
|||||||
import {
|
// VisProgTypes.ts
|
||||||
type Edge,
|
import type {
|
||||||
type Node,
|
Edge,
|
||||||
type OnNodesChange,
|
OnNodesChange,
|
||||||
type OnEdgesChange,
|
OnEdgesChange,
|
||||||
type OnConnect,
|
OnConnect,
|
||||||
type OnReconnect,
|
OnReconnect,
|
||||||
|
Node,
|
||||||
|
OnEdgesDelete,
|
||||||
|
OnNodesDelete, DeleteElementsOptions
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
|
import type {EditorWarningRegistry} from "./components/EditorWarnings.tsx";
|
||||||
|
import type {HandleRule} from "./HandleRuleLogic.ts";
|
||||||
type defaultNodeData = {
|
import type { NodeTypes } from './NodeRegistry';
|
||||||
label: string;
|
import type {FlowSnapshot} from "./EditorUndoRedo.ts";
|
||||||
};
|
|
||||||
|
|
||||||
export type StartNode = Node<defaultNodeData, 'start'>;
|
|
||||||
export type EndNode = Node<defaultNodeData, 'end'>;
|
|
||||||
export type GoalNode = Node<defaultNodeData & { value: string; }, 'goal'>;
|
|
||||||
export type NormNode = Node<defaultNodeData & { value: string; }, 'norm'>;
|
|
||||||
export type PhaseNode = Node<defaultNodeData & { number: number; }, 'phase'>;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a type meant to house different node types, currently not used
|
* Type representing all registered node types.
|
||||||
* but will allow us to more clearly define nodeTypes when we implement
|
* This corresponds to the keys of NodeTypes in NodeRegistry.
|
||||||
* computation of the Graph inside the ReactFlow editor
|
|
||||||
*/
|
*/
|
||||||
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 = {
|
export type FlowState = {
|
||||||
nodes: AppNode[];
|
nodes: Node[];
|
||||||
edges: Edge[];
|
edges: Edge[];
|
||||||
edgeReconnectSuccessful: boolean;
|
edgeReconnectSuccessful: boolean;
|
||||||
|
scrollable: boolean;
|
||||||
|
|
||||||
|
/** Handler for managing scrollable state */
|
||||||
|
setScrollable: (value: boolean) => void;
|
||||||
|
|
||||||
|
/** Handler for changes to nodes triggered by ReactFlow */
|
||||||
onNodesChange: OnNodesChange;
|
onNodesChange: OnNodesChange;
|
||||||
|
|
||||||
|
onNodesDelete: OnNodesDelete;
|
||||||
|
|
||||||
|
onEdgesDelete: OnEdgesDelete;
|
||||||
|
|
||||||
|
/** Handler for changes to edges triggered by ReactFlow */
|
||||||
onEdgesChange: OnEdgesChange;
|
onEdgesChange: OnEdgesChange;
|
||||||
|
|
||||||
|
/** Handler for creating a new connection between nodes */
|
||||||
onConnect: OnConnect;
|
onConnect: OnConnect;
|
||||||
|
|
||||||
|
/** Handler for reconnecting an existing edge */
|
||||||
onReconnect: OnReconnect;
|
onReconnect: OnReconnect;
|
||||||
|
|
||||||
|
/** Called when an edge reconnect process starts */
|
||||||
onReconnectStart: () => void;
|
onReconnectStart: () => void;
|
||||||
onReconnectEnd: (_: unknown, edge: { id: string }) => void;
|
|
||||||
deleteNode: (nodeId: string) => void;
|
/**
|
||||||
setNodes: (nodes: AppNode[]) => 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, deleteElements?: (params: DeleteElementsOptions) => Promise<{
|
||||||
|
deletedNodes: Node[]
|
||||||
|
deletedEdges: Edge[]
|
||||||
|
}>) => 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;
|
setEdges: (edges: Edge[]) => void;
|
||||||
};
|
|
||||||
|
/**
|
||||||
|
* Updates the data of a node by merging new data with existing node data.
|
||||||
|
* @param nodeId - the ID of the node to update
|
||||||
|
* @param data - object containing new data fields to merge
|
||||||
|
*/
|
||||||
|
updateNodeData: (nodeId: string, data: object) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new node to the flow.
|
||||||
|
* @param node - the Node object to add
|
||||||
|
*/
|
||||||
|
addNode: (node: Node) => void;
|
||||||
|
} & UndoRedoState & HandleRuleRegistry & EditorWarningRegistry;
|
||||||
|
|
||||||
|
export type UndoRedoState = {
|
||||||
|
// UndoRedo Types
|
||||||
|
past: FlowSnapshot[];
|
||||||
|
future: FlowSnapshot[];
|
||||||
|
pushSnapshot: () => void;
|
||||||
|
isBatchAction: boolean;
|
||||||
|
beginBatchAction: () => void;
|
||||||
|
endBatchAction: () => void;
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HandleRuleRegistry = {
|
||||||
|
ruleRegistry: Map<string, HandleRule[]>;
|
||||||
|
|
||||||
|
getTargetRules: (
|
||||||
|
targetNodeId: string,
|
||||||
|
targetHandleId: string
|
||||||
|
) => HandleRule[];
|
||||||
|
|
||||||
|
registerRules: (
|
||||||
|
nodeId: string,
|
||||||
|
handleId: string,
|
||||||
|
rules: HandleRule[]
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
unregisterHandleRules: (
|
||||||
|
nodeId: string,
|
||||||
|
handleId: string
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
// cleans up all registered rules of all handles of the provided node
|
||||||
|
unregisterNodeRules: (nodeId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,138 +1,117 @@
|
|||||||
import {useDraggable} from '@neodrag/react';
|
import { useDraggable } from '@neodrag/react';
|
||||||
import {
|
import { useReactFlow, type XYPosition } from '@xyflow/react';
|
||||||
useReactFlow,
|
import { type ReactNode, useCallback, useRef, useState } from 'react';
|
||||||
type XYPosition
|
import useFlowStore from '../VisProgStores';
|
||||||
} from '@xyflow/react';
|
import styles from '../../VisProg.module.css';
|
||||||
import {
|
import { NodeDefaults, type NodeTypes} from '../NodeRegistry'
|
||||||
type ReactNode,
|
import {Tooltip} from "./NodeComponents.tsx";
|
||||||
useCallback,
|
|
||||||
useRef,
|
|
||||||
useState
|
|
||||||
} from 'react';
|
|
||||||
import useFlowStore from "../VisProgStores.tsx";
|
|
||||||
import styles from "../../VisProg.module.css"
|
|
||||||
import type {AppNode, PhaseNode, NormNode} from "../VisProgTypes.tsx";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DraggableNodeProps dictates the type properties of a DraggableNode
|
* 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 {
|
interface DraggableNodeProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
nodeType: string;
|
nodeType: keyof typeof NodeTypes;
|
||||||
onDrop: (nodeType: string, position: XYPosition) => void;
|
onDrop: (nodeType: keyof typeof NodeTypes, position: XYPosition) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Definition of a node inside the drag and drop toolbar,
|
* A draggable node element used in the drag-and-drop toolbar.
|
||||||
* these nodes require an onDrop function to be supplied
|
|
||||||
* that dictates how the node is created in the graph.
|
|
||||||
*
|
*
|
||||||
* @param className
|
* Integrates with the NeoDrag library to handle drag events.
|
||||||
* @param children
|
* On drop, it calls the provided `onDrop` function with the node type and drop position.
|
||||||
* @param nodeType
|
*
|
||||||
* @param onDrop
|
* @param props - The draggable node configuration.
|
||||||
* @constructor
|
* @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 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, {
|
useDraggable(draggableRef, {
|
||||||
position: position,
|
position,
|
||||||
onDrag: ({offsetX, offsetY}) => {
|
onDrag: ({ offsetX, offsetY }) => {
|
||||||
// Calculate position relative to the viewport
|
setPosition({ x: offsetX, y: offsetY });
|
||||||
setPosition({
|
|
||||||
x: offsetX,
|
|
||||||
y: offsetY,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onDragEnd: ({event}) => {
|
onDragEnd: ({ event }) => {
|
||||||
setPosition({x: 0, y: 0});
|
setPosition({ x: 0, y: 0 });
|
||||||
onDrop(nodeType, {
|
onDrop(nodeType, { x: event.clientX, y: event.clientY });
|
||||||
x: event.clientX,
|
|
||||||
y: event.clientY,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} ref={draggableRef}>
|
<Tooltip nodeType={nodeType}>
|
||||||
{children}
|
<div>
|
||||||
</div>
|
<div className={className}
|
||||||
);
|
ref={draggableRef}
|
||||||
}
|
id={`draggable-${nodeType}`}
|
||||||
|
data-testid={`draggable-${nodeType}`}
|
||||||
|
>
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
{children}
|
||||||
export function addNode(nodeType: string, position: XYPosition) {
|
</div>
|
||||||
const {setNodes} = useFlowStore.getState();
|
</div>
|
||||||
const nds : AppNode[] = useFlowStore.getState().nodes;
|
</Tooltip>)
|
||||||
const newNode = () => {
|
|
||||||
switch (nodeType) {
|
|
||||||
case "phase":
|
|
||||||
{
|
|
||||||
const phaseNodes= nds.filter((node) => node.type === 'phase');
|
|
||||||
let phaseNumber;
|
|
||||||
if (phaseNodes.length > 0) {
|
|
||||||
const finalPhaseId : number = +(phaseNodes[phaseNodes.length - 1].id.split('-')[1]);
|
|
||||||
phaseNumber = finalPhaseId + 1;
|
|
||||||
} else {
|
|
||||||
phaseNumber = 1;
|
|
||||||
}
|
|
||||||
const phaseNode : PhaseNode = {
|
|
||||||
id: `phase-${phaseNumber}`,
|
|
||||||
type: nodeType,
|
|
||||||
position,
|
|
||||||
data: {label: 'new', number: phaseNumber},
|
|
||||||
}
|
|
||||||
return phaseNode;
|
|
||||||
}
|
|
||||||
case "norm":
|
|
||||||
{
|
|
||||||
const normNodes= nds.filter((node) => node.type === 'norm');
|
|
||||||
let normNumber
|
|
||||||
if (normNodes.length > 0) {
|
|
||||||
const finalNormId : number = +(normNodes[normNodes.length - 1].id.split('-')[1]);
|
|
||||||
normNumber = finalNormId + 1;
|
|
||||||
} else {
|
|
||||||
normNumber = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normNode : NormNode = {
|
|
||||||
id: `norm-${normNumber}`,
|
|
||||||
type: nodeType,
|
|
||||||
position,
|
|
||||||
data: {label: `new norm node`, value: "Pepper should be formal"},
|
|
||||||
}
|
|
||||||
return normNode;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
throw new Error(`Node ${nodeType} not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setNodes(nds.concat(newNode()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* the DndToolbar defines how the drag and drop toolbar component works
|
* Adds a new node to the flow graph.
|
||||||
* and includes the default onDrop behavior through handleNodeDrop
|
*
|
||||||
* @constructor
|
* Handles:
|
||||||
|
* - Automatic node ID generation based on existing nodes of the same type.
|
||||||
|
* - Loading of default data from the `NodeDefaults` registry.
|
||||||
|
* - Integration with the flow store to update global node state.
|
||||||
|
*
|
||||||
|
* @param nodeType - The type of node to create (from `NodeTypes`).
|
||||||
|
* @param position - The XY position in the flow canvas where the node will appear.
|
||||||
|
*/
|
||||||
|
function addNodeToFlow(nodeType: keyof typeof NodeTypes, position: XYPosition) {
|
||||||
|
const { addNode } = useFlowStore.getState();
|
||||||
|
|
||||||
|
// Load any predefined data for this node type.
|
||||||
|
const defaultData = NodeDefaults[nodeType] ?? {}
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
// Create new node
|
||||||
|
const newNode = {
|
||||||
|
id: id,
|
||||||
|
type: nodeType,
|
||||||
|
position,
|
||||||
|
data: JSON.parse(JSON.stringify(defaultData))
|
||||||
|
}
|
||||||
|
addNode(newNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The drag-and-drop toolbar component for the visual programming interface.
|
||||||
|
*
|
||||||
|
* Displays draggable node templates based on entries in `NodeDefaults`.
|
||||||
|
* Each droppable node can be dragged into the flow pane to instantiate it.
|
||||||
|
*
|
||||||
|
* Automatically filters nodes whose `droppable` flag is set to `true`.
|
||||||
|
*
|
||||||
|
* @returns A React element representing the drag-and-drop toolbar.
|
||||||
*/
|
*/
|
||||||
export function DndToolbar() {
|
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(
|
const handleNodeDrop = useCallback(
|
||||||
(nodeType: string, screenPosition: XYPosition) => {
|
(nodeType: keyof typeof NodeTypes, screenPosition: XYPosition) => {
|
||||||
const flow = document.querySelector('.react-flow');
|
const flow = document.querySelector('.react-flow');
|
||||||
const flowRect = flow?.getBoundingClientRect();
|
const flowRect = flow?.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Only add the node if it is inside the flow canvas area.
|
||||||
const isInFlow =
|
const isInFlow =
|
||||||
flowRect &&
|
flowRect &&
|
||||||
screenPosition.x >= flowRect.left &&
|
screenPosition.x >= flowRect.left &&
|
||||||
@@ -140,28 +119,41 @@ export function DndToolbar() {
|
|||||||
screenPosition.y >= flowRect.top &&
|
screenPosition.y >= flowRect.top &&
|
||||||
screenPosition.y <= flowRect.bottom;
|
screenPosition.y <= flowRect.bottom;
|
||||||
|
|
||||||
// Create a new node and add it to the flow
|
|
||||||
if (isInFlow) {
|
if (isInFlow) {
|
||||||
const position = screenToFlowPosition(screenPosition);
|
const position = screenToFlowPosition(screenPosition);
|
||||||
addNode(nodeType, position);
|
addNodeToFlow(nodeType, position);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[screenToFlowPosition],
|
[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 (
|
return (
|
||||||
<div className={`flex-col gap-lg padding-md ${styles.innerDndPanel}`}>
|
<div className={`flex-col gap-lg padding-md ${styles.innerDndPanel}`} id={"draggable-sidebar"}>
|
||||||
<div className="description">
|
<div className="description">
|
||||||
You can drag these nodes to the pane to create new nodes.
|
You can drag these nodes to the pane to create new nodes.
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex-row gap-lg ${styles.dndNodeContainer}`}>
|
<div className={`flex-row gap-lg ${styles.dndNodeContainer}`}>
|
||||||
<DraggableNode className={styles.draggableNodePhase} nodeType="phase" onDrop={handleNodeDrop}>
|
{/* Maps over all the nodes that are droppable, and puts them in the panel */}
|
||||||
phase Node
|
{droppableNodes.map(({type, data}) => (
|
||||||
</DraggableNode>
|
<DraggableNode
|
||||||
<DraggableNode className={styles.draggableNodeNorm} nodeType="norm" onDrop={handleNodeDrop}>
|
key={type}
|
||||||
norm Node
|
className={styles[`draggable-node-${type}`]} // Our current style signature for nodes
|
||||||
</DraggableNode>
|
nodeType={type}
|
||||||
|
onDrop={handleNodeDrop}
|
||||||
|
>
|
||||||
|
{data.label}
|
||||||
|
</DraggableNode>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
/* contains all logic for the VisProgEditor warning system
|
||||||
|
*
|
||||||
|
* Missing but desirable features:
|
||||||
|
* - Warning filtering:
|
||||||
|
* - if there is no completely connected chain of startNode-[PhaseNodes]-EndNode
|
||||||
|
* then hide any startNode, phaseNode, or endNode specific warnings
|
||||||
|
*/
|
||||||
|
import useFlowStore from "../VisProgStores.tsx";
|
||||||
|
import type {FlowState} from "../VisProgTypes.tsx";
|
||||||
|
|
||||||
|
// --| Type definitions |--
|
||||||
|
|
||||||
|
export type WarningId = NodeId | "GLOBAL_WARNINGS";
|
||||||
|
export type NodeId = string;
|
||||||
|
|
||||||
|
|
||||||
|
export type WarningType =
|
||||||
|
| 'MISSING_INPUT'
|
||||||
|
| 'MISSING_OUTPUT'
|
||||||
|
| 'PLAN_IS_UNDEFINED'
|
||||||
|
| 'INCOMPLETE_PROGRAM'
|
||||||
|
| 'NOT_CONNECTED_TO_PROGRAM'
|
||||||
|
| string
|
||||||
|
|
||||||
|
export type WarningSeverity =
|
||||||
|
| 'INFO' // Acceptable, but important to be aware of
|
||||||
|
| 'WARNING' // Acceptable, but probably undesirable behavior
|
||||||
|
| 'ERROR' // Prevents running program, should be fixed before running program is allowed
|
||||||
|
|
||||||
|
/**
|
||||||
|
* warning scope, include a handleId if the warning is handle specific
|
||||||
|
*/
|
||||||
|
export type WarningScope = {
|
||||||
|
id: string;
|
||||||
|
handleId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EditorWarning = {
|
||||||
|
scope: WarningScope;
|
||||||
|
type: WarningType;
|
||||||
|
severity: WarningSeverity;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a scoped WarningKey,
|
||||||
|
* the handleId scoping is only needed for handle specific errors
|
||||||
|
*
|
||||||
|
* "`WarningType`:`handleId`"
|
||||||
|
*/
|
||||||
|
export type WarningKey = string; // for warnings that can occur on a per-handle basis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a composite key used in the severityIndex
|
||||||
|
*
|
||||||
|
* "`WarningId`|`WarningKey`"
|
||||||
|
*/
|
||||||
|
export type CompositeWarningKey = string;
|
||||||
|
|
||||||
|
export type WarningRegistry = Map<WarningId , Map<WarningKey, EditorWarning>>;
|
||||||
|
export type SeverityIndex = Map<WarningSeverity, Set<CompositeWarningKey>>;
|
||||||
|
|
||||||
|
type ZustandSet = (partial: Partial<FlowState> | ((state: FlowState) => Partial<FlowState>)) => void;
|
||||||
|
type ZustandGet = () => FlowState;
|
||||||
|
|
||||||
|
export type EditorWarningRegistry = {
|
||||||
|
/**
|
||||||
|
* stores all editor warnings
|
||||||
|
*/
|
||||||
|
editorWarningRegistry: WarningRegistry;
|
||||||
|
/**
|
||||||
|
* index of warnings by severity
|
||||||
|
*/
|
||||||
|
severityIndex: SeverityIndex;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gets all warnings and returns them as a list of warnings
|
||||||
|
* @returns {EditorWarning[]}
|
||||||
|
*/
|
||||||
|
getWarnings: () => EditorWarning[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gets all warnings with the current severity
|
||||||
|
* @param {WarningSeverity} warningSeverity
|
||||||
|
* @returns {EditorWarning[]}
|
||||||
|
*/
|
||||||
|
getWarningsBySeverity: (warningSeverity: WarningSeverity) => EditorWarning[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* checks if there are no warnings of breaking severity
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isProgramValid: () => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* registers a warning to the warningRegistry and the SeverityIndex
|
||||||
|
* @param {EditorWarning} warning
|
||||||
|
*/
|
||||||
|
registerWarning: (warning: EditorWarning) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* unregisters a warning from the warningRegistry and the SeverityIndex
|
||||||
|
* @param {EditorWarning} warning
|
||||||
|
*/
|
||||||
|
unregisterWarning: (id: WarningId, warningKey: WarningKey) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* unregisters warnings from the warningRegistry and the SeverityIndex
|
||||||
|
* @param {WarningId} warning
|
||||||
|
*/
|
||||||
|
unregisterWarningsForId: (id: WarningId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --| implemented logic |--
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the id to use for global editor warnings
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
export const globalWarning = "GLOBAL_WARNINGS";
|
||||||
|
|
||||||
|
export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : EditorWarningRegistry { return {
|
||||||
|
editorWarningRegistry: new Map<NodeId, Map<WarningKey, EditorWarning>>(),
|
||||||
|
severityIndex: new Map([
|
||||||
|
['INFO', new Set<CompositeWarningKey>()],
|
||||||
|
['WARNING', new Set<CompositeWarningKey>()],
|
||||||
|
['ERROR', new Set<CompositeWarningKey>()],
|
||||||
|
]),
|
||||||
|
|
||||||
|
getWarningsBySeverity: (warningSeverity) => {
|
||||||
|
const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
|
||||||
|
const sIndex = new Map(get().severityIndex);
|
||||||
|
const warningKeys = sIndex.get(warningSeverity);
|
||||||
|
const warnings: EditorWarning[] = [];
|
||||||
|
|
||||||
|
warningKeys?.forEach(
|
||||||
|
(compositeKey) => {
|
||||||
|
const [id, warningKey] = compositeKey.split('|');
|
||||||
|
const warning = wRegistry.get(id)?.get(warningKey);
|
||||||
|
|
||||||
|
if (warning) {
|
||||||
|
warnings.push(warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return warnings;
|
||||||
|
},
|
||||||
|
|
||||||
|
isProgramValid: () => {
|
||||||
|
const sIndex = get().severityIndex;
|
||||||
|
return (sIndex.get("ERROR")!.size === 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
getWarnings: () => Array.from(get().editorWarningRegistry.values())
|
||||||
|
.flatMap(innerMap => Array.from(innerMap.values())),
|
||||||
|
|
||||||
|
|
||||||
|
registerWarning: (warning) => {
|
||||||
|
const { scope: {id, handleId}, type, severity } = warning;
|
||||||
|
const warningKey = handleId ? `${type}:${handleId}` : type;
|
||||||
|
const compositeKey = `${id}|${warningKey}`;
|
||||||
|
const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
|
||||||
|
const sIndex = new Map(get().severityIndex);
|
||||||
|
// add to warning registry
|
||||||
|
if (!wRegistry.has(id)) {
|
||||||
|
wRegistry.set(id, new Map());
|
||||||
|
}
|
||||||
|
wRegistry.get(id)!.set(warningKey, warning);
|
||||||
|
|
||||||
|
|
||||||
|
// add to severityIndex
|
||||||
|
if (!sIndex.get(severity)!.has(compositeKey)) {
|
||||||
|
sIndex.get(severity)!.add(compositeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
editorWarningRegistry: wRegistry,
|
||||||
|
severityIndex: sIndex
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
unregisterWarning: (id, warningKey) => {
|
||||||
|
const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
|
||||||
|
const sIndex = new Map(get().severityIndex);
|
||||||
|
// verify if the warning was created already
|
||||||
|
const warning = wRegistry.get(id)?.get(warningKey);
|
||||||
|
if (!warning) return;
|
||||||
|
|
||||||
|
// remove from warning registry
|
||||||
|
wRegistry.get(id)!.delete(warningKey);
|
||||||
|
|
||||||
|
|
||||||
|
// remove from severityIndex
|
||||||
|
sIndex.get(warning.severity)!.delete(`${id}|${warningKey}`);
|
||||||
|
|
||||||
|
set({
|
||||||
|
editorWarningRegistry: wRegistry,
|
||||||
|
severityIndex: sIndex
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
unregisterWarningsForId: (id) => {
|
||||||
|
const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
|
||||||
|
const sIndex = new Map(get().severityIndex);
|
||||||
|
|
||||||
|
const nodeWarnings = wRegistry.get(id);
|
||||||
|
|
||||||
|
// remove from severity index
|
||||||
|
if (nodeWarnings) {
|
||||||
|
nodeWarnings.forEach((warning) => {
|
||||||
|
const warningKey = warning.scope.handleId
|
||||||
|
? `${warning.type}:${warning.scope.handleId}`
|
||||||
|
: warning.type;
|
||||||
|
sIndex.get(warning.severity)?.delete(`${id}|${warningKey}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove from warning registry
|
||||||
|
wRegistry.delete(id);
|
||||||
|
|
||||||
|
set({
|
||||||
|
editorWarningRegistry: wRegistry,
|
||||||
|
severityIndex: sIndex
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* returns a summary of the warningRegistry
|
||||||
|
* @returns {{info: number, warning: number, error: number, isValid: boolean}}
|
||||||
|
*/
|
||||||
|
export function warningSummary() {
|
||||||
|
const {severityIndex, isProgramValid} = useFlowStore.getState();
|
||||||
|
return {
|
||||||
|
info: severityIndex.get('INFO')!.size,
|
||||||
|
warning: severityIndex.get('WARNING')!.size,
|
||||||
|
error: severityIndex.get('ERROR')!.size,
|
||||||
|
isValid: isProgramValid(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
|
||||||
|
.gestureEditor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modeSelector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modeLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleContainer {
|
||||||
|
display: flex;
|
||||||
|
background: rgba(78, 78, 78, 0.411);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleButton {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleButton:hover {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleButton.active {
|
||||||
|
box-shadow: 0 0 1px 0 rgba(9, 255, 0, 0.733);
|
||||||
|
}
|
||||||
|
|
||||||
|
.valueEditor {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textInput:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagSelector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagSelect {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: rgba(135, 135, 135, 0.296);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagSelect:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgb(0, 149, 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid rgba(var(--primary-rgb), 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagButton {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid gray;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--primary-rgb);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagButton:hover {
|
||||||
|
background: gray;
|
||||||
|
border-color: gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagButton.selected {
|
||||||
|
background: rgba(var(--primary-rgb), 0.5);
|
||||||
|
color: var(--primary-rgb);
|
||||||
|
border-color: rgb(27, 223, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionsDropdownLeft {
|
||||||
|
position: absolute;
|
||||||
|
left: -220px;
|
||||||
|
top: 120px;
|
||||||
|
|
||||||
|
width: 200px;
|
||||||
|
max-height: 20vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
background: var(--dropdown-menu-background-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 24px var(--dropdown-menu-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionsDropdownLeft::before {
|
||||||
|
content: "Gesture Suggestions";
|
||||||
|
display: block;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionItem {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
font-size: 14px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionItem:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionItem:hover {
|
||||||
|
background-color: var(--background-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionItem:active {
|
||||||
|
background-color: var(--primary-color-light);
|
||||||
|
}
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
import { useState, useRef } from "react";
|
||||||
|
import styles from './GestureValueEditor.module.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the GestureValueEditor component.
|
||||||
|
* - value: current gesture value (controlled by parent)
|
||||||
|
* - setValue: callback to update the gesture value in parent state
|
||||||
|
* - placeholder: optional placeholder text for the input field
|
||||||
|
*/
|
||||||
|
type GestureValueEditorProps = {
|
||||||
|
value: string;
|
||||||
|
setValue: (value: string) => void;
|
||||||
|
setType: (value: boolean) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of high-level gesture "tags".
|
||||||
|
* These are human-readable categories or semantic labels.
|
||||||
|
* In a real app, these would likely be loaded from an external source.
|
||||||
|
*/
|
||||||
|
const GESTURE_TAGS = ["above", "affirmative", "afford", "agitated", "all", "allright", "alright", "any",
|
||||||
|
"assuage", "attemper", "back", "bashful", "beg", "beseech", "blank",
|
||||||
|
"body language", "bored", "bow", "but", "call", "calm", "choose", "choice", "cloud",
|
||||||
|
"cogitate", "cool", "crazy", "disappointed", "down", "earth", "empty", "embarrassed",
|
||||||
|
"enthusiastic", "entire", "estimate", "except", "exalted", "excited", "explain", "far",
|
||||||
|
"field", "floor", "forlorn", "friendly", "front", "frustrated", "gentle", "gift",
|
||||||
|
"give", "ground", "happy", "hello", "her", "here", "hey", "hi", "him", "hopeless",
|
||||||
|
"hysterical", "I", "implore", "indicate", "joyful", "me", "meditate", "modest",
|
||||||
|
"negative", "nervous", "no", "not know", "nothing", "offer", "ok", "once upon a time",
|
||||||
|
"oppose", "or", "pacify", "pick", "placate", "please", "present", "proffer", "quiet",
|
||||||
|
"reason", "refute", "reject", "rousing", "sad", "select", "shamefaced", "show",
|
||||||
|
"show sky", "sky", "soothe", "sun", "supplicate", "tablet", "tall", "them", "there",
|
||||||
|
"think", "timid", "top", "unless", "up", "upstairs", "void", "warm", "winner", "yeah",
|
||||||
|
"yes", "yoo-hoo", "you", "your", "zero", "zestful"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of concrete gesture animation paths.
|
||||||
|
* These represent specific animation assets and are used in "single" mode
|
||||||
|
* with autocomplete-style selection, also would be loaded from an external source.
|
||||||
|
*/
|
||||||
|
const GESTURE_SINGLES = [
|
||||||
|
"animations/Stand/BodyTalk/Listening/Listening_1",
|
||||||
|
"animations/Stand/BodyTalk/Listening/Listening_2",
|
||||||
|
"animations/Stand/BodyTalk/Listening/Listening_3",
|
||||||
|
"animations/Stand/BodyTalk/Listening/Listening_4",
|
||||||
|
"animations/Stand/BodyTalk/Listening/Listening_5",
|
||||||
|
"animations/Stand/BodyTalk/Listening/Listening_6",
|
||||||
|
"animations/Stand/BodyTalk/Listening/Listening_7",
|
||||||
|
"animations/Stand/BodyTalk/Speaking/BodyTalk_1",
|
||||||
|
"animations/Stand/BodyTalk/Speaking/BodyTalk_10",
|
||||||
|
"animations/Stand/BodyTalk/Speaking/BodyTalk_11",
|
||||||
|
"animations/Stand/BodyTalk/Speaking/BodyTalk_12",
|
||||||
|
"animations/Stand/BodyTalk/Speaking/BodyTalk_13",
|
||||||
|
"animations/Stand/BodyTalk/Speaking/BodyTalk_14",
|
||||||
|
"animations/Stand/BodyTalk/Speaking/BodyTalk_15",
|
||||||
|
"animations/Stand/BodyTalk/Speaking/BodyTalk_16",
|
||||||
|
"animations/Stand/BodyTalk/Speaking/BodyTalk_2",
|
||||||
|
"animations/Stand/BodyTalk/Speaking/BodyTalk_3",
|
||||||
|
"animations/Stand/BodyTalk/Speaking/BodyTalk_4",
|
||||||
|
"animations/Stand/BodyTalk/Speaking/BodyTalk_5",
|
||||||
|
"animations/Stand/BodyTalk/Speaking/BodyTalk_6",
|
||||||
|
"animations/Stand/BodyTalk/Speaking/BodyTalk_7",
|
||||||
|
"animations/Stand/BodyTalk/Speaking/BodyTalk_8",
|
||||||
|
"animations/Stand/BodyTalk/Speaking/BodyTalk_9",
|
||||||
|
"animations/Stand/BodyTalk/Thinking/Remember_1",
|
||||||
|
"animations/Stand/BodyTalk/Thinking/Remember_2",
|
||||||
|
"animations/Stand/BodyTalk/Thinking/Remember_3",
|
||||||
|
"animations/Stand/BodyTalk/Thinking/ThinkingLoop_1",
|
||||||
|
"animations/Stand/BodyTalk/Thinking/ThinkingLoop_2",
|
||||||
|
"animations/Stand/Emotions/Negative/Angry_1",
|
||||||
|
"animations/Stand/Emotions/Negative/Angry_2",
|
||||||
|
"animations/Stand/Emotions/Negative/Angry_3",
|
||||||
|
"animations/Stand/Emotions/Negative/Angry_4",
|
||||||
|
"animations/Stand/Emotions/Negative/Anxious_1",
|
||||||
|
"animations/Stand/Emotions/Negative/Bored_1",
|
||||||
|
"animations/Stand/Emotions/Negative/Bored_2",
|
||||||
|
"animations/Stand/Emotions/Negative/Disappointed_1",
|
||||||
|
"animations/Stand/Emotions/Negative/Exhausted_1",
|
||||||
|
"animations/Stand/Emotions/Negative/Exhausted_2",
|
||||||
|
"animations/Stand/Emotions/Negative/Fear_1",
|
||||||
|
"animations/Stand/Emotions/Negative/Fear_2",
|
||||||
|
"animations/Stand/Emotions/Negative/Fearful_1",
|
||||||
|
"animations/Stand/Emotions/Negative/Frustrated_1",
|
||||||
|
"animations/Stand/Emotions/Negative/Humiliated_1",
|
||||||
|
"animations/Stand/Emotions/Negative/Hurt_1",
|
||||||
|
"animations/Stand/Emotions/Negative/Hurt_2",
|
||||||
|
"animations/Stand/Emotions/Negative/Late_1",
|
||||||
|
"animations/Stand/Emotions/Negative/Sad_1",
|
||||||
|
"animations/Stand/Emotions/Negative/Sad_2",
|
||||||
|
"animations/Stand/Emotions/Negative/Shocked_1",
|
||||||
|
"animations/Stand/Emotions/Negative/Sorry_1",
|
||||||
|
"animations/Stand/Emotions/Negative/Surprise_1",
|
||||||
|
"animations/Stand/Emotions/Negative/Surprise_2",
|
||||||
|
"animations/Stand/Emotions/Negative/Surprise_3",
|
||||||
|
"animations/Stand/Emotions/Neutral/Alienated_1",
|
||||||
|
"animations/Stand/Emotions/Neutral/AskForAttention_1",
|
||||||
|
"animations/Stand/Emotions/Neutral/AskForAttention_2",
|
||||||
|
"animations/Stand/Emotions/Neutral/AskForAttention_3",
|
||||||
|
"animations/Stand/Emotions/Neutral/Cautious_1",
|
||||||
|
"animations/Stand/Emotions/Neutral/Confused_1",
|
||||||
|
"animations/Stand/Emotions/Neutral/Determined_1",
|
||||||
|
"animations/Stand/Emotions/Neutral/Embarrassed_1",
|
||||||
|
"animations/Stand/Emotions/Neutral/Hesitation_1",
|
||||||
|
"animations/Stand/Emotions/Neutral/Innocent_1",
|
||||||
|
"animations/Stand/Emotions/Neutral/Lonely_1",
|
||||||
|
"animations/Stand/Emotions/Neutral/Mischievous_1",
|
||||||
|
"animations/Stand/Emotions/Neutral/Puzzled_1",
|
||||||
|
"animations/Stand/Emotions/Neutral/Sneeze",
|
||||||
|
"animations/Stand/Emotions/Neutral/Stubborn_1",
|
||||||
|
"animations/Stand/Emotions/Neutral/Suspicious_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Amused_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Confident_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Ecstatic_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Enthusiastic_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Excited_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Excited_2",
|
||||||
|
"animations/Stand/Emotions/Positive/Excited_3",
|
||||||
|
"animations/Stand/Emotions/Positive/Happy_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Happy_2",
|
||||||
|
"animations/Stand/Emotions/Positive/Happy_3",
|
||||||
|
"animations/Stand/Emotions/Positive/Happy_4",
|
||||||
|
"animations/Stand/Emotions/Positive/Hungry_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Hysterical_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Interested_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Interested_2",
|
||||||
|
"animations/Stand/Emotions/Positive/Laugh_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Laugh_2",
|
||||||
|
"animations/Stand/Emotions/Positive/Laugh_3",
|
||||||
|
"animations/Stand/Emotions/Positive/Mocker_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Optimistic_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Peaceful_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Proud_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Proud_2",
|
||||||
|
"animations/Stand/Emotions/Positive/Proud_3",
|
||||||
|
"animations/Stand/Emotions/Positive/Relieved_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Shy_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Shy_2",
|
||||||
|
"animations/Stand/Emotions/Positive/Sure_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Winner_1",
|
||||||
|
"animations/Stand/Emotions/Positive/Winner_2",
|
||||||
|
"animations/Stand/Gestures/Angry_1",
|
||||||
|
"animations/Stand/Gestures/Angry_2",
|
||||||
|
"animations/Stand/Gestures/Angry_3",
|
||||||
|
"animations/Stand/Gestures/BowShort_1",
|
||||||
|
"animations/Stand/Gestures/BowShort_2",
|
||||||
|
"animations/Stand/Gestures/BowShort_3",
|
||||||
|
"animations/Stand/Gestures/But_1",
|
||||||
|
"animations/Stand/Gestures/CalmDown_1",
|
||||||
|
"animations/Stand/Gestures/CalmDown_2",
|
||||||
|
"animations/Stand/Gestures/CalmDown_3",
|
||||||
|
"animations/Stand/Gestures/CalmDown_4",
|
||||||
|
"animations/Stand/Gestures/CalmDown_5",
|
||||||
|
"animations/Stand/Gestures/CalmDown_6",
|
||||||
|
"animations/Stand/Gestures/Choice_1",
|
||||||
|
"animations/Stand/Gestures/ComeOn_1",
|
||||||
|
"animations/Stand/Gestures/Confused_1",
|
||||||
|
"animations/Stand/Gestures/Confused_2",
|
||||||
|
"animations/Stand/Gestures/CountFive_1",
|
||||||
|
"animations/Stand/Gestures/CountFour_1",
|
||||||
|
"animations/Stand/Gestures/CountMore_1",
|
||||||
|
"animations/Stand/Gestures/CountOne_1",
|
||||||
|
"animations/Stand/Gestures/CountThree_1",
|
||||||
|
"animations/Stand/Gestures/CountTwo_1",
|
||||||
|
"animations/Stand/Gestures/Desperate_1",
|
||||||
|
"animations/Stand/Gestures/Desperate_2",
|
||||||
|
"animations/Stand/Gestures/Desperate_3",
|
||||||
|
"animations/Stand/Gestures/Desperate_4",
|
||||||
|
"animations/Stand/Gestures/Desperate_5",
|
||||||
|
"animations/Stand/Gestures/DontUnderstand_1",
|
||||||
|
"animations/Stand/Gestures/Enthusiastic_3",
|
||||||
|
"animations/Stand/Gestures/Enthusiastic_4",
|
||||||
|
"animations/Stand/Gestures/Enthusiastic_5",
|
||||||
|
"animations/Stand/Gestures/Everything_1",
|
||||||
|
"animations/Stand/Gestures/Everything_2",
|
||||||
|
"animations/Stand/Gestures/Everything_3",
|
||||||
|
"animations/Stand/Gestures/Everything_4",
|
||||||
|
"animations/Stand/Gestures/Everything_6",
|
||||||
|
"animations/Stand/Gestures/Excited_1",
|
||||||
|
"animations/Stand/Gestures/Explain_1",
|
||||||
|
"animations/Stand/Gestures/Explain_10",
|
||||||
|
"animations/Stand/Gestures/Explain_11",
|
||||||
|
"animations/Stand/Gestures/Explain_2",
|
||||||
|
"animations/Stand/Gestures/Explain_3",
|
||||||
|
"animations/Stand/Gestures/Explain_4",
|
||||||
|
"animations/Stand/Gestures/Explain_5",
|
||||||
|
"animations/Stand/Gestures/Explain_6",
|
||||||
|
"animations/Stand/Gestures/Explain_7",
|
||||||
|
"animations/Stand/Gestures/Explain_8",
|
||||||
|
"animations/Stand/Gestures/Far_1",
|
||||||
|
"animations/Stand/Gestures/Far_2",
|
||||||
|
"animations/Stand/Gestures/Far_3",
|
||||||
|
"animations/Stand/Gestures/Follow_1",
|
||||||
|
"animations/Stand/Gestures/Give_1",
|
||||||
|
"animations/Stand/Gestures/Give_2",
|
||||||
|
"animations/Stand/Gestures/Give_3",
|
||||||
|
"animations/Stand/Gestures/Give_4",
|
||||||
|
"animations/Stand/Gestures/Give_5",
|
||||||
|
"animations/Stand/Gestures/Give_6",
|
||||||
|
"animations/Stand/Gestures/Great_1",
|
||||||
|
"animations/Stand/Gestures/HeSays_1",
|
||||||
|
"animations/Stand/Gestures/HeSays_2",
|
||||||
|
"animations/Stand/Gestures/HeSays_3",
|
||||||
|
"animations/Stand/Gestures/Hey_1",
|
||||||
|
"animations/Stand/Gestures/Hey_10",
|
||||||
|
"animations/Stand/Gestures/Hey_2",
|
||||||
|
"animations/Stand/Gestures/Hey_3",
|
||||||
|
"animations/Stand/Gestures/Hey_4",
|
||||||
|
"animations/Stand/Gestures/Hey_6",
|
||||||
|
"animations/Stand/Gestures/Hey_7",
|
||||||
|
"animations/Stand/Gestures/Hey_8",
|
||||||
|
"animations/Stand/Gestures/Hey_9",
|
||||||
|
"animations/Stand/Gestures/Hide_1",
|
||||||
|
"animations/Stand/Gestures/Hot_1",
|
||||||
|
"animations/Stand/Gestures/Hot_2",
|
||||||
|
"animations/Stand/Gestures/IDontKnow_1",
|
||||||
|
"animations/Stand/Gestures/IDontKnow_2",
|
||||||
|
"animations/Stand/Gestures/IDontKnow_3",
|
||||||
|
"animations/Stand/Gestures/IDontKnow_4",
|
||||||
|
"animations/Stand/Gestures/IDontKnow_5",
|
||||||
|
"animations/Stand/Gestures/IDontKnow_6",
|
||||||
|
"animations/Stand/Gestures/Joy_1",
|
||||||
|
"animations/Stand/Gestures/Kisses_1",
|
||||||
|
"animations/Stand/Gestures/Look_1",
|
||||||
|
"animations/Stand/Gestures/Look_2",
|
||||||
|
"animations/Stand/Gestures/Maybe_1",
|
||||||
|
"animations/Stand/Gestures/Me_1",
|
||||||
|
"animations/Stand/Gestures/Me_2",
|
||||||
|
"animations/Stand/Gestures/Me_4",
|
||||||
|
"animations/Stand/Gestures/Me_7",
|
||||||
|
"animations/Stand/Gestures/Me_8",
|
||||||
|
"animations/Stand/Gestures/Mime_1",
|
||||||
|
"animations/Stand/Gestures/Mime_2",
|
||||||
|
"animations/Stand/Gestures/Next_1",
|
||||||
|
"animations/Stand/Gestures/No_1",
|
||||||
|
"animations/Stand/Gestures/No_2",
|
||||||
|
"animations/Stand/Gestures/No_3",
|
||||||
|
"animations/Stand/Gestures/No_4",
|
||||||
|
"animations/Stand/Gestures/No_5",
|
||||||
|
"animations/Stand/Gestures/No_6",
|
||||||
|
"animations/Stand/Gestures/No_7",
|
||||||
|
"animations/Stand/Gestures/No_8",
|
||||||
|
"animations/Stand/Gestures/No_9",
|
||||||
|
"animations/Stand/Gestures/Nothing_1",
|
||||||
|
"animations/Stand/Gestures/Nothing_2",
|
||||||
|
"animations/Stand/Gestures/OnTheEvening_1",
|
||||||
|
"animations/Stand/Gestures/OnTheEvening_2",
|
||||||
|
"animations/Stand/Gestures/OnTheEvening_3",
|
||||||
|
"animations/Stand/Gestures/OnTheEvening_4",
|
||||||
|
"animations/Stand/Gestures/OnTheEvening_5",
|
||||||
|
"animations/Stand/Gestures/Please_1",
|
||||||
|
"animations/Stand/Gestures/Please_2",
|
||||||
|
"animations/Stand/Gestures/Please_3",
|
||||||
|
"animations/Stand/Gestures/Reject_1",
|
||||||
|
"animations/Stand/Gestures/Reject_2",
|
||||||
|
"animations/Stand/Gestures/Reject_3",
|
||||||
|
"animations/Stand/Gestures/Reject_4",
|
||||||
|
"animations/Stand/Gestures/Reject_5",
|
||||||
|
"animations/Stand/Gestures/Reject_6",
|
||||||
|
"animations/Stand/Gestures/Salute_1",
|
||||||
|
"animations/Stand/Gestures/Salute_2",
|
||||||
|
"animations/Stand/Gestures/Salute_3",
|
||||||
|
"animations/Stand/Gestures/ShowFloor_1",
|
||||||
|
"animations/Stand/Gestures/ShowFloor_2",
|
||||||
|
"animations/Stand/Gestures/ShowFloor_3",
|
||||||
|
"animations/Stand/Gestures/ShowFloor_4",
|
||||||
|
"animations/Stand/Gestures/ShowFloor_5",
|
||||||
|
"animations/Stand/Gestures/ShowSky_1",
|
||||||
|
"animations/Stand/Gestures/ShowSky_10",
|
||||||
|
"animations/Stand/Gestures/ShowSky_11",
|
||||||
|
"animations/Stand/Gestures/ShowSky_12",
|
||||||
|
"animations/Stand/Gestures/ShowSky_2",
|
||||||
|
"animations/Stand/Gestures/ShowSky_3",
|
||||||
|
"animations/Stand/Gestures/ShowSky_4",
|
||||||
|
"animations/Stand/Gestures/ShowSky_5",
|
||||||
|
"animations/Stand/Gestures/ShowSky_6",
|
||||||
|
"animations/Stand/Gestures/ShowSky_7",
|
||||||
|
"animations/Stand/Gestures/ShowSky_8",
|
||||||
|
"animations/Stand/Gestures/ShowSky_9",
|
||||||
|
"animations/Stand/Gestures/ShowTablet_1",
|
||||||
|
"animations/Stand/Gestures/ShowTablet_2",
|
||||||
|
"animations/Stand/Gestures/ShowTablet_3",
|
||||||
|
"animations/Stand/Gestures/Shy_1",
|
||||||
|
"animations/Stand/Gestures/Stretch_1",
|
||||||
|
"animations/Stand/Gestures/Stretch_2",
|
||||||
|
"animations/Stand/Gestures/Surprised_1",
|
||||||
|
"animations/Stand/Gestures/TakePlace_1",
|
||||||
|
"animations/Stand/Gestures/TakePlace_2",
|
||||||
|
"animations/Stand/Gestures/Take_1",
|
||||||
|
"animations/Stand/Gestures/Thinking_1",
|
||||||
|
"animations/Stand/Gestures/Thinking_2",
|
||||||
|
"animations/Stand/Gestures/Thinking_3",
|
||||||
|
"animations/Stand/Gestures/Thinking_4",
|
||||||
|
"animations/Stand/Gestures/Thinking_5",
|
||||||
|
"animations/Stand/Gestures/Thinking_6",
|
||||||
|
"animations/Stand/Gestures/Thinking_7",
|
||||||
|
"animations/Stand/Gestures/Thinking_8",
|
||||||
|
"animations/Stand/Gestures/This_1",
|
||||||
|
"animations/Stand/Gestures/This_10",
|
||||||
|
"animations/Stand/Gestures/This_11",
|
||||||
|
"animations/Stand/Gestures/This_12",
|
||||||
|
"animations/Stand/Gestures/This_13",
|
||||||
|
"animations/Stand/Gestures/This_14",
|
||||||
|
"animations/Stand/Gestures/This_15",
|
||||||
|
"animations/Stand/Gestures/This_2",
|
||||||
|
"animations/Stand/Gestures/This_3",
|
||||||
|
"animations/Stand/Gestures/This_4",
|
||||||
|
"animations/Stand/Gestures/This_5",
|
||||||
|
"animations/Stand/Gestures/This_6",
|
||||||
|
"animations/Stand/Gestures/This_7",
|
||||||
|
"animations/Stand/Gestures/This_8",
|
||||||
|
"animations/Stand/Gestures/This_9",
|
||||||
|
"animations/Stand/Gestures/WhatSThis_1",
|
||||||
|
"animations/Stand/Gestures/WhatSThis_10",
|
||||||
|
"animations/Stand/Gestures/WhatSThis_11",
|
||||||
|
"animations/Stand/Gestures/WhatSThis_12",
|
||||||
|
"animations/Stand/Gestures/WhatSThis_13",
|
||||||
|
"animations/Stand/Gestures/WhatSThis_14",
|
||||||
|
"animations/Stand/Gestures/WhatSThis_15",
|
||||||
|
"animations/Stand/Gestures/WhatSThis_16",
|
||||||
|
"animations/Stand/Gestures/WhatSThis_2",
|
||||||
|
"animations/Stand/Gestures/WhatSThis_3",
|
||||||
|
"animations/Stand/Gestures/WhatSThis_4",
|
||||||
|
"animations/Stand/Gestures/WhatSThis_5",
|
||||||
|
"animations/Stand/Gestures/WhatSThis_6",
|
||||||
|
"animations/Stand/Gestures/WhatSThis_7",
|
||||||
|
"animations/Stand/Gestures/WhatSThis_8",
|
||||||
|
"animations/Stand/Gestures/WhatSThis_9",
|
||||||
|
"animations/Stand/Gestures/Whisper_1",
|
||||||
|
"animations/Stand/Gestures/Wings_1",
|
||||||
|
"animations/Stand/Gestures/Wings_2",
|
||||||
|
"animations/Stand/Gestures/Wings_3",
|
||||||
|
"animations/Stand/Gestures/Wings_4",
|
||||||
|
"animations/Stand/Gestures/Wings_5",
|
||||||
|
"animations/Stand/Gestures/Yes_1",
|
||||||
|
"animations/Stand/Gestures/Yes_2",
|
||||||
|
"animations/Stand/Gestures/Yes_3",
|
||||||
|
"animations/Stand/Gestures/YouKnowWhat_1",
|
||||||
|
"animations/Stand/Gestures/YouKnowWhat_2",
|
||||||
|
"animations/Stand/Gestures/YouKnowWhat_3",
|
||||||
|
"animations/Stand/Gestures/YouKnowWhat_4",
|
||||||
|
"animations/Stand/Gestures/YouKnowWhat_5",
|
||||||
|
"animations/Stand/Gestures/YouKnowWhat_6",
|
||||||
|
"animations/Stand/Gestures/You_1",
|
||||||
|
"animations/Stand/Gestures/You_2",
|
||||||
|
"animations/Stand/Gestures/You_3",
|
||||||
|
"animations/Stand/Gestures/You_4",
|
||||||
|
"animations/Stand/Gestures/You_5",
|
||||||
|
"animations/Stand/Gestures/Yum_1",
|
||||||
|
"animations/Stand/Reactions/EthernetOff_1",
|
||||||
|
"animations/Stand/Reactions/EthernetOn_1",
|
||||||
|
"animations/Stand/Reactions/Heat_1",
|
||||||
|
"animations/Stand/Reactions/Heat_2",
|
||||||
|
"animations/Stand/Reactions/LightShine_1",
|
||||||
|
"animations/Stand/Reactions/LightShine_2",
|
||||||
|
"animations/Stand/Reactions/LightShine_3",
|
||||||
|
"animations/Stand/Reactions/LightShine_4",
|
||||||
|
"animations/Stand/Reactions/SeeColor_1",
|
||||||
|
"animations/Stand/Reactions/SeeColor_2",
|
||||||
|
"animations/Stand/Reactions/SeeColor_3",
|
||||||
|
"animations/Stand/Reactions/SeeSomething_1",
|
||||||
|
"animations/Stand/Reactions/SeeSomething_3",
|
||||||
|
"animations/Stand/Reactions/SeeSomething_4",
|
||||||
|
"animations/Stand/Reactions/SeeSomething_5",
|
||||||
|
"animations/Stand/Reactions/SeeSomething_6",
|
||||||
|
"animations/Stand/Reactions/SeeSomething_7",
|
||||||
|
"animations/Stand/Reactions/SeeSomething_8",
|
||||||
|
"animations/Stand/Reactions/ShakeBody_1",
|
||||||
|
"animations/Stand/Reactions/ShakeBody_2",
|
||||||
|
"animations/Stand/Reactions/ShakeBody_3",
|
||||||
|
"animations/Stand/Reactions/TouchHead_1",
|
||||||
|
"animations/Stand/Reactions/TouchHead_2",
|
||||||
|
"animations/Stand/Reactions/TouchHead_3",
|
||||||
|
"animations/Stand/Reactions/TouchHead_4",
|
||||||
|
"animations/Stand/Waiting/AirGuitar_1",
|
||||||
|
"animations/Stand/Waiting/BackRubs_1",
|
||||||
|
"animations/Stand/Waiting/Bandmaster_1",
|
||||||
|
"animations/Stand/Waiting/Binoculars_1",
|
||||||
|
"animations/Stand/Waiting/BreathLoop_1",
|
||||||
|
"animations/Stand/Waiting/BreathLoop_2",
|
||||||
|
"animations/Stand/Waiting/BreathLoop_3",
|
||||||
|
"animations/Stand/Waiting/CallSomeone_1",
|
||||||
|
"animations/Stand/Waiting/Drink_1",
|
||||||
|
"animations/Stand/Waiting/DriveCar_1",
|
||||||
|
"animations/Stand/Waiting/Fitness_1",
|
||||||
|
"animations/Stand/Waiting/Fitness_2",
|
||||||
|
"animations/Stand/Waiting/Fitness_3",
|
||||||
|
"animations/Stand/Waiting/FunnyDancer_1",
|
||||||
|
"animations/Stand/Waiting/HappyBirthday_1",
|
||||||
|
"animations/Stand/Waiting/Helicopter_1",
|
||||||
|
"animations/Stand/Waiting/HideEyes_1",
|
||||||
|
"animations/Stand/Waiting/HideHands_1",
|
||||||
|
"animations/Stand/Waiting/Innocent_1",
|
||||||
|
"animations/Stand/Waiting/Knight_1",
|
||||||
|
"animations/Stand/Waiting/KnockEye_1",
|
||||||
|
"animations/Stand/Waiting/KungFu_1",
|
||||||
|
"animations/Stand/Waiting/LookHand_1",
|
||||||
|
"animations/Stand/Waiting/LookHand_2",
|
||||||
|
"animations/Stand/Waiting/LoveYou_1",
|
||||||
|
"animations/Stand/Waiting/Monster_1",
|
||||||
|
"animations/Stand/Waiting/MysticalPower_1",
|
||||||
|
"animations/Stand/Waiting/PlayHands_1",
|
||||||
|
"animations/Stand/Waiting/PlayHands_2",
|
||||||
|
"animations/Stand/Waiting/PlayHands_3",
|
||||||
|
"animations/Stand/Waiting/Relaxation_1",
|
||||||
|
"animations/Stand/Waiting/Relaxation_2",
|
||||||
|
"animations/Stand/Waiting/Relaxation_3",
|
||||||
|
"animations/Stand/Waiting/Relaxation_4",
|
||||||
|
"animations/Stand/Waiting/Rest_1",
|
||||||
|
"animations/Stand/Waiting/Robot_1",
|
||||||
|
"animations/Stand/Waiting/ScratchBack_1",
|
||||||
|
"animations/Stand/Waiting/ScratchBottom_1",
|
||||||
|
"animations/Stand/Waiting/ScratchEye_1",
|
||||||
|
"animations/Stand/Waiting/ScratchHand_1",
|
||||||
|
"animations/Stand/Waiting/ScratchHead_1",
|
||||||
|
"animations/Stand/Waiting/ScratchLeg_1",
|
||||||
|
"animations/Stand/Waiting/ScratchTorso_1",
|
||||||
|
"animations/Stand/Waiting/ShowMuscles_1",
|
||||||
|
"animations/Stand/Waiting/ShowMuscles_2",
|
||||||
|
"animations/Stand/Waiting/ShowMuscles_3",
|
||||||
|
"animations/Stand/Waiting/ShowMuscles_4",
|
||||||
|
"animations/Stand/Waiting/ShowMuscles_5",
|
||||||
|
"animations/Stand/Waiting/ShowSky_1",
|
||||||
|
"animations/Stand/Waiting/ShowSky_2",
|
||||||
|
"animations/Stand/Waiting/SpaceShuttle_1",
|
||||||
|
"animations/Stand/Waiting/Stretch_1",
|
||||||
|
"animations/Stand/Waiting/Stretch_2",
|
||||||
|
"animations/Stand/Waiting/TakePicture_1",
|
||||||
|
"animations/Stand/Waiting/Taxi_1",
|
||||||
|
"animations/Stand/Waiting/Think_1",
|
||||||
|
"animations/Stand/Waiting/Think_2",
|
||||||
|
"animations/Stand/Waiting/Think_3",
|
||||||
|
"animations/Stand/Waiting/Think_4",
|
||||||
|
"animations/Stand/Waiting/Waddle_1",
|
||||||
|
"animations/Stand/Waiting/Waddle_2",
|
||||||
|
"animations/Stand/Waiting/WakeUp_1",
|
||||||
|
"animations/Stand/Waiting/Zombie_1"]
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a gesture value editor component.
|
||||||
|
* @returns JSX.Element
|
||||||
|
*/
|
||||||
|
export default function GestureValueEditor({
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
setType,
|
||||||
|
placeholder = "Gesture name",
|
||||||
|
}: GestureValueEditorProps) {
|
||||||
|
|
||||||
|
/** Input mode: semantic tag vs concrete animation path */
|
||||||
|
const [mode, setMode] = useState<"single" | "tag">("tag");
|
||||||
|
|
||||||
|
/** Raw text value for single-gesture input */
|
||||||
|
const [customValue, setCustomValue] = useState("");
|
||||||
|
|
||||||
|
/** Autocomplete dropdown state */
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(true);
|
||||||
|
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
||||||
|
|
||||||
|
/** Reserved for future click-outside / positioning logic */
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
/** Switch between tag and single input modes */
|
||||||
|
const handleModeChange = (newMode: "single" | "tag") => {
|
||||||
|
setMode(newMode);
|
||||||
|
|
||||||
|
if (newMode === "single") {
|
||||||
|
setValue(customValue || value);
|
||||||
|
setType(false);
|
||||||
|
setFilteredSuggestions(GESTURE_SINGLES);
|
||||||
|
setShowSuggestions(true);
|
||||||
|
} else {
|
||||||
|
// Clear value if it does not match a valid tag
|
||||||
|
setType(true);
|
||||||
|
const isValidTag = GESTURE_TAGS.some(
|
||||||
|
tag => tag.toLowerCase() === value.toLowerCase()
|
||||||
|
);
|
||||||
|
if (!isValidTag) setValue("");
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Select a semantic gesture tag */
|
||||||
|
const handleTagSelect = (tag: string) => {
|
||||||
|
setValue(tag);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Update single-gesture input and filter suggestions */
|
||||||
|
const handleCustomChange = (newValue: string) => {
|
||||||
|
setCustomValue(newValue);
|
||||||
|
setValue(newValue);
|
||||||
|
|
||||||
|
if (newValue.trim() === "") {
|
||||||
|
setFilteredSuggestions(GESTURE_SINGLES);
|
||||||
|
setShowSuggestions(true);
|
||||||
|
} else {
|
||||||
|
const filtered = GESTURE_SINGLES.filter(single =>
|
||||||
|
single.toLowerCase().includes(newValue.toLowerCase())
|
||||||
|
);
|
||||||
|
setFilteredSuggestions(filtered);
|
||||||
|
setShowSuggestions(filtered.length > 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Commit autocomplete selection */
|
||||||
|
const handleSuggestionSelect = (suggestion: string) => {
|
||||||
|
setCustomValue(suggestion);
|
||||||
|
setValue(suggestion);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Refresh suggestions on refocus */
|
||||||
|
const handleInputFocus = () => {
|
||||||
|
if (!customValue.trim()) return;
|
||||||
|
|
||||||
|
const filtered = GESTURE_SINGLES.filter(single =>
|
||||||
|
single.toLowerCase().includes(customValue.toLowerCase())
|
||||||
|
);
|
||||||
|
setFilteredSuggestions(filtered);
|
||||||
|
setShowSuggestions(filtered.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Exists to allow delayed blur handling if needed */
|
||||||
|
const handleInputBlur = (_e: React.FocusEvent) => {};
|
||||||
|
|
||||||
|
|
||||||
|
/** Build the JSX component */
|
||||||
|
return (
|
||||||
|
<div className={styles.gestureEditor} ref={containerRef}>
|
||||||
|
{/* Mode toggle */}
|
||||||
|
<div className={styles.modeSelector}>
|
||||||
|
<label className={styles.modeLabel}>Input Mode:</label>
|
||||||
|
<div className={styles.toggleContainer}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.toggleButton} ${mode === "single" ? styles.active : ""}`}
|
||||||
|
onClick={() => handleModeChange("single")}
|
||||||
|
>
|
||||||
|
Single
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.toggleButton} ${mode === "tag" ? styles.active : ""}`}
|
||||||
|
onClick={() => handleModeChange("tag")}
|
||||||
|
>
|
||||||
|
Tag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.valueEditor} data-testid={"valueEditorTestID"}>
|
||||||
|
{mode === "single" ? (
|
||||||
|
<div className={styles.autocompleteContainer}>
|
||||||
|
{showSuggestions && (
|
||||||
|
<div className={styles.suggestionsDropdownLeft}>
|
||||||
|
{filteredSuggestions.map((suggestion) => (
|
||||||
|
<div
|
||||||
|
key={suggestion}
|
||||||
|
className={styles.suggestionItem}
|
||||||
|
onClick={() => handleSuggestionSelect(suggestion)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // prevent blur before click
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customValue}
|
||||||
|
onChange={(e) => handleCustomChange(e.target.value)}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={`${styles.textInput} ${showSuggestions ? styles.textInputWithSuggestions : ''}`}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.tagSelector}>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleTagSelect(e.target.value)}
|
||||||
|
className={styles.tagSelect}
|
||||||
|
data-testid={"tagSelectorTestID"}
|
||||||
|
>
|
||||||
|
<option value="" >Select a gesture tag...</option>
|
||||||
|
{GESTURE_TAGS.map((tag) => (
|
||||||
|
<option key={tag} value={tag}>{tag}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className={styles.tagList}>
|
||||||
|
{GESTURE_TAGS.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.tagButton} ${value === tag ? styles.selected : ""}`}
|
||||||
|
onClick={() => handleTagSelect(tag)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import {NodeToolbar, useReactFlow} from '@xyflow/react';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import {type JSX, useState} from "react";
|
||||||
|
import {createPortal} from "react-dom";
|
||||||
|
import styles from "../../VisProg.module.css";
|
||||||
|
import {NodeTooltips} from "../NodeRegistry.ts";
|
||||||
|
import useFlowStore from "../VisProgStores.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the Toolbar component.
|
||||||
|
*
|
||||||
|
* @property nodeId - The ID of the node this toolbar is attached to.
|
||||||
|
* @property allowDelete - If `true`, the delete button is enabled; otherwise disabled.
|
||||||
|
*/
|
||||||
|
type ToolbarProps = {
|
||||||
|
nodeId: string;
|
||||||
|
allowDelete: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node Toolbar definition:
|
||||||
|
* Handles: node deleting functionality
|
||||||
|
* Can be integrated to any custom node component as a React component
|
||||||
|
*
|
||||||
|
* @param {string} nodeId - The ID of the node for which the toolbar is rendered.
|
||||||
|
* @param {boolean} allowDelete - Enables or disables the delete functionality.
|
||||||
|
* @returns {React.JSX.Element} A JSX element representing the toolbar.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
|
||||||
|
const {nodes, deleteNode} = useFlowStore();
|
||||||
|
const { deleteElements } = useReactFlow();
|
||||||
|
|
||||||
|
const deleteParentNode = () => {
|
||||||
|
|
||||||
|
deleteNode(nodeId, deleteElements);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeType = nodes.find((node) => node.id === nodeId)?.type as keyof typeof NodeTooltips;
|
||||||
|
return (
|
||||||
|
<NodeToolbar className={"flex-row align-center"}>
|
||||||
|
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
|
||||||
|
<Tooltip nodeType={nodeType}>
|
||||||
|
<div className={styles.nodeToolbarTooltip}>i</div>
|
||||||
|
</Tooltip>
|
||||||
|
</NodeToolbar>);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type TooltipProps = {
|
||||||
|
nodeType?: keyof typeof NodeTooltips;
|
||||||
|
children: JSX.Element;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A general tooltip component, that can be used as a wrapper for any component
|
||||||
|
* that has a nodeType and a corresponding nodeTooltip.
|
||||||
|
*
|
||||||
|
* currently used to show tooltips for draggable-nodes and nodes inside the editor
|
||||||
|
*
|
||||||
|
* @param {"start" | "end" | "phase" | "norm" | "goal" | "trigger" | "basic_belief" | undefined} nodeType
|
||||||
|
* @param {React.JSX.Element} children
|
||||||
|
* @returns {React.JSX.Element}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export function Tooltip({ nodeType, children }: TooltipProps) {
|
||||||
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
const [disabled , setDisabled] = useState(false);
|
||||||
|
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||||
|
|
||||||
|
const updateTooltipPos = () => {
|
||||||
|
const rect = document.getElementById("draggable-sidebar")!.getBoundingClientRect();
|
||||||
|
setCoords({
|
||||||
|
// Position exactly below the bottom edge of the draggable sidebar (plus a small gap)
|
||||||
|
top: rect.bottom + 10,
|
||||||
|
left: rect.left + rect.width / 2, // Keep it horizontally centered
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return nodeType ?
|
||||||
|
(<div>
|
||||||
|
<div
|
||||||
|
onMouseDown={() => {
|
||||||
|
updateTooltipPos();
|
||||||
|
setShowTooltip(false);
|
||||||
|
setDisabled(true);
|
||||||
|
}}
|
||||||
|
onMouseUp={() => {
|
||||||
|
setDisabled(false);
|
||||||
|
}}
|
||||||
|
onMouseOver={() => {
|
||||||
|
if (!disabled) {
|
||||||
|
updateTooltipPos();
|
||||||
|
setShowTooltip(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={ () => setShowTooltip(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{showTooltip && createPortal(
|
||||||
|
<div
|
||||||
|
className={"flex-row"}
|
||||||
|
style={{
|
||||||
|
pointerEvents: 'none',
|
||||||
|
position: 'fixed',
|
||||||
|
top: `${coords.top}px`,
|
||||||
|
left: `${coords.left}px`,
|
||||||
|
transform: 'translateX(-50%)', // Center based on the midpoint
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.customTooltipHeader}>{nodeType}</span>
|
||||||
|
<span className={styles.customTooltip}>
|
||||||
|
{NodeTooltips[nodeType] || "Available for drag"}
|
||||||
|
</span>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : children
|
||||||
|
}
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import {Handle, type NodeProps, NodeToolbar, Position} from '@xyflow/react';
|
|
||||||
import '@xyflow/react/dist/style.css';
|
|
||||||
import styles from '../../VisProg.module.css';
|
|
||||||
import useFlowStore from "../VisProgStores.tsx";
|
|
||||||
import type {
|
|
||||||
StartNode,
|
|
||||||
EndNode,
|
|
||||||
PhaseNode,
|
|
||||||
NormNode
|
|
||||||
} from "../VisProgTypes.tsx";
|
|
||||||
|
|
||||||
//
|
|
||||||
|
|
||||||
type ToolbarProps = {
|
|
||||||
nodeId: string;
|
|
||||||
allowDelete: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Node Toolbar definition:
|
|
||||||
* handles: node deleting functionality
|
|
||||||
* can be added to any custom node component as a React component
|
|
||||||
*
|
|
||||||
* @param {string} nodeId
|
|
||||||
* @param {boolean} allowDelete
|
|
||||||
* @returns {React.JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
|
|
||||||
const {deleteNode} = useFlowStore();
|
|
||||||
|
|
||||||
const deleteParentNode = ()=> {
|
|
||||||
deleteNode(nodeId);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<NodeToolbar>
|
|
||||||
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
|
|
||||||
</NodeToolbar>);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Definitions of Nodes
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start Node definition:
|
|
||||||
*
|
|
||||||
* @param {string} id
|
|
||||||
* @param {defaultNodeData} data
|
|
||||||
* @returns {React.JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
export const StartNodeComponent = ({id, data}: NodeProps<StartNode>) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Toolbar nodeId={id} allowDelete={false}/>
|
|
||||||
<div className={styles.defaultNodeStart}>
|
|
||||||
<div> data test {data.label} </div>
|
|
||||||
<Handle type="source" position={Position.Right} id="start"/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End node definition:
|
|
||||||
*
|
|
||||||
* @param {string} id
|
|
||||||
* @param {defaultNodeData} data
|
|
||||||
* @returns {React.JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
export const EndNodeComponent = ({id, data}: NodeProps<EndNode>) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Toolbar nodeId={id} allowDelete={false}/>
|
|
||||||
<div className={styles.defaultNodeEnd}>
|
|
||||||
<div> {data.label} </div>
|
|
||||||
<Handle type="target" position={Position.Left} id="end"/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Phase node definition:
|
|
||||||
*
|
|
||||||
* @param {string} id
|
|
||||||
* @param {defaultNodeData & {number: number}} data
|
|
||||||
* @returns {React.JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
export const PhaseNodeComponent = ({id, data}: NodeProps<PhaseNode>) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Toolbar nodeId={id} allowDelete={true}/>
|
|
||||||
<div className={styles.defaultNodePhase}>
|
|
||||||
<div> phase {data.number} {data.label} </div>
|
|
||||||
<Handle type="target" position={Position.Left} id="target"/>
|
|
||||||
<Handle type="target" position={Position.Bottom} id="norms"/>
|
|
||||||
<Handle type="source" position={Position.Right} id="source"/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Norm node definition:
|
|
||||||
*
|
|
||||||
* @param {string} id
|
|
||||||
* @param {defaultNodeData & {value: string}} data
|
|
||||||
* @returns {React.JSX.Element}
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
export const NormNodeComponent = ({id, data}: NodeProps<NormNode>) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Toolbar nodeId={id} allowDelete={true}/>
|
|
||||||
<div className={styles.defaultNodeNorm}>
|
|
||||||
<div> Norm {data.label} </div>
|
|
||||||
<Handle type="source" position={Position.Right} id="NormSource"/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Plan, PlanElement } from "./Plan";
|
||||||
|
|
||||||
|
export const defaultPlan: Plan = {
|
||||||
|
name: "Default Plan",
|
||||||
|
id: "-1",
|
||||||
|
steps: [] as PlanElement[],
|
||||||
|
}
|
||||||
124
src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx
Normal file
124
src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { type Node } from "@xyflow/react"
|
||||||
|
import { GoalReduce } from "../nodes/GoalNode"
|
||||||
|
|
||||||
|
|
||||||
|
export type Plan = {
|
||||||
|
name: string,
|
||||||
|
id: string,
|
||||||
|
steps: PlanElement[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlanElement = Goal | Action
|
||||||
|
|
||||||
|
export type Goal = {
|
||||||
|
id: string // we let the reducer figure out the rest dynamically
|
||||||
|
type: "goal"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
export type Action = SpeechAction | GestureAction | LLMAction
|
||||||
|
export type SpeechAction = { id: string, text: string, type:"speech" }
|
||||||
|
export type GestureAction = { id: string, gesture: string, isTag: boolean, type:"gesture" }
|
||||||
|
export type LLMAction = { id: string, goal: string, type:"llm" }
|
||||||
|
export type ActionTypes = "speech" | "gesture" | "llm";
|
||||||
|
|
||||||
|
|
||||||
|
// Extract the wanted information from a plan within the reducing of nodes
|
||||||
|
export function PlanReduce(_nodes: Node[], plan?: Plan, ) {
|
||||||
|
if (!plan) return ""
|
||||||
|
return {
|
||||||
|
id: plan.id,
|
||||||
|
steps: plan.steps.map((x) => StepReduce(x, _nodes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Extract the wanted information from a plan element.
|
||||||
|
function StepReduce(planElement: PlanElement, _nodes: Node[]) : Record<string, unknown> {
|
||||||
|
// We have different types of plan elements, requiring differnt types of output
|
||||||
|
const nodes = _nodes
|
||||||
|
const thisNode = _nodes.find((x) => x.id === planElement.id)
|
||||||
|
switch (planElement.type) {
|
||||||
|
case ("speech"):
|
||||||
|
return {
|
||||||
|
id: planElement.id,
|
||||||
|
text: planElement.text,
|
||||||
|
}
|
||||||
|
case ("gesture"):
|
||||||
|
return {
|
||||||
|
id: planElement.id,
|
||||||
|
gesture: {
|
||||||
|
type: planElement.isTag ? "tag" : "single",
|
||||||
|
name: planElement.gesture
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case ("llm"):
|
||||||
|
return {
|
||||||
|
id: planElement.id,
|
||||||
|
goal: planElement.goal,
|
||||||
|
}
|
||||||
|
case ("goal"):
|
||||||
|
return thisNode ? GoalReduce(thisNode, nodes) : {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds out whether the plan can iterate multiple times, or always stops after one action.
|
||||||
|
* This comes down to checking if the plan only has speech/ gesture actions, or others as well.
|
||||||
|
* @param plan: the plan to check
|
||||||
|
* @returns: a boolean
|
||||||
|
*/
|
||||||
|
export function DoesPlanIterate( _nodes: Node[], plan?: Plan,) : boolean {
|
||||||
|
// TODO: should recursively check plans that have goals (and thus more plans) in them.
|
||||||
|
if (!plan) return false
|
||||||
|
return plan.steps.filter((step) => step.type == "llm").length > 0 ||
|
||||||
|
(
|
||||||
|
// Find the goal node of this step
|
||||||
|
plan.steps.filter((step) => step.type == "goal").map((goalStep) => {
|
||||||
|
const goalId = goalStep.id;
|
||||||
|
const goalNode = _nodes.find((x) => x.id === goalId);
|
||||||
|
// In case we don't find any valid plan, this node doesn't iterate
|
||||||
|
if (!goalNode || !goalNode.data.plan) return false;
|
||||||
|
// Otherwise, check if this node can fail - if so, we should have the option to iterate
|
||||||
|
return (goalNode && goalNode.data.plan && goalNode.data.can_fail)
|
||||||
|
})
|
||||||
|
).includes(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if any of the plan's goal steps has its can_fail value set to true.
|
||||||
|
* @param plan: plan to check
|
||||||
|
* @param _nodes: nodes in flow store.
|
||||||
|
*/
|
||||||
|
export function HasCheckingSubGoal(plan: Plan, _nodes: Node[]) {
|
||||||
|
const goalSteps = plan.steps.filter((x) => x.type == "goal");
|
||||||
|
return goalSteps.map((goalStep) => {
|
||||||
|
// Find the goal node and check its can_fail data boolean.
|
||||||
|
const goalId = goalStep.id;
|
||||||
|
const goalNode = _nodes.find((x) => x.id === goalId);
|
||||||
|
return (goalNode && goalNode.data.can_fail)
|
||||||
|
}).includes(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the value of the action.
|
||||||
|
* Since typescript can't polymorphicly access the value field,
|
||||||
|
* we need to switch over the types and return the correct field.
|
||||||
|
* @param action: action to retrieve the value from
|
||||||
|
* @returns string | undefined
|
||||||
|
*/
|
||||||
|
export function GetActionValue(action: Action) {
|
||||||
|
let returnAction;
|
||||||
|
switch (action.type) {
|
||||||
|
case "gesture":
|
||||||
|
returnAction = action as GestureAction
|
||||||
|
return returnAction.gesture;
|
||||||
|
case "speech":
|
||||||
|
returnAction = action as SpeechAction
|
||||||
|
return returnAction.text;
|
||||||
|
case "llm":
|
||||||
|
returnAction = action as LLMAction
|
||||||
|
return returnAction.goal;
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// This file is to avoid sharing both functions and components which eslint dislikes. :)
|
||||||
|
import type { GoalNode } from "../nodes/GoalNode"
|
||||||
|
import type { Goal, Plan } from "./Plan"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a goal into a plan
|
||||||
|
* @param plan: plan to insert goal into
|
||||||
|
* @param goalNode: the goal node to insert into the plan.
|
||||||
|
* @returns: a new plan with the goal inside.
|
||||||
|
*/
|
||||||
|
export function insertGoalInPlan(plan: Plan, goalNode: GoalNode): Plan {
|
||||||
|
const planElement : Goal = {
|
||||||
|
id: goalNode.id,
|
||||||
|
type: "goal",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...plan,
|
||||||
|
steps: [...plan.steps, planElement],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a goal from a plan
|
||||||
|
* @param plan: plan to delete goal from
|
||||||
|
* @param goalID: the goal node to delete.
|
||||||
|
* @returns: a new plan with the goal removed.
|
||||||
|
*/
|
||||||
|
export function deleteGoalInPlanByID(plan: Plan, goalID: string) {
|
||||||
|
const updatedPlan = {...plan,
|
||||||
|
steps: plan.steps.filter((x) => x.id !== goalID)
|
||||||
|
}
|
||||||
|
return updatedPlan.steps.length == 0 ? undefined : updatedPlan
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
.planDialog {
|
||||||
|
overflow:visible;
|
||||||
|
width: 80vw;
|
||||||
|
max-width: 900px;
|
||||||
|
transition: width 0.25s ease;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.planDialog::backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planEditor {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planEditorLeft {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planEditorRight {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-left: 1px solid var(--border-color, #ccc);
|
||||||
|
padding-left: 1rem;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planStep {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: text-decoration 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.planStep:hover {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepType {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.stepIndex {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptySteps {
|
||||||
|
opacity: 0.5;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepSuggestion {
|
||||||
|
opacity: 0.5;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import {useRef, useState} from "react";
|
||||||
|
import useFlowStore from "../VisProgStores.tsx";
|
||||||
|
import styles from './PlanEditor.module.css';
|
||||||
|
import { GetActionValue, type Action, type ActionTypes, type Plan } from "../components/Plan";
|
||||||
|
import { defaultPlan } from "../components/Plan.default";
|
||||||
|
import { TextField } from "../../../../components/TextField";
|
||||||
|
import GestureValueEditor from "./GestureValueEditor";
|
||||||
|
|
||||||
|
type PlanEditorDialogProps = {
|
||||||
|
plan?: Plan;
|
||||||
|
onSave: (plan: Plan | undefined) => void;
|
||||||
|
description? : string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PlanEditorDialog({
|
||||||
|
plan,
|
||||||
|
onSave,
|
||||||
|
description,
|
||||||
|
}: PlanEditorDialogProps) {
|
||||||
|
// UseStates and references
|
||||||
|
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||||
|
const [draftPlan, setDraftPlan] = useState<Plan | null>(null);
|
||||||
|
const [newActionType, setNewActionType] = useState<ActionTypes>("speech");
|
||||||
|
const [newActionGestureType, setNewActionGestureType] = useState<boolean>(true);
|
||||||
|
const [newActionValue, setNewActionValue] = useState("");
|
||||||
|
const [hasInteractedWithPlan, setHasInteractedWithPlan] = useState<boolean>(false)
|
||||||
|
const { setScrollable } = useFlowStore();
|
||||||
|
const nodes = useFlowStore().nodes;
|
||||||
|
|
||||||
|
//Button Actions
|
||||||
|
const openCreate = () => {
|
||||||
|
setScrollable(false);
|
||||||
|
setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()});
|
||||||
|
dialogRef.current?.showModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateWithDescription = () => {
|
||||||
|
setScrollable(false);
|
||||||
|
setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID(), name: description!});
|
||||||
|
setNewActionType("llm")
|
||||||
|
setNewActionValue(description!)
|
||||||
|
dialogRef.current?.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = () => {
|
||||||
|
setScrollable(false);
|
||||||
|
if (!plan) return;
|
||||||
|
setDraftPlan(structuredClone(plan));
|
||||||
|
dialogRef.current?.showModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setScrollable(true);
|
||||||
|
dialogRef.current?.close();
|
||||||
|
setDraftPlan(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildAction = (): Action => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
setHasInteractedWithPlan(true)
|
||||||
|
switch (newActionType) {
|
||||||
|
case "speech":
|
||||||
|
return { id, text: newActionValue, type: "speech" };
|
||||||
|
case "gesture":
|
||||||
|
return { id, gesture: newActionValue, isTag: newActionGestureType, type: "gesture" };
|
||||||
|
case "llm":
|
||||||
|
return { id, goal: newActionValue, type: "llm" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (<>
|
||||||
|
{/* Create and edit buttons */}
|
||||||
|
{!plan && (
|
||||||
|
<button className={styles.nodeButton} onClick={description ? openCreateWithDescription : openCreate}>
|
||||||
|
Create Plan
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{plan && (
|
||||||
|
<button className={styles.nodeButton} onClick={openEdit}>
|
||||||
|
Edit Plan
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Start of dialog (plan editor) */}
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
className={`${styles.planDialog}`}
|
||||||
|
//onWheel={(e) => e.stopPropagation()}
|
||||||
|
data-testid={"PlanEditorDialogTestID"}
|
||||||
|
>
|
||||||
|
<form method="dialog" className="flex-col gap-md">
|
||||||
|
<h3> {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"} </h3>
|
||||||
|
{/* Plan name text field */}
|
||||||
|
{draftPlan && (
|
||||||
|
<TextField
|
||||||
|
value={draftPlan.name}
|
||||||
|
setValue={(name) =>
|
||||||
|
setDraftPlan({ ...draftPlan, name })}
|
||||||
|
placeholder="Plan name"
|
||||||
|
data-testid="name_text_field"/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Entire "bottom" part (adder and steps) without cancel, confirm and reset */}
|
||||||
|
{draftPlan && (<div className={styles.planEditor}>
|
||||||
|
<div className={styles.planEditorLeft}>
|
||||||
|
{/* Left Side (Action Adder) */}
|
||||||
|
<h4>Add Action</h4>
|
||||||
|
{(!plan && description && draftPlan.steps.length === 0 && !hasInteractedWithPlan) && (<div className={styles.stepSuggestion}>
|
||||||
|
<label> Filled in as a suggestion! </label>
|
||||||
|
<label> Feel free to change! </label>
|
||||||
|
</div>)}
|
||||||
|
<label>
|
||||||
|
Action Type <wbr />
|
||||||
|
{/* Type selection */}
|
||||||
|
<select
|
||||||
|
value={newActionType}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewActionType(e.target.value as ActionTypes);
|
||||||
|
// Reset value when action type changes
|
||||||
|
setNewActionValue("");
|
||||||
|
}}>
|
||||||
|
<option value="speech">Speech Action</option>
|
||||||
|
<option value="gesture">Gesture Action</option>
|
||||||
|
<option value="llm">LLM Action</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Action value editor*/}
|
||||||
|
{newActionType === "gesture" ? (
|
||||||
|
// Gesture get their own editor component
|
||||||
|
<GestureValueEditor
|
||||||
|
value={newActionValue}
|
||||||
|
setValue={setNewActionValue}
|
||||||
|
setType={setNewActionGestureType}
|
||||||
|
placeholder="Gesture name"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
value={newActionValue}
|
||||||
|
setValue={setNewActionValue}
|
||||||
|
placeholder={
|
||||||
|
newActionType === "speech" ? "Speech text"
|
||||||
|
: "LLM goal"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Adding steps */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!newActionValue}
|
||||||
|
onClick={() => {
|
||||||
|
if (!draftPlan) return;
|
||||||
|
// Add action to steps
|
||||||
|
const action = buildAction();
|
||||||
|
setDraftPlan({
|
||||||
|
...draftPlan,
|
||||||
|
steps: [...draftPlan.steps, action],});
|
||||||
|
|
||||||
|
// Reset current action building
|
||||||
|
setNewActionValue("");
|
||||||
|
setNewActionType("speech");
|
||||||
|
}}>
|
||||||
|
Add Step
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side (Steps shown) */}
|
||||||
|
<div className={styles.planEditorRight}>
|
||||||
|
<h4>Steps</h4>
|
||||||
|
|
||||||
|
{/* Show if there are no steps yet */}
|
||||||
|
{draftPlan.steps.length === 0 && (
|
||||||
|
<div className={styles.emptySteps}>
|
||||||
|
No steps yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* Map over all steps */}
|
||||||
|
{draftPlan.steps.map((step, index) => (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
key={step.id}
|
||||||
|
className={styles.planStep}
|
||||||
|
// Extra logic for screen readers to access using keyboard
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
setDraftPlan({
|
||||||
|
...draftPlan,
|
||||||
|
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
|
||||||
|
}}}
|
||||||
|
onClick={() => {
|
||||||
|
setDraftPlan({
|
||||||
|
...draftPlan,
|
||||||
|
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
|
||||||
|
}}>
|
||||||
|
|
||||||
|
<span className={styles.stepIndex}>{index + 1}.</span>
|
||||||
|
<span className={styles.stepType}>{step.type}:</span>
|
||||||
|
<span className={styles.stepName}>
|
||||||
|
{
|
||||||
|
// This just tries to find the goals name, i know it looks ugly:(
|
||||||
|
step.type === "goal"
|
||||||
|
? ((nodes.find(x => x.id === step.id)?.data.name as string) == "" ?
|
||||||
|
"unnamed goal": (nodes.find(x => x.id === step.id)?.data.name as string))
|
||||||
|
: (GetActionValue(step) ?? "")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex-row gap-md">
|
||||||
|
{/* Close button */}
|
||||||
|
<button type="button" onClick={close}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Confirm/ Create button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!draftPlan}
|
||||||
|
onClick={() => {
|
||||||
|
if (!draftPlan) return;
|
||||||
|
onSave(draftPlan);
|
||||||
|
close();
|
||||||
|
}}>
|
||||||
|
{draftPlan?.id === plan?.id ? "Confirm" : "Create"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Reset button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!draftPlan}
|
||||||
|
onClick={() => {
|
||||||
|
onSave(undefined);
|
||||||
|
close();
|
||||||
|
}}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
:global(.react-flow__handle.source){
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
|
:global(.react-flow__handle.target){
|
||||||
|
border-radius: 15%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
:global(.react-flow__handle.connected) {
|
||||||
|
background: lightgray;
|
||||||
|
border-color: green;
|
||||||
|
filter: drop-shadow(0 0 0.15rem green);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.singleConnectionHandle.connected) {
|
||||||
|
background: #55dd99;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.react-flow__handle.unconnected){
|
||||||
|
background: lightgray;
|
||||||
|
border-color: gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.singleConnectionHandle.unconnected){
|
||||||
|
background: lightsalmon;
|
||||||
|
border-color: #ff6060;
|
||||||
|
filter: drop-shadow(0 0 0.15rem #ff6060);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.react-flow__handle.connectingto) {
|
||||||
|
background: #ff6060;
|
||||||
|
border-color: coral;
|
||||||
|
filter: drop-shadow(0 0 0.15rem coral);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.react-flow__handle.valid) {
|
||||||
|
background: #55dd99;
|
||||||
|
border-color: green;
|
||||||
|
filter: drop-shadow(0 0 0.15rem green);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.react-flow__handle) {
|
||||||
|
width: calc(8px / var(--flow-zoom, 1));
|
||||||
|
height: calc(8px / var(--flow-zoom, 1));
|
||||||
|
transition: width 0.1s ease, height 0.1s ease;
|
||||||
|
min-width: 8px;
|
||||||
|
min-height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
Handle,
|
||||||
|
type HandleProps,
|
||||||
|
type Connection,
|
||||||
|
useNodeId, useNodeConnections
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import { type HandleRule, useHandleRules} from "../HandleRuleLogic.ts";
|
||||||
|
import "./RuleBasedHandle.module.css";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function MultiConnectionHandle({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
rules = [],
|
||||||
|
...otherProps
|
||||||
|
} : HandleProps & { rules?: HandleRule[]}) {
|
||||||
|
let nodeId = useNodeId();
|
||||||
|
// this check is used to make sure that the handle code doesn't break when used inside a test,
|
||||||
|
// since useNodeId would be undefined if the handle is not used inside a node
|
||||||
|
nodeId = nodeId ? nodeId : "mockId";
|
||||||
|
const validate = useHandleRules(nodeId, id!, type!, rules);
|
||||||
|
|
||||||
|
|
||||||
|
const connections = useNodeConnections({
|
||||||
|
id: nodeId,
|
||||||
|
handleType: type,
|
||||||
|
handleId: id!
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Handle
|
||||||
|
{...otherProps}
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
className={"multiConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected") + ` ${type}`}
|
||||||
|
isValidConnection={(connection) => {
|
||||||
|
const result = validate(connection as Connection);
|
||||||
|
return result.isSatisfied;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SingleConnectionHandle({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
rules = [],
|
||||||
|
...otherProps
|
||||||
|
} : HandleProps & { rules?: HandleRule[]}) {
|
||||||
|
let nodeId = useNodeId();
|
||||||
|
// this check is used to make sure that the handle code doesn't break when used inside a test,
|
||||||
|
// since useNodeId would be undefined if the handle is not used inside a node
|
||||||
|
nodeId = nodeId ? nodeId : "mockId";
|
||||||
|
const validate = useHandleRules(nodeId, id!, type!, rules);
|
||||||
|
|
||||||
|
const connections = useNodeConnections({
|
||||||
|
id: nodeId,
|
||||||
|
handleType: type,
|
||||||
|
handleId: id!
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Handle
|
||||||
|
{...otherProps}
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
className={"singleConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected") + ` ${type}`}
|
||||||
|
isConnectable={connections.length === 0}
|
||||||
|
isValidConnection={(connection) => {
|
||||||
|
const result = validate(connection as Connection);
|
||||||
|
return result.isSatisfied;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
.warnings-sidebar {
|
||||||
|
min-width: auto;
|
||||||
|
max-width: 340px;
|
||||||
|
margin-right: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: canvas;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnings-toggle-bar {
|
||||||
|
background-color: ButtonFace;
|
||||||
|
justify-items: center;
|
||||||
|
align-content: center;
|
||||||
|
width: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnings-toggle-bar.error:first-child:has(.arrow-right){
|
||||||
|
background-color: hsl(from red h s 75%);
|
||||||
|
}
|
||||||
|
.warnings-toggle-bar.warning:first-child:has(.arrow-right) {
|
||||||
|
background-color: hsl(from orange h s 75%);
|
||||||
|
}
|
||||||
|
.warnings-toggle-bar.info:first-child:has(.arrow-right) {
|
||||||
|
background-color: hsl(from steelblue h s 75%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnings-toggle-bar:hover {
|
||||||
|
background-color: GrayText !important ;
|
||||||
|
.arrow-left {
|
||||||
|
border-right-color: ButtonFace;
|
||||||
|
transition: transform 0.15s ease-in-out;
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
.arrow-right {
|
||||||
|
border-left-color: ButtonFace;
|
||||||
|
transition: transform 0.15s ease-in-out;
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.warnings-content {
|
||||||
|
width: 320px;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
border-left: 2px solid CanvasText;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnings-header {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 2px solid CanvasText;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px;
|
||||||
|
background: ButtonFace;
|
||||||
|
color: GrayText;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
padding: 4px;
|
||||||
|
color: GrayText;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-tab.active {
|
||||||
|
color: ButtonText;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
.count {
|
||||||
|
color: ButtonText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-group-header {
|
||||||
|
background: ButtonFace;
|
||||||
|
padding: 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnings-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnings-empty {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 5px;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: GrayText;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-item:hover {
|
||||||
|
background: ButtonFace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-item--error {
|
||||||
|
border: 2px solid red;
|
||||||
|
background-color: hsl(from red h s 96%);
|
||||||
|
.item-header{
|
||||||
|
background-color: red;
|
||||||
|
.type{
|
||||||
|
color: hsl(from red h s 96%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-item--error:hover {
|
||||||
|
background-color: hsl(from red h s 75%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-item--warning {
|
||||||
|
border: 2px solid orange;
|
||||||
|
background-color: hsl(from orange h s 96%);
|
||||||
|
.item-header{
|
||||||
|
background-color: orange;
|
||||||
|
.type{
|
||||||
|
color: hsl(from orange h s 96%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-item--warning:hover {
|
||||||
|
background-color: hsl(from orange h s 75%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-item--info {
|
||||||
|
border: 2px solid steelblue;
|
||||||
|
background-color: hsl(from steelblue h s 96%);
|
||||||
|
.item-header{
|
||||||
|
background-color: steelblue;
|
||||||
|
.type{
|
||||||
|
color: hsl(from steelblue h s 96%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-item--info:hover {
|
||||||
|
background-color: hsl(from steelblue h s 75%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-item .item-header {
|
||||||
|
padding: 8px 8px;
|
||||||
|
opacity: 1;
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
.warning-item .item-header .type{
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-item .description {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-hide {
|
||||||
|
background-color: Canvas;
|
||||||
|
border-top: 2px solid CanvasText;
|
||||||
|
margin-top: auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* arrows for toggleBar */
|
||||||
|
.arrow-right {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-top: 0.5rem solid transparent;
|
||||||
|
border-bottom: 0.5rem solid transparent;
|
||||||
|
border-left: 0.6rem solid GrayText;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-left {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-top: 0.5rem solid transparent;
|
||||||
|
border-bottom: 0.5rem solid transparent;
|
||||||
|
border-right: 0.6rem solid GrayText;
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import {useReactFlow, useStoreApi} from "@xyflow/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import useFlowStore from "../VisProgStores.tsx";
|
||||||
|
import {
|
||||||
|
warningSummary,
|
||||||
|
type WarningSeverity,
|
||||||
|
type EditorWarning, globalWarning
|
||||||
|
} from "./EditorWarnings.tsx";
|
||||||
|
import styles from "./WarningSidebar.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the warning sidebar, shows all warnings
|
||||||
|
*
|
||||||
|
* @returns {React.JSX.Element}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export function WarningsSidebar() {
|
||||||
|
const warnings = useFlowStore.getState().getWarnings();
|
||||||
|
const [hide, setHide] = useState(false);
|
||||||
|
const [severityFilter, setSeverityFilter] = useState<WarningSeverity | 'ALL'>('ALL');
|
||||||
|
const [autoHide, setAutoHide] = useState(false);
|
||||||
|
|
||||||
|
// let autohide change hide status only when autohide is toggled
|
||||||
|
// and allow for user to change the hide state even if autohide is enabled
|
||||||
|
const hasWarnings = warnings.length > 0;
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoHide) {
|
||||||
|
setHide(!hasWarnings);
|
||||||
|
}
|
||||||
|
}, [autoHide, hasWarnings]);
|
||||||
|
|
||||||
|
const filtered = severityFilter === 'ALL'
|
||||||
|
? warnings
|
||||||
|
: warnings.filter(w => w.severity === severityFilter);
|
||||||
|
|
||||||
|
|
||||||
|
const summary = warningSummary();
|
||||||
|
// Finds the first key where the count > 0
|
||||||
|
const getHighestSeverity = () => {
|
||||||
|
if (summary.error > 0) return styles.error;
|
||||||
|
if (summary.warning > 0) return styles.warning;
|
||||||
|
if (summary.info > 0) return styles.info;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={`flex-row`} >
|
||||||
|
<div
|
||||||
|
className={`${styles.warningsToggleBar} ${getHighestSeverity()}`}
|
||||||
|
onClick={() => setHide(!hide)}
|
||||||
|
title={"toggle warnings"}
|
||||||
|
>
|
||||||
|
<div className={`${hide ? styles.arrowRight : styles.arrowLeft}`}></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="warningSidebar"
|
||||||
|
className={styles.warningsContent}
|
||||||
|
style={hide ? {display: "none"} : {display: "flex"}}
|
||||||
|
>
|
||||||
|
<WarningsHeader
|
||||||
|
severityFilter={severityFilter}
|
||||||
|
onChange={setSeverityFilter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WarningsList warnings={filtered} />
|
||||||
|
<div className={styles.autoHide}>
|
||||||
|
<input
|
||||||
|
id="autoHideSwitch"
|
||||||
|
type={"checkbox"}
|
||||||
|
checked={autoHide}
|
||||||
|
onChange={(e) => setAutoHide(e.target.checked)}
|
||||||
|
/><label>Hide if there are no warnings</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the header of the warning sidebar, contains severity filtering buttons
|
||||||
|
*
|
||||||
|
* @param {WarningSeverity | "ALL"} severityFilter
|
||||||
|
* @param {(severity: (WarningSeverity | "ALL")) => void} onChange
|
||||||
|
* @returns {React.JSX.Element}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function WarningsHeader({
|
||||||
|
severityFilter,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
severityFilter: WarningSeverity | 'ALL';
|
||||||
|
onChange: (severity: WarningSeverity | 'ALL') => void;
|
||||||
|
}) {
|
||||||
|
const summary = warningSummary();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.warningsHeader}>
|
||||||
|
<h3>Warnings</h3>
|
||||||
|
<div className={styles.severityTabs}>
|
||||||
|
{(['ALL', 'ERROR', 'WARNING', 'INFO'] as const).map(severity => (
|
||||||
|
<button
|
||||||
|
key={severity}
|
||||||
|
className={clsx(styles.severityTab, severityFilter === severity && styles.active)}
|
||||||
|
onClick={() => onChange(severity)}
|
||||||
|
>
|
||||||
|
{severity}
|
||||||
|
{severity !== 'ALL' && (
|
||||||
|
<span className={styles.count}>
|
||||||
|
{summary[severity.toLowerCase() as keyof typeof summary]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the list of warnings in the warning sidebar
|
||||||
|
*
|
||||||
|
* @param {{warnings: EditorWarning[]}} props
|
||||||
|
* @returns {React.JSX.Element}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function WarningsList(props: { warnings: EditorWarning[] }) {
|
||||||
|
const splitWarnings = {
|
||||||
|
global: props.warnings.filter(w => w.scope.id === globalWarning),
|
||||||
|
other: props.warnings.filter(w => w.scope.id !== globalWarning),
|
||||||
|
}
|
||||||
|
if (props.warnings.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={styles.warningsEmpty}>
|
||||||
|
No warnings!
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.warningsList}>
|
||||||
|
<div className={styles.warningGroupHeader}>global:</div>
|
||||||
|
<div className={styles.warningsGroup}>
|
||||||
|
{splitWarnings.global.map((warning) => (
|
||||||
|
<WarningListItem warning={warning} key={`${warning.scope.id}|${warning.type}` + (warning.scope.handleId
|
||||||
|
? `:${warning.scope.handleId}`
|
||||||
|
: "")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{splitWarnings.global.length === 0 && "No global warnings!"}
|
||||||
|
</div>
|
||||||
|
<div className={styles.warningGroupHeader}>other:</div>
|
||||||
|
<div className={styles.warningsGroup}>
|
||||||
|
{splitWarnings.other.map((warning) => (
|
||||||
|
<WarningListItem warning={warning} key={`${warning.scope.id}|${warning.type}` + (warning.scope.handleId
|
||||||
|
? `:${warning.scope.handleId}`
|
||||||
|
: "")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{splitWarnings.other.length === 0 && "No other warnings!"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a single warning in the warning sidebar
|
||||||
|
*
|
||||||
|
* @param {{warning: EditorWarning, key: string}} props
|
||||||
|
* @returns {React.JSX.Element}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function WarningListItem(props: { warning: EditorWarning, key: string}) {
|
||||||
|
const jumpToNode = useJumpToNode();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(styles.warningItem, styles[`warning-item--${props.warning.severity.toLowerCase()}`],)}
|
||||||
|
onClick={() => jumpToNode(props.warning.scope.id)}
|
||||||
|
>
|
||||||
|
<div className={styles.itemHeader}>
|
||||||
|
<span className={styles.type}>{props.warning.type}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.description}>
|
||||||
|
{props.warning.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* moves the editor to the provided node
|
||||||
|
* @returns {(nodeId: string) => void}
|
||||||
|
*/
|
||||||
|
function useJumpToNode() {
|
||||||
|
const { getNode, setCenter, getViewport } = useReactFlow();
|
||||||
|
const { addSelectedNodes } = useStoreApi().getState();
|
||||||
|
|
||||||
|
|
||||||
|
return (nodeId: string) => {
|
||||||
|
// user can't jump to global warning, so prevent further logic from running if the warning is a globalWarning
|
||||||
|
if (nodeId === globalWarning) return;
|
||||||
|
const node = getNode(nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
const nodeElement = document.querySelector(`.react-flow__node[data-id="${nodeId}"]`) as HTMLElement;
|
||||||
|
const { position } = node;
|
||||||
|
const viewport = getViewport();
|
||||||
|
const { width, height } = nodeElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
//move to node
|
||||||
|
setCenter(
|
||||||
|
position!.x + ((width / viewport.zoom) / 2),
|
||||||
|
position!.y + ((height / viewport.zoom) / 2),
|
||||||
|
{duration: 300, interpolate: "smooth" }
|
||||||
|
).then(() => {
|
||||||
|
addSelectedNodes([nodeId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { BasicBeliefNodeData } from "./BasicBeliefNode.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default data for this node
|
||||||
|
*/
|
||||||
|
export const BasicBeliefNodeDefaults: BasicBeliefNodeData = {
|
||||||
|
label: "Belief",
|
||||||
|
droppable: true,
|
||||||
|
belief: {type: "keyword", id: "", value: "", label: "Keyword said:"},
|
||||||
|
hasReduce: true,
|
||||||
|
};
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import {
|
||||||
|
type NodeProps,
|
||||||
|
Position,
|
||||||
|
type Node,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import { Toolbar } from '../components/NodeComponents.tsx';
|
||||||
|
import styles from '../../VisProg.module.css';
|
||||||
|
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
|
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
|
||||||
|
import useFlowStore from '../VisProgStores.tsx';
|
||||||
|
import { TextField } from '../../../../components/TextField.tsx';
|
||||||
|
import { MultilineTextField } from '../../../../components/MultilineTextField.tsx';
|
||||||
|
import {noMatchingLeftRightBelief} from "./BeliefGlobals.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default data structure for a BasicBelief node
|
||||||
|
*
|
||||||
|
* Represents configuration for a node that activates when a specific condition is met,
|
||||||
|
* such as keywords being spoken or emotions detected.
|
||||||
|
*
|
||||||
|
* @property label: the display label of this BasicBelief node.
|
||||||
|
* @property droppable: Whether this node can be dropped from the toolbar (default: true).
|
||||||
|
* @property BasicBeliefType - The type of BasicBelief ("keywords" or a custom string).
|
||||||
|
* @property BasicBeliefs - The list of keyword BasicBeliefs (if applicable).
|
||||||
|
* @property hasReduce - Whether this node supports reduction logic.
|
||||||
|
*/
|
||||||
|
export type BasicBeliefNodeData = {
|
||||||
|
label: string;
|
||||||
|
droppable: boolean;
|
||||||
|
belief: BasicBeliefType;
|
||||||
|
hasReduce: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// These are all the types a basic belief could be.
|
||||||
|
export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion
|
||||||
|
type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"};
|
||||||
|
type Semantic = { type: "semantic", id: string, value: string, description: string, label: "Detected with LLM:"};
|
||||||
|
type DetectedObject = { type: "object", id: string, value: string, label: "Object found:"};
|
||||||
|
type Emotion = { type: "emotion", id: string, value: string, label: "Emotion recognised:"};
|
||||||
|
|
||||||
|
export type BasicBeliefNode = Node<BasicBeliefNodeData>
|
||||||
|
|
||||||
|
// update the tooltip to reflect newly added connection options for a belief
|
||||||
|
export const BasicBeliefTooltip = `
|
||||||
|
A belief describes a condition that must be met
|
||||||
|
in order for a connected norm to be activated`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the target
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the received connection
|
||||||
|
*/
|
||||||
|
export function BasicBeliefConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the source
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the created connection
|
||||||
|
*/
|
||||||
|
export function BasicBeliefConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the target
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the disconnected connection
|
||||||
|
*/
|
||||||
|
export function BasicBeliefDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the source
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the diconnected connection
|
||||||
|
*/
|
||||||
|
export function BasicBeliefDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how a BasicBelief node should be rendered
|
||||||
|
* @param props - Node properties provided by React Flow, including `id` and `data`.
|
||||||
|
* @returns The rendered BasicBeliefNode React element (React.JSX.Element).
|
||||||
|
*/
|
||||||
|
export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
|
||||||
|
const data = props.data;
|
||||||
|
const {updateNodeData} = useFlowStore();
|
||||||
|
const updateValue = (value: string) => updateNodeData(props.id, {...data, belief: {...data.belief, value: value}});
|
||||||
|
const label_input_id = `basic_belief_${props.id}_label_input`;
|
||||||
|
|
||||||
|
type BeliefString = BasicBeliefType["type"];
|
||||||
|
|
||||||
|
function updateBeliefType(newType: BeliefString) {
|
||||||
|
updateNodeData(props.id, {
|
||||||
|
...data,
|
||||||
|
belief: {
|
||||||
|
...data.belief,
|
||||||
|
type: newType,
|
||||||
|
value:
|
||||||
|
newType === "emotion"
|
||||||
|
? emotionOptions[0]
|
||||||
|
: data.belief.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const setBeliefDescription = (value: string) => {
|
||||||
|
updateNodeData(props.id, {...data, belief: {...data.belief, description: value}});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use this
|
||||||
|
const emotionOptions = ["Happy", "Angry", "Sad", "Cheerful"]
|
||||||
|
|
||||||
|
|
||||||
|
let placeholder = ""
|
||||||
|
let wrapping = ""
|
||||||
|
switch (props.data.belief.type) {
|
||||||
|
case ("keyword"):
|
||||||
|
placeholder = "keyword..."
|
||||||
|
wrapping = '"'
|
||||||
|
break;
|
||||||
|
case ("semantic"):
|
||||||
|
placeholder = "short description..."
|
||||||
|
wrapping = '"'
|
||||||
|
break;
|
||||||
|
case ("object"):
|
||||||
|
placeholder = "object..."
|
||||||
|
break;
|
||||||
|
case ("emotion"):
|
||||||
|
// TODO: emotion should probably be a drop-down menu rather than a string
|
||||||
|
// So this placeholder won't hold for always
|
||||||
|
placeholder = "emotion..."
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeBasicBelief /*TODO: Change this*/}`}>
|
||||||
|
<div className={"flex-center-x gap-sm"}>
|
||||||
|
<label htmlFor={label_input_id}>Belief:</label>
|
||||||
|
</div>
|
||||||
|
<div className={"flex-row gap-sm"}>
|
||||||
|
<select
|
||||||
|
value={data.belief.type}
|
||||||
|
onChange={(e) => updateBeliefType(e.target.value as BeliefString)}
|
||||||
|
>
|
||||||
|
<option value="keyword">Keyword said:</option>
|
||||||
|
<option value="semantic">Detected with LLM:</option>
|
||||||
|
<option value="object">Object found:</option>
|
||||||
|
<option value="emotion">Emotion recognised:</option>
|
||||||
|
</select>
|
||||||
|
{wrapping}
|
||||||
|
{data.belief.type === "emotion" && (
|
||||||
|
<select
|
||||||
|
value={data.belief.value}
|
||||||
|
onChange={(e) => updateValue(e.target.value)}
|
||||||
|
>
|
||||||
|
{emotionOptions.map((emotion) => (
|
||||||
|
<option key={emotion} value={emotion.toLowerCase()}>
|
||||||
|
{emotion}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.belief.type !== "emotion" &&
|
||||||
|
(<TextField
|
||||||
|
id={label_input_id}
|
||||||
|
value={data.belief.value}
|
||||||
|
setValue={updateValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>)}
|
||||||
|
{wrapping}
|
||||||
|
</div>
|
||||||
|
{data.belief.type === "semantic" && (
|
||||||
|
<div className={"flex-wrap padding-sm"}>
|
||||||
|
<MultilineTextField
|
||||||
|
value={data.belief.description}
|
||||||
|
setValue={setBeliefDescription}
|
||||||
|
placeholder={"Describe a detailed desciption of this LLM belief..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||||
|
noMatchingLeftRightBelief,
|
||||||
|
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"}]),
|
||||||
|
]} title="Connect to any number of trigger and/or normNode(-s)"/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces each BasicBelief, including its children down into its core data.
|
||||||
|
* @param node - The BasicBelief node to reduce.
|
||||||
|
* @param _nodes - The list of all nodes in the current flow graph.
|
||||||
|
* @returns A simplified object containing the node label and its list of BasicBeliefs.
|
||||||
|
*/
|
||||||
|
export function BasicBeliefReduce(node: Node, _nodes: Node[]) {
|
||||||
|
const data = node.data as BasicBeliefNodeData;
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
id: node.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (data.belief.type) {
|
||||||
|
case "emotion":
|
||||||
|
result["emotion"] = data.belief.value;
|
||||||
|
break;
|
||||||
|
case "keyword":
|
||||||
|
result["keyword"] = data.belief.value;
|
||||||
|
break;
|
||||||
|
case "object":
|
||||||
|
result["object"] = data.belief.value;
|
||||||
|
break;
|
||||||
|
case "semantic":
|
||||||
|
result["name"] = data.belief.value;
|
||||||
|
result["description"] = data.belief.description;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {getOutgoers, type Node} from '@xyflow/react';
|
||||||
|
import {type HandleRule, type RuleResult, ruleResult} from "../HandleRuleLogic.ts";
|
||||||
|
import useFlowStore from "../VisProgStores.tsx";
|
||||||
|
import {BasicBeliefReduce} from "./BasicBeliefNode.tsx";
|
||||||
|
import {type InferredBeliefNodeData, InferredBeliefReduce} from "./InferredBeliefNode.tsx";
|
||||||
|
|
||||||
|
export function BeliefGlobalReduce(beliefNode: Node, nodes: Node[]) {
|
||||||
|
switch (beliefNode.type) {
|
||||||
|
case 'basic_belief':
|
||||||
|
return BasicBeliefReduce(beliefNode, nodes);
|
||||||
|
case 'inferred_belief':
|
||||||
|
return InferredBeliefReduce(beliefNode, nodes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const noMatchingLeftRightBelief : HandleRule = (connection, _)=> {
|
||||||
|
const { nodes } = useFlowStore.getState();
|
||||||
|
const thisNode = nodes.find(node => node.id === connection.target && node.type === 'inferred_belief');
|
||||||
|
if (!thisNode) return ruleResult.satisfied;
|
||||||
|
|
||||||
|
const iBelief = (thisNode.data as InferredBeliefNodeData).inferredBelief;
|
||||||
|
return (iBelief.left === connection.source || iBelief.right === connection.source)
|
||||||
|
? ruleResult.notSatisfied("Connecting one belief to both input handles of an inferred belief node is not allowed")
|
||||||
|
: ruleResult.satisfied;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* makes it impossible to connect Inferred belief nodes
|
||||||
|
* if the connection would create a cyclical connection between inferred beliefs
|
||||||
|
*/
|
||||||
|
export const noBeliefCycles: HandleRule = (connection, _): RuleResult => {
|
||||||
|
const {nodes, edges} = useFlowStore.getState();
|
||||||
|
const defaultErrorMessage = "Cyclical connection exists between inferred beliefs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* recursively checks for cyclical connections between InferredBelief nodes
|
||||||
|
*
|
||||||
|
* to check for a cycle provide the source of an attempted connection as the targetNode for the cycle check,
|
||||||
|
* the currentNodeId should be initialised with the id of the targetNode of the attempted connection.
|
||||||
|
*
|
||||||
|
* @param {string} targetNodeId - the id of the node we are looking for as the endpoint of a cyclical connection
|
||||||
|
* @param {string} currentNodeId - the id of the node we are checking for outgoing connections to the provided target node
|
||||||
|
* @returns {RuleResult}
|
||||||
|
*/
|
||||||
|
function checkForCycle(targetNodeId: string, currentNodeId: string): RuleResult {
|
||||||
|
const outgoingBeliefs = getOutgoers({id: currentNodeId}, nodes, edges)
|
||||||
|
.filter(node => node.type === 'inferred_belief');
|
||||||
|
|
||||||
|
if (outgoingBeliefs.length === 0) return ruleResult.satisfied;
|
||||||
|
if (outgoingBeliefs.some(node => node.id === targetNodeId)) return ruleResult
|
||||||
|
.notSatisfied(defaultErrorMessage);
|
||||||
|
|
||||||
|
const next = outgoingBeliefs.map(node => checkForCycle(targetNodeId, node.id))
|
||||||
|
.find(result => !result.isSatisfied);
|
||||||
|
|
||||||
|
return next
|
||||||
|
? next
|
||||||
|
: ruleResult.satisfied;
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.source === connection.target
|
||||||
|
? ruleResult.notSatisfied(defaultErrorMessage)
|
||||||
|
: checkForCycle(connection.source, connection.target);
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
120
src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx
Normal file
120
src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
type NodeProps,
|
||||||
|
Position,
|
||||||
|
type Node, useNodeConnections
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import {useEffect} from "react";
|
||||||
|
import type {EditorWarning} from "../components/EditorWarnings.tsx";
|
||||||
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
|
import styles from '../../VisProg.module.css';
|
||||||
|
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
|
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
||||||
|
import useFlowStore from "../VisProgStores.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>) {
|
||||||
|
const {registerWarning, unregisterWarning} = useFlowStore.getState();
|
||||||
|
const connections = useNodeConnections({
|
||||||
|
id: props.id,
|
||||||
|
handleId: 'target'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const noConnectionWarning : EditorWarning = {
|
||||||
|
scope: {
|
||||||
|
id: props.id,
|
||||||
|
handleId: 'target'
|
||||||
|
},
|
||||||
|
type: 'MISSING_INPUT',
|
||||||
|
severity: "ERROR",
|
||||||
|
description: "the endNode does not have an incoming connection from a phaseNode"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connections.length === 0) { registerWarning(noConnectionWarning); }
|
||||||
|
else { unregisterWarning(props.id, `${noConnectionWarning.type}:target`); }
|
||||||
|
}, [connections.length, props.id, registerWarning, unregisterWarning]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toolbar nodeId={props.id} allowDelete={false}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeEnd}`}>
|
||||||
|
<div className={"flex-row gap-sm"}>
|
||||||
|
End
|
||||||
|
</div>
|
||||||
|
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
|
||||||
|
allowOnlyConnectionsFromType(["phase"])
|
||||||
|
]} title="Connect to a phaseNode"/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Functionality for reducing this node into its more compact json program
|
||||||
|
* @param node the node to reduce
|
||||||
|
* @param _nodes all nodes present
|
||||||
|
* @returns Dictionary, {id: node.id}
|
||||||
|
*/
|
||||||
|
export function EndReduce(node: Node, _nodes: Node[]) {
|
||||||
|
// Replace this for nodes functionality
|
||||||
|
return {
|
||||||
|
id: node.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EndTooltip = `
|
||||||
|
The end node signifies the endpoint of your program;
|
||||||
|
the output of the final phase of your program should connect to the end node`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the target
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the received connection
|
||||||
|
*/
|
||||||
|
export function EndConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the source
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the created connection
|
||||||
|
*/
|
||||||
|
export function EndConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the target
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the disconnected connection
|
||||||
|
*/
|
||||||
|
export function EndDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the source
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the diconnected connection
|
||||||
|
*/
|
||||||
|
export function EndDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { GoalNodeData } from "./GoalNode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default data for this node
|
||||||
|
*/
|
||||||
|
export const GoalNodeDefaults: GoalNodeData = {
|
||||||
|
label: "Goal Node",
|
||||||
|
name: "",
|
||||||
|
droppable: true,
|
||||||
|
description: "",
|
||||||
|
achieved: false,
|
||||||
|
hasReduce: true,
|
||||||
|
can_fail: false,
|
||||||
|
};
|
||||||
225
src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx
Normal file
225
src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import {
|
||||||
|
type NodeProps,
|
||||||
|
Position,
|
||||||
|
type Node
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import {useEffect} from "react";
|
||||||
|
import type {EditorWarning} from "../components/EditorWarnings.tsx";
|
||||||
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
|
import styles from '../../VisProg.module.css';
|
||||||
|
import { TextField } from '../../../../components/TextField';
|
||||||
|
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
|
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
||||||
|
import useFlowStore from '../VisProgStores';
|
||||||
|
import {DoesPlanIterate, HasCheckingSubGoal, PlanReduce, type Plan } from '../components/Plan';
|
||||||
|
import PlanEditorDialog from '../components/PlanEditor';
|
||||||
|
import { MultilineTextField } from '../../../../components/MultilineTextField';
|
||||||
|
import { defaultPlan } from '../components/Plan.default.ts';
|
||||||
|
import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default data dot a phase node
|
||||||
|
* @param label: the label of this phase
|
||||||
|
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
||||||
|
* @param desciption: description of the goal - this will be checked for completion
|
||||||
|
* @param hasReduce: whether this node has reducing functionality (true by default)
|
||||||
|
* @param can_fail: whether this plan should be checked- this plan could possible fail
|
||||||
|
* @param plan: The (possible) attached plan to this goal
|
||||||
|
*/
|
||||||
|
export type GoalNodeData = {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
droppable: boolean;
|
||||||
|
achieved: boolean;
|
||||||
|
hasReduce: boolean;
|
||||||
|
can_fail: boolean;
|
||||||
|
plan?: Plan;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GoalNode = Node<GoalNodeData>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how a Goal node should be rendered
|
||||||
|
* @param props NodeProps, like id, label, children
|
||||||
|
* @returns React.JSX.Element
|
||||||
|
*/
|
||||||
|
export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
||||||
|
const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
|
||||||
|
const _nodes = useFlowStore().nodes;
|
||||||
|
|
||||||
|
const text_input_id = `goal_${id}_text_input`;
|
||||||
|
const checkbox_id = `goal_${id}_checkbox`;
|
||||||
|
const planIterate = DoesPlanIterate(_nodes, data.plan);
|
||||||
|
const hasCheckSubGoal = data.plan !== undefined && HasCheckingSubGoal(data.plan, _nodes)
|
||||||
|
|
||||||
|
const setDescription = (value: string) => {
|
||||||
|
updateNodeData(id, {...data, description: value});
|
||||||
|
}
|
||||||
|
|
||||||
|
const setName= (value: string) => {
|
||||||
|
updateNodeData(id, {...data, name: value})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setFailable = (value: boolean) => {
|
||||||
|
updateNodeData(id, {...data, can_fail: value});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const noPlanWarning : EditorWarning = {
|
||||||
|
scope: {
|
||||||
|
id: id,
|
||||||
|
handleId: undefined
|
||||||
|
},
|
||||||
|
type: 'PLAN_IS_UNDEFINED',
|
||||||
|
severity: 'ERROR',
|
||||||
|
description: "This goalNode is missing a plan, please make sure to create a plan by using the create plan button"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.plan){
|
||||||
|
registerWarning(noPlanWarning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unregisterWarning(id, noPlanWarning.type);
|
||||||
|
},[data.plan, id, registerWarning, unregisterWarning])
|
||||||
|
return <>
|
||||||
|
<Toolbar nodeId={id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeGoal} flex-col gap-sm`}>
|
||||||
|
<div className={"flex-row gap-md"}>
|
||||||
|
<label htmlFor={text_input_id}>Goal:</label>
|
||||||
|
<TextField
|
||||||
|
id={text_input_id}
|
||||||
|
value={data.name}
|
||||||
|
setValue={(val) => setName(val)}
|
||||||
|
placeholder={"To ..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(data.can_fail || hasCheckSubGoal) && (<div>
|
||||||
|
<label htmlFor={text_input_id}>Description/ Condition of goal:</label>
|
||||||
|
<div className={"flex-wrap"}>
|
||||||
|
<MultilineTextField
|
||||||
|
id={text_input_id}
|
||||||
|
value={data.description}
|
||||||
|
setValue={setDescription}
|
||||||
|
placeholder={"Describe the condition of this goal..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
<div>
|
||||||
|
<label> {!data.plan ? "No plan set to execute while goal is not reached. 🔴" : "Will follow plan '" + data.plan.name + "' until all steps complete. 🟢"} </label>
|
||||||
|
</div>
|
||||||
|
{data.plan && (<div className={"flex-row gap-md align-center " + (planIterate ? "" : styles.planNoIterate)}>
|
||||||
|
{planIterate ? "" : <s></s>}
|
||||||
|
<label htmlFor={checkbox_id}>{!planIterate ? "This plan always succeeds!" : "Check if this plan fails"}:</label>
|
||||||
|
<input
|
||||||
|
id={checkbox_id}
|
||||||
|
type={"checkbox"}
|
||||||
|
disabled={!planIterate || (data.plan && HasCheckingSubGoal(data.plan, _nodes))}
|
||||||
|
checked={!planIterate || data.can_fail || (data.plan && HasCheckingSubGoal(data.plan, _nodes))}
|
||||||
|
onChange={(e) => planIterate ? setFailable(e.target.checked) : setFailable(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<PlanEditorDialog
|
||||||
|
plan={data.plan}
|
||||||
|
onSave={(plan) => {
|
||||||
|
updateNodeData(id, {
|
||||||
|
...data,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
description={data.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<MultiConnectionHandle type="source" position={Position.Right} id="GoalSource" rules={[
|
||||||
|
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
|
||||||
|
]} title="Connect to any number of phase and/or goalNode(-s)"/>
|
||||||
|
|
||||||
|
<MultiConnectionHandle type="target" position={Position.Bottom} id="GoalTarget" rules={[
|
||||||
|
allowOnlyConnectionsFromType(["goal"])]
|
||||||
|
} title="Connect to any number of goalNode(-s)"/>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces each Goal, including its children down into its relevant data.
|
||||||
|
* @param node The Node Properties of this node.
|
||||||
|
* @param _nodes all the nodes in the graph
|
||||||
|
*/
|
||||||
|
export function GoalReduce(node: Node, _nodes: Node[]) {
|
||||||
|
const data = node.data as GoalNodeData;
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
can_fail: data.can_fail || (data.plan && HasCheckingSubGoal(data.plan, _nodes)),
|
||||||
|
plan: data.plan ? PlanReduce(_nodes, data.plan) : "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const GoalTooltip = `
|
||||||
|
The goal node allows you to set goals that Pepper has to achieve
|
||||||
|
before moving to the next phase of your program`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the target
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the received connection
|
||||||
|
*/
|
||||||
|
export function GoalConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
// Goals should only be targeted by other goals, for them to be part of our plan.
|
||||||
|
const nodes = useFlowStore.getState().nodes;
|
||||||
|
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
|
||||||
|
if (!otherNode || otherNode.type !== "goal") return;
|
||||||
|
|
||||||
|
const data = _thisNode.data as GoalNodeData
|
||||||
|
|
||||||
|
// First, let's see if we have a plan currently. If not, let's create a default plan with this goal inside.:)
|
||||||
|
if (!data.plan) {
|
||||||
|
data.plan = insertGoalInPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()} as Plan, otherNode as GoalNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Else, lets just insert this goal into our current plan.
|
||||||
|
else {
|
||||||
|
data.plan = insertGoalInPlan(structuredClone(data.plan), otherNode as GoalNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the source
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the created connection
|
||||||
|
*/
|
||||||
|
export function GoalConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the target
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the disconnected connection
|
||||||
|
*/
|
||||||
|
export function GoalDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
// We should probably check if our disconnection was by a goal, since it would mean we have to remove it from our plan list.
|
||||||
|
const data = _thisNode.data as GoalNodeData
|
||||||
|
data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the source
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the diconnected connection
|
||||||
|
*/
|
||||||
|
export function GoalDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { InferredBeliefNodeData } from "./InferredBeliefNode.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default data for this node
|
||||||
|
*/
|
||||||
|
export const InferredBeliefNodeDefaults: InferredBeliefNodeData = {
|
||||||
|
label: "AND/OR",
|
||||||
|
droppable: true,
|
||||||
|
inferredBelief: {
|
||||||
|
left: undefined,
|
||||||
|
operator: true,
|
||||||
|
right: undefined
|
||||||
|
},
|
||||||
|
hasReduce: true,
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
.operator-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: sans-serif;
|
||||||
|
/* Change this font-size to scale the whole component */
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hide the default checkbox */
|
||||||
|
.operator-switch input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Track */
|
||||||
|
.switch-visual {
|
||||||
|
position: relative;
|
||||||
|
/* height is now 3x the font size */
|
||||||
|
height: 3em;
|
||||||
|
aspect-ratio: 1 / 2;
|
||||||
|
background-color: ButtonFace;
|
||||||
|
border-radius: 2em;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Knob */
|
||||||
|
.switch-visual::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0.1em;
|
||||||
|
left: 0.1em;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
background: Canvas;
|
||||||
|
border: 0.175em solid mediumpurple;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s ease-in-out, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labels */
|
||||||
|
.switch-labels {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 3em; /* Matches the track height */
|
||||||
|
font-weight: 800;
|
||||||
|
color: Canvas;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 0.2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-switch input:checked + .switch-visual::after {
|
||||||
|
/* Moves the slider down */
|
||||||
|
transform: translateY(1.4em);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*change the colours to highlight the selected operator*/
|
||||||
|
.operator-switch input:checked ~ .switch-labels{
|
||||||
|
:first-child {
|
||||||
|
transition: ease-in-out color 0.2s;
|
||||||
|
color: ButtonFace;
|
||||||
|
}
|
||||||
|
:last-child {
|
||||||
|
transition: ease-in-out color 0.2s;
|
||||||
|
color: mediumpurple;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-switch input:not(:checked) ~ .switch-labels{
|
||||||
|
:first-child {
|
||||||
|
transition: ease-in-out color 0.2s;
|
||||||
|
color: mediumpurple;
|
||||||
|
}
|
||||||
|
:last-child {
|
||||||
|
transition: ease-in-out color 0.2s;
|
||||||
|
color: ButtonFace;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import {getConnectedEdges, type Node, type NodeProps, Position, useNodeConnections} from '@xyflow/react';
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import styles from '../../VisProg.module.css';
|
||||||
|
import type {EditorWarning} from "../components/EditorWarnings.tsx";
|
||||||
|
import {Toolbar} from '../components/NodeComponents.tsx';
|
||||||
|
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
|
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
||||||
|
import useFlowStore from "../VisProgStores.tsx";
|
||||||
|
import {BeliefGlobalReduce, noBeliefCycles, noMatchingLeftRightBelief} from "./BeliefGlobals.ts";
|
||||||
|
import switchStyles from './InferredBeliefNode.module.css';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default data structure for an InferredBelief node
|
||||||
|
*/
|
||||||
|
export type InferredBeliefNodeData = {
|
||||||
|
label: string;
|
||||||
|
droppable: boolean;
|
||||||
|
inferredBelief: InferredBelief;
|
||||||
|
hasReduce: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* stores a boolean to represent the operator
|
||||||
|
* and a left and right BeliefNode (can be both an inferred and a basic belief)
|
||||||
|
* in the form of their corresponding id's
|
||||||
|
*/
|
||||||
|
export type InferredBelief = {
|
||||||
|
left: string | undefined,
|
||||||
|
operator: boolean,
|
||||||
|
right: string | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InferredBeliefNode = Node<InferredBeliefNodeData>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the target
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the received connection
|
||||||
|
*/
|
||||||
|
export function InferredBeliefConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
const data = _thisNode.data as InferredBeliefNodeData;
|
||||||
|
|
||||||
|
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId
|
||||||
|
&& ['basic_belief', 'inferred_belief'].includes(node.type!)))
|
||||||
|
) {
|
||||||
|
const connectedEdges = getConnectedEdges([_thisNode], useFlowStore.getState().edges);
|
||||||
|
switch(connectedEdges.find(edge => edge.source === _sourceNodeId)?.targetHandle){
|
||||||
|
case 'beliefLeft': data.inferredBelief.left = _sourceNodeId; break;
|
||||||
|
case 'beliefRight': data.inferredBelief.right = _sourceNodeId; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the source
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the created connection
|
||||||
|
*/
|
||||||
|
export function InferredBeliefConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the target
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the disconnected connection
|
||||||
|
*/
|
||||||
|
export function InferredBeliefDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
const data = _thisNode.data as InferredBeliefNodeData;
|
||||||
|
|
||||||
|
if (_sourceNodeId === data.inferredBelief.left) data.inferredBelief.left = undefined;
|
||||||
|
if (_sourceNodeId === data.inferredBelief.right) data.inferredBelief.right = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the source
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the diconnected connection
|
||||||
|
*/
|
||||||
|
export function InferredBeliefDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InferredBeliefTooltip = `
|
||||||
|
Combines two beliefs into a single belief using logical inference,
|
||||||
|
the node can be toggled between using "AND" and "OR" mode for inference`;
|
||||||
|
/**
|
||||||
|
* Defines how an InferredBelief node should be rendered
|
||||||
|
* @param {NodeProps<InferredBeliefNode>} props - Node properties provided by React Flow, including `id` and `data`.
|
||||||
|
* @returns The rendered InferredBeliefNode React element. (React.JSX.Element)
|
||||||
|
*/
|
||||||
|
export default function InferredBeliefNode(props: NodeProps<InferredBeliefNode>) {
|
||||||
|
const data = props.data;
|
||||||
|
const { updateNodeData, registerWarning, unregisterWarning } = useFlowStore();
|
||||||
|
// start of as an AND operator, true: "AND", false: "OR"
|
||||||
|
const [enforceAllBeliefs, setEnforceAllBeliefs] = useState(true);
|
||||||
|
|
||||||
|
// used to toggle operator
|
||||||
|
function onToggle() {
|
||||||
|
const newOperator = !enforceAllBeliefs; // compute the new value
|
||||||
|
setEnforceAllBeliefs(newOperator);
|
||||||
|
|
||||||
|
updateNodeData(props.id, {
|
||||||
|
...data,
|
||||||
|
inferredBelief: {
|
||||||
|
...data.inferredBelief,
|
||||||
|
operator: enforceAllBeliefs,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const beliefConnections = useNodeConnections({
|
||||||
|
id: props.id,
|
||||||
|
handleType: "target",
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const noBeliefsWarning : EditorWarning = {
|
||||||
|
scope: {
|
||||||
|
id: props.id,
|
||||||
|
handleId: undefined
|
||||||
|
},
|
||||||
|
type: 'MISSING_INPUT',
|
||||||
|
severity: 'ERROR',
|
||||||
|
description: `This AND/OR node is missing one or more beliefs,
|
||||||
|
please make sure to use both inputs of an AND/OR node`
|
||||||
|
};
|
||||||
|
|
||||||
|
if (beliefConnections.length < 2){
|
||||||
|
registerWarning(noBeliefsWarning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unregisterWarning(props.id, noBeliefsWarning.type);
|
||||||
|
},[beliefConnections.length, props.id, registerWarning, unregisterWarning])
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeInferredBelief}`}>
|
||||||
|
{/* The checkbox used to toggle the operator between 'AND' and 'OR' */}
|
||||||
|
<label className={switchStyles.operatorSwitch}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={data.inferredBelief.operator}
|
||||||
|
onChange={onToggle}
|
||||||
|
/>
|
||||||
|
<div className={switchStyles.switchVisual}></div>
|
||||||
|
<div className={switchStyles.switchLabels}>
|
||||||
|
<span title={"Belief is fulfilled if either of the supplied beliefs is true"}>OR</span>
|
||||||
|
<span title={"Belief is fulfilled if all of the supplied beliefs are true"}>AND</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
|
||||||
|
{/* outgoing connections */}
|
||||||
|
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||||
|
allowOnlyConnectionsFromType(["norm", "trigger"]),
|
||||||
|
noBeliefCycles,
|
||||||
|
noMatchingLeftRightBelief
|
||||||
|
]}/>
|
||||||
|
|
||||||
|
{/* incoming connections */}
|
||||||
|
<SingleConnectionHandle type="target" position={Position.Left} style={{top: '30%'}} id="beliefLeft" rules={[
|
||||||
|
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"]),
|
||||||
|
noBeliefCycles,
|
||||||
|
noMatchingLeftRightBelief
|
||||||
|
]}/>
|
||||||
|
<SingleConnectionHandle type="target" position={Position.Left} style={{top: '70%'}} id="beliefRight" rules={[
|
||||||
|
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"]),
|
||||||
|
noBeliefCycles,
|
||||||
|
noMatchingLeftRightBelief
|
||||||
|
]}/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces each BasicBelief, including its children down into its core data.
|
||||||
|
* @param {Node} node - The BasicBelief node to reduce.
|
||||||
|
* @param {Node[]} nodes - The list of all nodes in the current flow graph.
|
||||||
|
* @returns A simplified object containing the node label and its list of BasicBeliefs.
|
||||||
|
*/
|
||||||
|
export function InferredBeliefReduce(node: Node, nodes: Node[]) {
|
||||||
|
const data = node.data as InferredBeliefNodeData;
|
||||||
|
const leftBelief = nodes.find((node) => node.id === data.inferredBelief.left);
|
||||||
|
const rightBelief = nodes.find((node) => node.id === data.inferredBelief.right);
|
||||||
|
|
||||||
|
if (!leftBelief) { throw new Error("No Left belief found")}
|
||||||
|
if (!rightBelief) { throw new Error("No Right Belief found")}
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
id: node.id,
|
||||||
|
left: BeliefGlobalReduce(leftBelief, nodes),
|
||||||
|
operator: data.inferredBelief.operator ? "AND" : "OR",
|
||||||
|
right: BeliefGlobalReduce(rightBelief, nodes),
|
||||||
|
};
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { NormNodeData } from "./NormNode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default data for this node
|
||||||
|
*/
|
||||||
|
export const NormNodeDefaults: NormNodeData = {
|
||||||
|
label: "Norm Node",
|
||||||
|
droppable: true,
|
||||||
|
condition: undefined,
|
||||||
|
norm: "",
|
||||||
|
hasReduce: true,
|
||||||
|
critical: false,
|
||||||
|
};
|
||||||
160
src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx
Normal file
160
src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
type NodeProps,
|
||||||
|
Position,
|
||||||
|
type Node,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
|
import styles from '../../VisProg.module.css';
|
||||||
|
import { TextField } from '../../../../components/TextField';
|
||||||
|
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
|
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
||||||
|
import useFlowStore from '../VisProgStores';
|
||||||
|
import {BeliefGlobalReduce} from "./BeliefGlobals.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default data dot a phase node
|
||||||
|
* @param label: the label of this phase
|
||||||
|
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
||||||
|
* @param norm: list of strings of norms for this node
|
||||||
|
* @param hasReduce: whether this node has reducing functionality (true by default)
|
||||||
|
*/
|
||||||
|
export type NormNodeData = {
|
||||||
|
label: string;
|
||||||
|
droppable: boolean;
|
||||||
|
condition?: string; // id of this node's belief.
|
||||||
|
norm: string;
|
||||||
|
hasReduce: boolean;
|
||||||
|
critical: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NormNode = Node<NormNodeData>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how a Norm node should be rendered
|
||||||
|
* @param props NodeProps, like id, label, children
|
||||||
|
* @returns React.JSX.Element
|
||||||
|
*/
|
||||||
|
export default function NormNode(props: NodeProps<NormNode>) {
|
||||||
|
const data = props.data;
|
||||||
|
const {updateNodeData} = useFlowStore();
|
||||||
|
|
||||||
|
const text_input_id = `norm_${props.id}_text_input`;
|
||||||
|
const checkbox_id = `goal_${props.id}_checkbox`;
|
||||||
|
|
||||||
|
const setValue = (value: string) => {
|
||||||
|
updateNodeData(props.id, {norm: value});
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCritical = (value: boolean) => {
|
||||||
|
updateNodeData(props.id, {...data, critical: value});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
|
||||||
|
<div className={"flex-row gap-sm"}>
|
||||||
|
<label htmlFor={text_input_id}>Norm :</label>
|
||||||
|
<TextField
|
||||||
|
id={text_input_id}
|
||||||
|
value={data.norm}
|
||||||
|
setValue={(val) => setValue(val)}
|
||||||
|
placeholder={"Pepper should ..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={"flex-row gap-md align-center"}>
|
||||||
|
<label htmlFor={checkbox_id}>Critical:</label>
|
||||||
|
<input
|
||||||
|
id={checkbox_id}
|
||||||
|
type={"checkbox"}
|
||||||
|
checked={data.critical || false}
|
||||||
|
onChange={(e) => setCritical(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{data.condition && (<div className={"flex-row gap-md align-center"} data-testid="norm-condition-information">
|
||||||
|
<label htmlFor={checkbox_id}>Condition/ Belief attached.</label>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
|
||||||
|
<MultiConnectionHandle type="source" position={Position.Right} id="norms" rules={[
|
||||||
|
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}])
|
||||||
|
]} title="Connect to any number of phaseNode(-s)"/>
|
||||||
|
<SingleConnectionHandle type="target" position={Position.Bottom} id="NormBeliefs" rules={[
|
||||||
|
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"])
|
||||||
|
]} title="Connect to a beliefNode or a set of beliefs combined using the AND/OR node"/>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces each Norm, including its children down into its relevant data.
|
||||||
|
* @param node The Node Properties of this node.
|
||||||
|
* @param nodes all the nodes in the graph
|
||||||
|
*/
|
||||||
|
export function NormReduce(node: Node, nodes: Node[]) {
|
||||||
|
const data = node.data as NormNodeData;
|
||||||
|
|
||||||
|
// conditions nodes - make sure to check for empty arrays
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
id: node.id,
|
||||||
|
label: data.label,
|
||||||
|
norm: data.norm,
|
||||||
|
critical: data.critical,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.condition) {
|
||||||
|
const conditionNode = nodes.find((node) => node.id === data.condition);
|
||||||
|
// In case something went wrong, and our condition doesn't actually exist;
|
||||||
|
if (conditionNode == undefined) return result;
|
||||||
|
result["condition"] = BeliefGlobalReduce(conditionNode, nodes)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NormTooltip = `
|
||||||
|
A norm describes a behavioral rule Pepper must follow during the connected phase(-s),
|
||||||
|
for example: "respond using formal language"`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the target
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the received connection
|
||||||
|
*/
|
||||||
|
export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
const data = _thisNode.data as NormNodeData;
|
||||||
|
// If we got a belief connected, this is the condition for the norm.
|
||||||
|
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && ['basic_belief', 'inferred_belief'].includes(node.type!)))) {
|
||||||
|
data.condition = _sourceNodeId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the source
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the created connection
|
||||||
|
*/
|
||||||
|
export function NormConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the target
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the disconnected connection
|
||||||
|
*/
|
||||||
|
export function NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
const data = _thisNode.data as NormNodeData;
|
||||||
|
// remove if the target of disconnection was our condition
|
||||||
|
if (_sourceNodeId == data.condition) data.condition = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the source
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the diconnected connection
|
||||||
|
*/
|
||||||
|
export function NormDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { PhaseNodeData } from "./PhaseNode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default data for this node
|
||||||
|
*/
|
||||||
|
export const PhaseNodeDefaults: PhaseNodeData = {
|
||||||
|
label: "Phase Node",
|
||||||
|
droppable: true,
|
||||||
|
children: [],
|
||||||
|
hasReduce: true,
|
||||||
|
nextPhaseId: null,
|
||||||
|
isFirstPhase: false,
|
||||||
|
};
|
||||||
305
src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx
Normal file
305
src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import {
|
||||||
|
type NodeProps,
|
||||||
|
Position,
|
||||||
|
type Node, useNodeConnections
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import {useEffect, useRef} from "react";
|
||||||
|
import {type EditorWarning} from "../components/EditorWarnings.tsx";
|
||||||
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
|
import styles from '../../VisProg.module.css';
|
||||||
|
import {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
|
import {allowOnlyConnectionsFromType, noSelfConnections} from "../HandleRules.ts";
|
||||||
|
import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry';
|
||||||
|
import useFlowStore from '../VisProgStores';
|
||||||
|
import { TextField } from '../../../../components/TextField';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default data dot a phase node
|
||||||
|
* @param label: the label of this phase
|
||||||
|
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
||||||
|
* @param children: ID's of children of this node
|
||||||
|
* @param hasReduce: whether this node has reducing functionality (true by default)
|
||||||
|
* @param nextPhaseId:
|
||||||
|
*/
|
||||||
|
export type PhaseNodeData = {
|
||||||
|
label: string;
|
||||||
|
droppable: boolean;
|
||||||
|
children: string[];
|
||||||
|
hasReduce: boolean;
|
||||||
|
nextPhaseId: string | "end" | null;
|
||||||
|
isFirstPhase: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PhaseNode = Node<PhaseNodeData>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how a phase node should be rendered
|
||||||
|
* @param props NodeProps, like id, label, children
|
||||||
|
* @returns React.JSX.Element
|
||||||
|
*/
|
||||||
|
export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
||||||
|
const data = props.data;
|
||||||
|
const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
|
||||||
|
const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value});
|
||||||
|
const label_input_id = `phase_${props.id}_label_input`;
|
||||||
|
|
||||||
|
const connections = useNodeConnections({
|
||||||
|
id: props.id,
|
||||||
|
handleType: "target",
|
||||||
|
handleId: 'data'
|
||||||
|
})
|
||||||
|
|
||||||
|
const phaseOutCons = useNodeConnections({
|
||||||
|
id: props.id,
|
||||||
|
handleType: "source",
|
||||||
|
handleId: 'source',
|
||||||
|
})
|
||||||
|
|
||||||
|
const phaseInCons = useNodeConnections({
|
||||||
|
id: props.id,
|
||||||
|
handleType: "target",
|
||||||
|
handleId: 'target',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const noConnectionWarning : EditorWarning = {
|
||||||
|
scope: {
|
||||||
|
id: props.id,
|
||||||
|
handleId: 'data'
|
||||||
|
},
|
||||||
|
type: 'MISSING_INPUT',
|
||||||
|
severity: "WARNING",
|
||||||
|
description: "the phaseNode has no incoming goals, norms, and/or triggers"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connections.length === 0) { registerWarning(noConnectionWarning); return; }
|
||||||
|
unregisterWarning(props.id, `${noConnectionWarning.type}:data`);
|
||||||
|
}, [connections.length, props.id, registerWarning, unregisterWarning]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const notConnectedInfo : EditorWarning = {
|
||||||
|
scope: {
|
||||||
|
id: props.id,
|
||||||
|
handleId: undefined,
|
||||||
|
},
|
||||||
|
type: 'NOT_CONNECTED_TO_PROGRAM',
|
||||||
|
severity: "INFO",
|
||||||
|
description: "The PhaseNode is not connected to other nodes"
|
||||||
|
};
|
||||||
|
const noIncomingPhaseWarning : EditorWarning = {
|
||||||
|
scope: {
|
||||||
|
id: props.id,
|
||||||
|
handleId: 'target'
|
||||||
|
},
|
||||||
|
type: 'MISSING_INPUT',
|
||||||
|
severity: "WARNING",
|
||||||
|
description: "the phaseNode has no incoming connection from a phase or the startNode"
|
||||||
|
}
|
||||||
|
const noOutgoingPhaseWarning : EditorWarning = {
|
||||||
|
scope: {
|
||||||
|
id: props.id,
|
||||||
|
handleId: 'source'
|
||||||
|
},
|
||||||
|
type: 'MISSING_OUTPUT',
|
||||||
|
severity: "WARNING",
|
||||||
|
description: "the phaseNode has no outgoing connection to a phase or the endNode"
|
||||||
|
}
|
||||||
|
|
||||||
|
// register relevant warning and unregister others
|
||||||
|
if (phaseInCons.length === 0 && phaseOutCons.length === 0) {
|
||||||
|
registerWarning(notConnectedInfo);
|
||||||
|
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
|
||||||
|
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (phaseOutCons.length === 0) {
|
||||||
|
registerWarning(noOutgoingPhaseWarning);
|
||||||
|
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
|
||||||
|
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (phaseInCons.length === 0) {
|
||||||
|
registerWarning(noIncomingPhaseWarning);
|
||||||
|
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
|
||||||
|
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// unregister all warnings if none should be present
|
||||||
|
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
|
||||||
|
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
|
||||||
|
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
|
||||||
|
}, [phaseInCons.length, phaseOutCons.length, props.id, registerWarning, unregisterWarning]);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) {
|
||||||
|
const { width, height } = ref.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
console.log('Node width:', width, 'height:', height);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodePhase}`}>
|
||||||
|
<div className={"flex-row gap-sm"}>
|
||||||
|
<label htmlFor={label_input_id}>Name:</label>
|
||||||
|
<TextField
|
||||||
|
id={label_input_id}
|
||||||
|
value={data.label}
|
||||||
|
setValue={updateLabel}
|
||||||
|
placeholder={"Phase ..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
|
||||||
|
noSelfConnections,
|
||||||
|
allowOnlyConnectionsFromType(["phase", "start"]),
|
||||||
|
]} title="Connect to a phase or the startNode"/>
|
||||||
|
<MultiConnectionHandle type="target" position={Position.Bottom} id="data" rules={[
|
||||||
|
allowOnlyConnectionsFromType(["norm", "goal", "trigger"])
|
||||||
|
]} title="Connect to any number of norm, goal, and TriggerNode(-s)"/>
|
||||||
|
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||||
|
noSelfConnections,
|
||||||
|
allowOnlyConnectionsFromType(["phase", "end"]),
|
||||||
|
]} title="Connect to a phase or the endNode"/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces each phase, including its children down into its relevant data.
|
||||||
|
* @param node the node which is being reduced
|
||||||
|
* @param nodes all the nodes currently in the flow.
|
||||||
|
* @returns A collection of all reduced nodes in this phase, starting with this phases' reduced data.
|
||||||
|
*/
|
||||||
|
export function PhaseReduce(node: Node, nodes: Node[]) {
|
||||||
|
const thisNode = node as PhaseNode;
|
||||||
|
const data = thisNode.data as PhaseNodeData;
|
||||||
|
|
||||||
|
// node typings that are not in phase
|
||||||
|
const nodesNotInPhase: string[] = Object.entries(NodesInPhase)
|
||||||
|
.filter(([, f]) => !f())
|
||||||
|
.map(([t]) => t);
|
||||||
|
|
||||||
|
// node typings that then are in phase
|
||||||
|
const nodesInPhase: string[] = Object.entries(NodeTypes)
|
||||||
|
.filter(([t]) => !nodesNotInPhase.includes(t))
|
||||||
|
.map(([t]) => t);
|
||||||
|
|
||||||
|
// children nodes - make sure to check for empty arrays
|
||||||
|
let childrenNodes: Node[] = [];
|
||||||
|
if (data.children)
|
||||||
|
childrenNodes = nodes.filter((node) => data.children.includes(node.id));
|
||||||
|
|
||||||
|
// Build the result object
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
id: thisNode.id,
|
||||||
|
name: data.label,
|
||||||
|
};
|
||||||
|
|
||||||
|
nodesInPhase.forEach((type) => {
|
||||||
|
const typedChildren = childrenNodes.filter((child) => child.type == type);
|
||||||
|
const reducer = NodeReduces[type as keyof typeof NodeReduces];
|
||||||
|
if (!reducer) {
|
||||||
|
console.warn(`No reducer found for node type ${type}`);
|
||||||
|
result[type + "s"] = [];
|
||||||
|
} else {
|
||||||
|
result[type + "s"] = [];
|
||||||
|
for (const typedChild of typedChildren) {
|
||||||
|
(result[type + "s"] as object[]).push(reducer(typedChild, nodes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PhaseTooltip = `
|
||||||
|
A phase is a single stage of the program, during a phase Pepper will behave
|
||||||
|
in accordance with any connected norms, goals and triggers`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the target (phase)
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the received connection
|
||||||
|
*/
|
||||||
|
export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
const data = _thisNode.data as PhaseNodeData
|
||||||
|
|
||||||
|
const nodes = useFlowStore.getState().nodes;
|
||||||
|
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)!
|
||||||
|
switch (sourceNode.type) {
|
||||||
|
case "phase": break;
|
||||||
|
case "start": data.isFirstPhase = true; break;
|
||||||
|
// we only add none phase or start nodes to the children
|
||||||
|
// endNodes cannot be the source of an outgoing connection
|
||||||
|
// so we don't need to cover them with a special case
|
||||||
|
// before handling the default behavior
|
||||||
|
default: data.children.push(_sourceNodeId); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the source (phase)
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the created connection
|
||||||
|
*/
|
||||||
|
export function PhaseConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
const data = _thisNode.data as PhaseNodeData
|
||||||
|
const nodes = useFlowStore.getState().nodes;
|
||||||
|
|
||||||
|
const targetNode = nodes.find((node) => node.id === _targetNodeId)
|
||||||
|
if (!targetNode) {throw new Error("Source node not found")}
|
||||||
|
|
||||||
|
// we set the nextPhaseId to the next target's id if the target is a phaseNode,
|
||||||
|
// or "end" if the target node is the end node
|
||||||
|
switch (targetNode.type) {
|
||||||
|
case 'phase': data.nextPhaseId = _targetNodeId; break;
|
||||||
|
case 'end': data.nextPhaseId = "end"; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the target (phase)
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the disconnected connection
|
||||||
|
*/
|
||||||
|
export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
const data = _thisNode.data as PhaseNodeData
|
||||||
|
|
||||||
|
const nodes = useFlowStore.getState().nodes;
|
||||||
|
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)
|
||||||
|
const sourceType = sourceNode ? sourceNode.type : "deleted";
|
||||||
|
switch (sourceType) {
|
||||||
|
case "phase": break;
|
||||||
|
case "start": data.isFirstPhase = false; break;
|
||||||
|
// we only add none phase or start nodes to the children
|
||||||
|
// endNodes cannot be the source of an outgoing connection
|
||||||
|
// so we don't need to cover them with a special case
|
||||||
|
// before handling the default behavior
|
||||||
|
default:
|
||||||
|
data.children = data.children.filter((child) => { if (child != _sourceNodeId) return child; });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the source (phase)
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the diconnected connection
|
||||||
|
*/
|
||||||
|
export function PhaseDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
const data = _thisNode.data as PhaseNodeData
|
||||||
|
const nodes = useFlowStore.getState().nodes;
|
||||||
|
|
||||||
|
// if the target is a phase or end node set the nextPhaseId to null,
|
||||||
|
// as we are no longer connected to a subsequent phaseNode or to the endNode
|
||||||
|
if (nodes.some((node) => node.id === _targetNodeId && ['phase', 'end'].includes(node.type!))){
|
||||||
|
data.nextPhaseId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
118
src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx
Normal file
118
src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
type NodeProps,
|
||||||
|
Position,
|
||||||
|
type Node, useNodeConnections
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import {useEffect} from "react";
|
||||||
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
|
import styles from '../../VisProg.module.css';
|
||||||
|
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
|
import {type EditorWarning} from "../components/EditorWarnings.tsx";
|
||||||
|
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
|
||||||
|
import useFlowStore from "../VisProgStores.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
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>) {
|
||||||
|
const {registerWarning, unregisterWarning} = useFlowStore.getState();
|
||||||
|
const connections = useNodeConnections({
|
||||||
|
id: props.id,
|
||||||
|
handleId: 'source'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const noConnectionWarning : EditorWarning = {
|
||||||
|
scope: {
|
||||||
|
id: props.id,
|
||||||
|
handleId: 'source'
|
||||||
|
},
|
||||||
|
type: 'MISSING_OUTPUT',
|
||||||
|
severity: "ERROR",
|
||||||
|
description: "the startNode does not have an outgoing connection to a phaseNode"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connections.length === 0) { registerWarning(noConnectionWarning); }
|
||||||
|
else { unregisterWarning(props.id, `${noConnectionWarning.type}:source`); }
|
||||||
|
}, [connections.length, props.id, registerWarning, unregisterWarning]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toolbar nodeId={props.id} allowDelete={false}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeStart}`}>
|
||||||
|
<div className={"flex-row gap-sm"}>
|
||||||
|
Start
|
||||||
|
</div>
|
||||||
|
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||||
|
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"target"}])
|
||||||
|
]} title="Connect to a phaseNode"/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reduce function for this node type.
|
||||||
|
* @param node this node
|
||||||
|
* @param _nodes all the nodes in the graph
|
||||||
|
* @returns a reduced structure of this node
|
||||||
|
*/
|
||||||
|
export function StartReduce(node: Node, _nodes: Node[]) {
|
||||||
|
// Replace this for nodes functionality
|
||||||
|
return {
|
||||||
|
id: node.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StartTooltip = `
|
||||||
|
The start node acts as the starting point for a program,
|
||||||
|
it should be connected to the left handle of the first phase of your program`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the target
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the received connection
|
||||||
|
*/
|
||||||
|
export function StartConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the source
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the created connection
|
||||||
|
*/
|
||||||
|
export function StartConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the target
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the disconnected connection
|
||||||
|
*/
|
||||||
|
export function StartDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the source
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the diconnected connection
|
||||||
|
*/
|
||||||
|
export function StartDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { TriggerNodeData } from "./TriggerNode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default data for this node
|
||||||
|
*/
|
||||||
|
export const TriggerNodeDefaults: TriggerNodeData = {
|
||||||
|
label: "Trigger Node",
|
||||||
|
name: "",
|
||||||
|
droppable: true,
|
||||||
|
hasReduce: true,
|
||||||
|
};
|
||||||
275
src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx
Normal file
275
src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import {
|
||||||
|
type NodeProps,
|
||||||
|
Position,
|
||||||
|
type Node, useNodeConnections
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import {useEffect} from "react";
|
||||||
|
import type {EditorWarning} from "../components/EditorWarnings.tsx";
|
||||||
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
|
import styles from '../../VisProg.module.css';
|
||||||
|
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
|
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
||||||
|
import useFlowStore from '../VisProgStores';
|
||||||
|
import {PlanReduce, type Plan } from '../components/Plan';
|
||||||
|
import PlanEditorDialog from '../components/PlanEditor';
|
||||||
|
import {BeliefGlobalReduce} from "./BeliefGlobals.ts";
|
||||||
|
import type { GoalNode } from './GoalNode.tsx';
|
||||||
|
import { defaultPlan } from '../components/Plan.default.ts';
|
||||||
|
import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
|
||||||
|
import { TextField } from '../../../../components/TextField.tsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default data structure for a Trigger node
|
||||||
|
*
|
||||||
|
* Represents configuration for a node that activates when a specific condition is met,
|
||||||
|
* such as keywords being spoken or emotions detected.
|
||||||
|
*
|
||||||
|
* @property label: the display label of this Trigger node.
|
||||||
|
* @property droppable: Whether this node can be dropped from the toolbar (default: true).
|
||||||
|
* @property hasReduce - Whether this node supports reduction logic.
|
||||||
|
*/
|
||||||
|
export type TriggerNodeData = {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
droppable: boolean;
|
||||||
|
condition?: string; // id of the belief
|
||||||
|
plan?: Plan;
|
||||||
|
hasReduce: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type TriggerNode = Node<TriggerNodeData>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how a Trigger node should be rendered
|
||||||
|
* @param props - Node properties provided by React Flow, including `id` and `data`.
|
||||||
|
* @returns The rendered TriggerNode React element (React.JSX.Element).
|
||||||
|
*/
|
||||||
|
export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
||||||
|
const data = props.data;
|
||||||
|
const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
|
||||||
|
|
||||||
|
const setName= (value: string) => {
|
||||||
|
updateNodeData(props.id, {...data, name: value})
|
||||||
|
}
|
||||||
|
|
||||||
|
const beliefInput = useNodeConnections({
|
||||||
|
id: props.id,
|
||||||
|
handleType: "target",
|
||||||
|
handleId: "TriggerBeliefs"
|
||||||
|
})
|
||||||
|
|
||||||
|
const outputCons = useNodeConnections({
|
||||||
|
id: props.id,
|
||||||
|
handleType: "source",
|
||||||
|
handleId: "TriggerSource"
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const noPhaseConnectionWarning : EditorWarning = {
|
||||||
|
scope: {
|
||||||
|
id: props.id,
|
||||||
|
handleId: 'TriggerSource'
|
||||||
|
},
|
||||||
|
type: 'MISSING_OUTPUT',
|
||||||
|
severity: 'INFO',
|
||||||
|
description: "This triggerNode is missing a condition/belief, please make sure to connect a belief node to "
|
||||||
|
};
|
||||||
|
|
||||||
|
if (outputCons.length === 0){
|
||||||
|
registerWarning(noPhaseConnectionWarning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unregisterWarning(props.id, `${noPhaseConnectionWarning.type}:${noPhaseConnectionWarning.scope.handleId}`);
|
||||||
|
},[outputCons.length, props.id, registerWarning, unregisterWarning])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const noBeliefWarning : EditorWarning = {
|
||||||
|
scope: {
|
||||||
|
id: props.id,
|
||||||
|
handleId: 'TriggerBeliefs'
|
||||||
|
},
|
||||||
|
type: 'MISSING_INPUT',
|
||||||
|
severity: 'ERROR',
|
||||||
|
description: "This triggerNode is missing a condition/belief, please make sure to connect a belief node to "
|
||||||
|
};
|
||||||
|
|
||||||
|
if (beliefInput.length === 0 && outputCons.length !== 0){
|
||||||
|
registerWarning(noBeliefWarning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unregisterWarning(props.id, `${noBeliefWarning.type}:${noBeliefWarning.scope.handleId}`);
|
||||||
|
},[beliefInput.length, outputCons.length, props.id, registerWarning, unregisterWarning])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const noPlanWarning : EditorWarning = {
|
||||||
|
scope: {
|
||||||
|
id: props.id,
|
||||||
|
handleId: undefined
|
||||||
|
},
|
||||||
|
type: 'PLAN_IS_UNDEFINED',
|
||||||
|
severity: 'ERROR',
|
||||||
|
description: "This triggerNode is missing a plan, please make sure to create a plan by using the create plan button"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.plan && outputCons.length !== 0){
|
||||||
|
registerWarning(noPlanWarning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unregisterWarning(props.id, noPlanWarning.type);
|
||||||
|
},[data.plan, outputCons.length, props.id, registerWarning, unregisterWarning])
|
||||||
|
return <>
|
||||||
|
|
||||||
|
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
|
||||||
|
<TextField
|
||||||
|
value={props.data.name}
|
||||||
|
setValue={(val) => setName(val)}
|
||||||
|
placeholder={"Name of this trigger..."}
|
||||||
|
/>
|
||||||
|
<div className={"flex-row gap-md"}>Triggers when the condition is met.</div>
|
||||||
|
<div className={"flex-row gap-md"}>Condition/ Belief is currently {data.condition ? "" : "not"} set. {data.condition ? "🟢" : "🔴"}</div>
|
||||||
|
<div className={"flex-row gap-md"}>Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}</div>
|
||||||
|
<MultiConnectionHandle type="source" position={Position.Right} id="TriggerSource" rules={[
|
||||||
|
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
|
||||||
|
]} title="Connect to any number of phaseNodes"/>
|
||||||
|
<SingleConnectionHandle
|
||||||
|
type="target"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id="TriggerBeliefs"
|
||||||
|
style={{ left: '40%' }}
|
||||||
|
rules={[
|
||||||
|
allowOnlyConnectionsFromType(["basic_belief","inferred_belief"]),
|
||||||
|
]}
|
||||||
|
title="Connect to a beliefNode or a set of beliefs combined using the AND/OR node"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MultiConnectionHandle
|
||||||
|
type="target"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id="GoalTarget"
|
||||||
|
style={{ left: '60%' }}
|
||||||
|
rules={[
|
||||||
|
allowOnlyConnectionsFromType(['goal']),
|
||||||
|
]}
|
||||||
|
title="Connect to any number of goalNodes"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PlanEditorDialog
|
||||||
|
plan={data.plan}
|
||||||
|
onSave={(plan) => {
|
||||||
|
updateNodeData(props.id, {
|
||||||
|
...data,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces each Trigger, including its children down into its core data.
|
||||||
|
* @param node - The Trigger node to reduce.
|
||||||
|
* @param nodes - The list of all nodes in the current flow graph.
|
||||||
|
* @returns A simplified object containing the node label and its list of triggers.
|
||||||
|
*/
|
||||||
|
export function TriggerReduce(node: Node, nodes: Node[]) {
|
||||||
|
const data = node.data as TriggerNodeData;
|
||||||
|
const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined
|
||||||
|
const conditionData = conditionNode ? BeliefGlobalReduce(conditionNode, nodes) : ""
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
name: node.data.name,
|
||||||
|
condition: conditionData, // Make sure we have a condition before reducing, or default to ""
|
||||||
|
plan: !data.plan ? "" : PlanReduce(nodes, data.plan), // Make sure we have a plan when reducing, or default to ""
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const TriggerTooltip = `
|
||||||
|
A trigger node is used to make Pepper execute a predefined plan -
|
||||||
|
consisting of one or more actions - when the connected beliefs are met`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the target
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the received connection
|
||||||
|
*/
|
||||||
|
export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
const data = _thisNode.data as TriggerNodeData;
|
||||||
|
// If we got a belief connected, this is the condition for the norm.
|
||||||
|
const nodes = useFlowStore.getState().nodes;
|
||||||
|
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
|
||||||
|
if (!otherNode) return;
|
||||||
|
|
||||||
|
if (['basic_belief', 'inferred_belief'].includes(otherNode.type!)) {
|
||||||
|
data.condition = _sourceNodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (otherNode.type === 'goal') {
|
||||||
|
// First, let's see if we have a plan currently. If not, let's create a default plan with this goal inside.:)
|
||||||
|
if (!data.plan) {
|
||||||
|
data.plan = insertGoalInPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()} as Plan, otherNode as GoalNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Else, lets just insert this goal into our current plan.
|
||||||
|
else {
|
||||||
|
data.plan = insertGoalInPlan(structuredClone(data.plan), otherNode as GoalNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the source
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the created connection
|
||||||
|
*/
|
||||||
|
export function TriggerConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the target
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the disconnected connection
|
||||||
|
*/
|
||||||
|
export function TriggerDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
const data = _thisNode.data as TriggerNodeData;
|
||||||
|
// remove if the target of disconnection was our condition
|
||||||
|
if (_sourceNodeId == data.condition) data.condition = undefined
|
||||||
|
|
||||||
|
data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the source
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the diconnected connection
|
||||||
|
*/
|
||||||
|
export function TriggerDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definitions for the possible triggers, being keywords and emotions
|
||||||
|
|
||||||
|
/** Represents a single keyword trigger entry. */
|
||||||
|
type Keyword = { id: string, keyword: string };
|
||||||
|
|
||||||
|
/** Properties for an emotion-type trigger node. */
|
||||||
|
export type EmotionTriggerNodeProps = {
|
||||||
|
type: "emotion";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for a keyword-type trigger node. */
|
||||||
|
export type KeywordTriggerNodeProps = {
|
||||||
|
type: "keywords";
|
||||||
|
value: Keyword[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union type for all possible Trigger node configurations. */
|
||||||
|
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;
|
||||||
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" });
|
||||||
|
}
|
||||||
96
src/utils/cellStore.ts
Normal file
96
src/utils/cellStore.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import {useSyncExternalStore} from "react";
|
||||||
|
|
||||||
|
type Unsub = () => void;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple reactive state container that holds a value of type `T` that provides methods to get, set, and subscribe.
|
||||||
|
*/
|
||||||
|
export type Cell<T> = {
|
||||||
|
/**
|
||||||
|
* Returns the current value stored in the cell.
|
||||||
|
*/
|
||||||
|
get: () => T;
|
||||||
|
/**
|
||||||
|
* Updates the cell's value, pass either a direct value or an updater function.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* count.set(5);
|
||||||
|
* count.set(prev => prev + 1);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
set: (next: T | ((prev: T) => T)) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to changes in the cell's value, meaning the provided callback is called whenever the value changes.
|
||||||
|
* Returns an unsubscribe function.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const unsubscribe = count.subscribe(() => console.log(count.get()));
|
||||||
|
* // later:
|
||||||
|
* unsubscribe();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
subscribe: (callback: () => void) => Unsub;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new reactive state container (`Cell`) with an initial value.
|
||||||
|
*
|
||||||
|
* This function allows you to store and mutate state outside of React,
|
||||||
|
* while still supporting subscriptions for reactivity.
|
||||||
|
*
|
||||||
|
* @param initial - The initial value for the cell.
|
||||||
|
* @returns A Cell object with `get`, `set`, and `subscribe` methods.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const count = cell(0);
|
||||||
|
* count.set(10);
|
||||||
|
* console.log(count.get()); // 10
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function cell<T>(initial: T): Cell<T> {
|
||||||
|
let value = initial;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
return {
|
||||||
|
get: () => value,
|
||||||
|
set: (next) => {
|
||||||
|
value = typeof next === "function" ? (next as (v: T) => T)(value) : next;
|
||||||
|
for (const l of listeners) l();
|
||||||
|
},
|
||||||
|
subscribe: (callback) => {
|
||||||
|
listeners.add(callback);
|
||||||
|
return () => listeners.delete(callback);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook that subscribes a component to a Cell.
|
||||||
|
*
|
||||||
|
* Automatically re-renders the component whenever the Cell's value changes.
|
||||||
|
* Uses 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);
|
||||||
|
}
|
||||||
19
src/utils/duplicateIndices.ts
Normal file
19
src/utils/duplicateIndices.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Find the indices of all elements that occur more than once.
|
||||||
|
*
|
||||||
|
* @param array The array to search for duplicates.
|
||||||
|
* @returns An array of indices where an element occurs more than once, in no particular order.
|
||||||
|
*/
|
||||||
|
export default function duplicateIndices<T>(array: T[]): number[] {
|
||||||
|
const positions = new Map<T, number[]>();
|
||||||
|
|
||||||
|
array.forEach((value, i) => {
|
||||||
|
if (!positions.has(value)) positions.set(value, []);
|
||||||
|
positions.get(value)!.push(i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// flatten all index lists with more than one element
|
||||||
|
return Array.from(positions.values())
|
||||||
|
.filter(idxs => idxs.length > 1)
|
||||||
|
.flat();
|
||||||
|
}
|
||||||
21
src/utils/formatDuration.ts
Normal file
21
src/utils/formatDuration.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Format a time duration like `HH:MM:SS.mmm`.
|
||||||
|
*
|
||||||
|
* @param durationMs time duration in milliseconds.
|
||||||
|
* @return formatted time string.
|
||||||
|
*/
|
||||||
|
export default function formatDuration(durationMs: number): string {
|
||||||
|
const isNegative = durationMs < 0;
|
||||||
|
if (isNegative) durationMs = -durationMs;
|
||||||
|
|
||||||
|
const hours = Math.floor(durationMs / 3600000);
|
||||||
|
const minutes = Math.floor((durationMs % 3600000) / 60000);
|
||||||
|
const seconds = Math.floor((durationMs % 60000) / 1000);
|
||||||
|
const milliseconds = Math.floor(durationMs % 1000);
|
||||||
|
|
||||||
|
return (isNegative ? '-' : '') +
|
||||||
|
`${hours.toString().padStart(2, '0')}:` +
|
||||||
|
`${minutes.toString().padStart(2, '0')}:` +
|
||||||
|
`${seconds.toString().padStart(2, '0')}.` +
|
||||||
|
`${milliseconds.toString().padStart(3, '0')}`;
|
||||||
|
}
|
||||||
40
src/utils/orderPhaseNodes.ts
Normal file
40
src/utils/orderPhaseNodes.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type {PhaseNode} from "../pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* takes an array of phaseNodes and orders them according to their nextPhaseId attributes,
|
||||||
|
* starting with the phase that has isFirstPhase = true
|
||||||
|
*
|
||||||
|
* @param {PhaseNode[]} nodes an unordered phaseNode array
|
||||||
|
* @returns {PhaseNode[]} the ordered phaseNode array
|
||||||
|
*/
|
||||||
|
export default function orderPhaseNodeArray(nodes: PhaseNode[]) : PhaseNode[] {
|
||||||
|
// find the first phaseNode of the sequence
|
||||||
|
const start = nodes.find(node => node.data.isFirstPhase);
|
||||||
|
if (!start) {
|
||||||
|
throw new Error('No phaseNode with isFirstObject = true found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepare for ordering of phaseNodes
|
||||||
|
const orderedPhaseNodes: PhaseNode[] = [];
|
||||||
|
const IdMap = new Map(nodes.map(node => [node.id, node]));
|
||||||
|
let currentNode: PhaseNode | undefined = start;
|
||||||
|
|
||||||
|
// populate orderedPhaseNodes array with the phaseNodes in the correct order
|
||||||
|
while (currentNode) {
|
||||||
|
orderedPhaseNodes.push(currentNode);
|
||||||
|
|
||||||
|
if (!currentNode.data.nextPhaseId) {
|
||||||
|
throw new Error("Incomplete phase sequence, program does not reach the end node");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentNode.data.nextPhaseId === "end") break;
|
||||||
|
|
||||||
|
currentNode = IdMap.get(currentNode.data.nextPhaseId);
|
||||||
|
|
||||||
|
if (!currentNode) {
|
||||||
|
throw new Error(`Incomplete phase sequence, phaseNode with id "${orderedPhaseNodes.at(-1)?.data.nextPhaseId}" not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedPhaseNodes;
|
||||||
|
}
|
||||||
24
src/utils/priorityFiltering.ts
Normal file
24
src/utils/priorityFiltering.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export type PriorityFilterPredicate<T> = {
|
||||||
|
priority: number;
|
||||||
|
predicate: (element: T) => boolean | null; // The predicate and its priority are ignored if it returns null.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a list of priority predicates to an element. For all predicates that don't return null, if the ones with the highest level return true, then this function returns true.
|
||||||
|
* @param element The element to apply the predicates to.
|
||||||
|
* @param predicates The list of predicates to apply.
|
||||||
|
*/
|
||||||
|
export function applyPriorityPredicates<T>(element: T, predicates: PriorityFilterPredicate<T>[]): boolean {
|
||||||
|
let highestPriority = -1;
|
||||||
|
let highestKeep = true;
|
||||||
|
for (const predicate of predicates) {
|
||||||
|
if (predicate.priority >= highestPriority) {
|
||||||
|
const predicateKeep = predicate.predicate(element);
|
||||||
|
if (predicateKeep === null) continue; // This predicate doesn't care about the element, so skip it
|
||||||
|
if (predicate.priority > highestPriority) highestKeep = true;
|
||||||
|
highestPriority = predicate.priority;
|
||||||
|
highestKeep = highestKeep && predicateKeep;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return highestKeep;
|
||||||
|
}
|
||||||
81
src/utils/programStore.ts
Normal file
81
src/utils/programStore.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import {create} from "zustand";
|
||||||
|
|
||||||
|
// the type of a reduced program
|
||||||
|
export type ReducedProgram = { phases: Record<string, unknown>[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the type definition of the programStore
|
||||||
|
*/
|
||||||
|
export type ProgramState = {
|
||||||
|
// Basic store functionality:
|
||||||
|
currentProgram: ReducedProgram;
|
||||||
|
setProgramState: (state: ReducedProgram) => void;
|
||||||
|
getProgramState: () => ReducedProgram;
|
||||||
|
|
||||||
|
// Utility functions:
|
||||||
|
// to avoid having to manually go through the entire state for every instance where data is required
|
||||||
|
getPhaseIds: () => string[];
|
||||||
|
getNormsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||||
|
getGoalsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||||
|
getTriggersInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||||
|
// if more specific utility functions are needed they can be added here:
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the ProgramStore can be used to access all information of the most recently sent program,
|
||||||
|
* it contains basic functions to set and get the current program.
|
||||||
|
* And it contains some utility functions that allow you to easily gain access
|
||||||
|
* to the norms, triggers and goals of a specific phase.
|
||||||
|
*/
|
||||||
|
const useProgramStore = create<ProgramState>((set, get) => ({
|
||||||
|
currentProgram: { phases: [] as Record<string, unknown>[]},
|
||||||
|
/**
|
||||||
|
* sets the current program by cloning the provided program using a structuredClone
|
||||||
|
*/
|
||||||
|
setProgramState: (program: ReducedProgram) => set({currentProgram: structuredClone(program)}),
|
||||||
|
/**
|
||||||
|
* gets the current program
|
||||||
|
*/
|
||||||
|
getProgramState: () => get().currentProgram,
|
||||||
|
|
||||||
|
// utility functions:
|
||||||
|
/**
|
||||||
|
* gets the ids of all phases in the program
|
||||||
|
*/
|
||||||
|
getPhaseIds: () => get().currentProgram.phases.map(entry => entry["id"] as string),
|
||||||
|
/**
|
||||||
|
* gets the norms for the provided phase
|
||||||
|
*/
|
||||||
|
getNormsInPhase: (currentPhaseId) => {
|
||||||
|
const program = get().currentProgram;
|
||||||
|
const phase = program.phases.find(val => val["id"] === currentPhaseId);
|
||||||
|
if (phase) {
|
||||||
|
return phase["norms"] as Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
throw new Error(`phase with id:"${currentPhaseId}" not found`)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* gets the goals for the provided phase
|
||||||
|
*/
|
||||||
|
getGoalsInPhase: (currentPhaseId) => {
|
||||||
|
const program = get().currentProgram;
|
||||||
|
const phase = program.phases.find(val => val["id"] === currentPhaseId);
|
||||||
|
if (phase) {
|
||||||
|
return phase["goals"] as Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
throw new Error(`phase with id:"${currentPhaseId}" not found`)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* gets the triggers for the provided phase
|
||||||
|
*/
|
||||||
|
getTriggersInPhase: (currentPhaseId) => {
|
||||||
|
const program = get().currentProgram;
|
||||||
|
const phase = program.phases.find(val => val["id"] === currentPhaseId);
|
||||||
|
if (phase) {
|
||||||
|
return phase["triggers"] as Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
throw new Error(`phase with id:"${currentPhaseId}" not found`)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useProgramStore;
|
||||||
328
test/components/Logging/Filters.test.tsx
Normal file
328
test/components/Logging/Filters.test.tsx
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import {render, screen, waitFor, fireEvent} from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type ControlledUseState = typeof React.useState & {
|
||||||
|
__forceNextReturn?: (value: any) => jest.Mock;
|
||||||
|
__resetMockState?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock("react", () => {
|
||||||
|
const actual = jest.requireActual("react");
|
||||||
|
const queue: Array<{value: any; setter: jest.Mock}> = [];
|
||||||
|
const mockUseState = ((initial: any) => {
|
||||||
|
if (queue.length) {
|
||||||
|
const {value, setter} = queue.shift()!;
|
||||||
|
return [value, setter];
|
||||||
|
}
|
||||||
|
return actual.useState(initial);
|
||||||
|
}) as ControlledUseState;
|
||||||
|
|
||||||
|
mockUseState.__forceNextReturn = (value: any) => {
|
||||||
|
const setter = jest.fn();
|
||||||
|
queue.push({value, setter});
|
||||||
|
return setter;
|
||||||
|
};
|
||||||
|
mockUseState.__resetMockState = () => {
|
||||||
|
queue.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
...actual,
|
||||||
|
useState: mockUseState,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
import Filters from "../../../src/components/Logging/Filters.tsx";
|
||||||
|
import type {LogFilterPredicate, LogRecord} from "../../../src/components/Logging/useLogs.ts";
|
||||||
|
|
||||||
|
const GLOBAL = "global_log_level";
|
||||||
|
const AGENT_PREFIX = "agent_log_level_";
|
||||||
|
const optionMapping = new Map([
|
||||||
|
["ALL", 0],
|
||||||
|
["DEBUG", 10],
|
||||||
|
["INFO", 20],
|
||||||
|
["WARNING", 30],
|
||||||
|
["ERROR", 40],
|
||||||
|
["CRITICAL", 50],
|
||||||
|
["NONE", 999_999_999_999],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const controlledUseState = React.useState as ControlledUseState;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
controlledUseState.__resetMockState?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
function getCallArg<T>(mock: jest.Mock, index = 0): T {
|
||||||
|
return mock.mock.calls[index][0] as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleRecord(levelno: number, name = "any.logger"): LogRecord {
|
||||||
|
return {
|
||||||
|
levelname: "UNKNOWN",
|
||||||
|
levelno,
|
||||||
|
name,
|
||||||
|
message: "Whatever",
|
||||||
|
created: 0,
|
||||||
|
relativeCreated: 0,
|
||||||
|
firstCreated: 0,
|
||||||
|
firstRelativeCreated: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("Filters", () => {
|
||||||
|
describe("Global level filter", () => {
|
||||||
|
it("initializes to INFO when missing", async () => {
|
||||||
|
const setFilterPredicates = jest.fn();
|
||||||
|
const filterPredicates = new Map<string, LogFilterPredicate>();
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<Filters
|
||||||
|
filterPredicates={filterPredicates}
|
||||||
|
setFilterPredicates={setFilterPredicates}
|
||||||
|
agentNames={new Set<string>()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Effect sets default to INFO
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(setFilterPredicates).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
|
||||||
|
const newMap = updater(filterPredicates);
|
||||||
|
const global = newMap.get(GLOBAL)!;
|
||||||
|
|
||||||
|
expect(global.value).toBe("INFO");
|
||||||
|
expect(global.priority).toBe(0);
|
||||||
|
// Predicate gate at INFO (>= 20)
|
||||||
|
expect(global.predicate(sampleRecord(10))).toBe(false);
|
||||||
|
expect(global.predicate(sampleRecord(20))).toBe(true);
|
||||||
|
|
||||||
|
// UI shows INFO selected after parent state updates
|
||||||
|
view.rerender(
|
||||||
|
<Filters
|
||||||
|
filterPredicates={newMap}
|
||||||
|
setFilterPredicates={setFilterPredicates}
|
||||||
|
agentNames={new Set<string>()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const globalSelect = screen.getByLabelText("Global:");
|
||||||
|
expect((globalSelect as HTMLSelectElement).value).toBe("INFO");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates predicate when selecting a higher level", async () => {
|
||||||
|
// Start with INFO already present
|
||||||
|
const existing = new Map<string, LogFilterPredicate>([
|
||||||
|
[
|
||||||
|
GLOBAL,
|
||||||
|
{
|
||||||
|
value: "INFO",
|
||||||
|
priority: 0,
|
||||||
|
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setFilterPredicates = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Filters
|
||||||
|
filterPredicates={existing}
|
||||||
|
setFilterPredicates={setFilterPredicates}
|
||||||
|
agentNames={new Set<string>()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("Global:");
|
||||||
|
await user.selectOptions(select, "ERROR");
|
||||||
|
|
||||||
|
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
|
||||||
|
const updated = updater(existing);
|
||||||
|
const global = updated.get(GLOBAL)!;
|
||||||
|
|
||||||
|
expect(global.value).toBe("ERROR");
|
||||||
|
expect(global.priority).toBe(0);
|
||||||
|
expect(global.predicate(sampleRecord(30))).toBe(false);
|
||||||
|
expect(global.predicate(sampleRecord(40))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Agent level filters", () => {
|
||||||
|
it("adds an agent using the current global level when none specified", async () => {
|
||||||
|
// Global set to WARNING
|
||||||
|
const existing = new Map<string, LogFilterPredicate>([
|
||||||
|
[
|
||||||
|
GLOBAL,
|
||||||
|
{
|
||||||
|
value: "WARNING",
|
||||||
|
priority: 0,
|
||||||
|
predicate: (r: any) => r.levelno >= optionMapping.get("WARNING")!
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setFilterPredicates = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Filters
|
||||||
|
filterPredicates={existing}
|
||||||
|
setFilterPredicates={setFilterPredicates}
|
||||||
|
agentNames={new Set<string>(["pepper.speech", "vision.agent"])}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const addSelect = screen.getByLabelText("Add:");
|
||||||
|
await user.selectOptions(addSelect, "pepper.speech");
|
||||||
|
|
||||||
|
// Agent setter is functional: prev => next
|
||||||
|
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
|
||||||
|
const next = updater(existing);
|
||||||
|
|
||||||
|
const key = AGENT_PREFIX + "pepper.speech";
|
||||||
|
const agentPred = next.get(key)!;
|
||||||
|
|
||||||
|
expect(agentPred.priority).toBe(1);
|
||||||
|
expect(agentPred.value).toEqual({agentName: "pepper.speech", level: "WARNING"});
|
||||||
|
// When agentName matches, enforce WARNING (>= 30)
|
||||||
|
expect(agentPred.predicate(sampleRecord(20, "pepper.speech"))).toBe(false);
|
||||||
|
expect(agentPred.predicate(sampleRecord(30, "pepper.speech"))).toBe(true);
|
||||||
|
// Other agents -> null
|
||||||
|
expect(agentPred.predicate(sampleRecord(999, "other"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes an agent's level when its select is updated", async () => {
|
||||||
|
// Prepopulate agent predicate at WARNING
|
||||||
|
const key = AGENT_PREFIX + "pepper.speech";
|
||||||
|
const existing = new Map<string, LogFilterPredicate>([
|
||||||
|
[
|
||||||
|
GLOBAL,
|
||||||
|
{
|
||||||
|
value: "INFO",
|
||||||
|
priority: 0,
|
||||||
|
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
value: {agentName: "pepper.speech", level: "WARNING"},
|
||||||
|
priority: 1,
|
||||||
|
predicate: (r: any) => (r.name === "pepper.speech" ? r.levelno >= optionMapping.get("WARNING")! : null)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setFilterPredicates = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const element = render(
|
||||||
|
<Filters
|
||||||
|
filterPredicates={existing}
|
||||||
|
setFilterPredicates={setFilterPredicates}
|
||||||
|
agentNames={new Set(["pepper.speech"])}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentSelect = element.container.querySelector("select#log_level_pepper\\.speech")!;
|
||||||
|
|
||||||
|
await user.selectOptions(agentSelect, "ERROR");
|
||||||
|
|
||||||
|
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
|
||||||
|
const next = updater(existing);
|
||||||
|
const updated = next.get(key)!;
|
||||||
|
|
||||||
|
expect(updated.value).toEqual({agentName: "pepper.speech", level: "ERROR"});
|
||||||
|
// Threshold moved to ERROR (>= 40)
|
||||||
|
expect(updated.predicate(sampleRecord(30, "pepper.speech"))).toBe(false);
|
||||||
|
expect(updated.predicate(sampleRecord(40, "pepper.speech"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes an agent predicate when clicking its name button", async () => {
|
||||||
|
const key = AGENT_PREFIX + "pepper.speech";
|
||||||
|
const existing = new Map<string, LogFilterPredicate>([
|
||||||
|
[
|
||||||
|
GLOBAL,
|
||||||
|
{
|
||||||
|
value: "INFO",
|
||||||
|
priority: 0,
|
||||||
|
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
value: {agentName: "pepper.speech", level: "INFO"},
|
||||||
|
priority: 1,
|
||||||
|
predicate: (r: any) => (r.name === "pepper.speech" ? r.levelno >= optionMapping.get("INFO")! : null)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setFilterPredicates = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Filters
|
||||||
|
filterPredicates={existing}
|
||||||
|
setFilterPredicates={setFilterPredicates}
|
||||||
|
agentNames={new Set<string>(["pepper.speech"])}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteBtn = screen.getByRole("button", {name: "speech:"});
|
||||||
|
await user.click(deleteBtn);
|
||||||
|
|
||||||
|
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
|
||||||
|
const next = updater(existing);
|
||||||
|
expect(next.has(key)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Filter popup behavior", () => {
|
||||||
|
function renderWithPopupOpen() {
|
||||||
|
const existing = new Map<string, LogFilterPredicate>([
|
||||||
|
[
|
||||||
|
GLOBAL,
|
||||||
|
{
|
||||||
|
value: "INFO",
|
||||||
|
priority: 0,
|
||||||
|
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
const setFilterPredicates = jest.fn();
|
||||||
|
const forceNext = controlledUseState.__forceNextReturn;
|
||||||
|
if (!forceNext) throw new Error("useState mock missing helper");
|
||||||
|
const setOpen = forceNext(true);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Filters
|
||||||
|
filterPredicates={existing}
|
||||||
|
setFilterPredicates={setFilterPredicates}
|
||||||
|
agentNames={new Set(["pepper.vision"])}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { setOpen };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("closes the popup when clicking outside", () => {
|
||||||
|
const { setOpen } = renderWithPopupOpen();
|
||||||
|
fireEvent.mouseDown(document.body);
|
||||||
|
expect(setOpen).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the popup when pressing Escape", () => {
|
||||||
|
const { setOpen } = renderWithPopupOpen();
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(setOpen).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
239
test/components/Logging/Logging.test.tsx
Normal file
239
test/components/Logging/Logging.test.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import {render, screen, fireEvent, act, waitFor} from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import type {Cell} from "../../../src/utils/cellStore.ts";
|
||||||
|
import {cell} from "../../../src/utils/cellStore.ts";
|
||||||
|
import type {LogFilterPredicate, LogRecord} from "../../../src/components/Logging/useLogs.ts";
|
||||||
|
|
||||||
|
const mockFiltersRender = jest.fn();
|
||||||
|
const loggingStoreRef: { current: null | { setState: (state: Partial<LoggingSettingsState>) => void } } = { current: null };
|
||||||
|
|
||||||
|
type LoggingSettingsState = {
|
||||||
|
showRelativeTime: boolean;
|
||||||
|
setShowRelativeTime: (show: boolean) => void;
|
||||||
|
scrollToBottom: boolean;
|
||||||
|
setScrollToBottom: (scroll: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock("zustand", () => {
|
||||||
|
const actual = jest.requireActual("zustand");
|
||||||
|
const actualCreate = actual.create;
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
...actual,
|
||||||
|
create: (...args: any[]) => {
|
||||||
|
const store = actualCreate(...args);
|
||||||
|
const state = store.getState();
|
||||||
|
if ("setShowRelativeTime" in state && "setScrollToBottom" in state) {
|
||||||
|
loggingStoreRef.current = store;
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("../../../src/components/Logging/Filters.tsx", () => {
|
||||||
|
const React = jest.requireActual("react");
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
default: (props: any) => {
|
||||||
|
mockFiltersRender(props);
|
||||||
|
return React.createElement("div", {"data-testid": "filters-mock"}, "filters");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("../../../src/components/Logging/useLogs.ts", () => {
|
||||||
|
const actual = jest.requireActual("../../../src/components/Logging/useLogs.ts");
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
...actual,
|
||||||
|
useLogs: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import {useLogs} from "../../../src/components/Logging/useLogs.ts";
|
||||||
|
const mockUseLogs = useLogs as jest.MockedFunction<typeof useLogs>;
|
||||||
|
|
||||||
|
type LoggingComponent = typeof import("../../../src/components/Logging/Logging.tsx").default;
|
||||||
|
let Logging: LoggingComponent;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (!Element.prototype.scrollIntoView) {
|
||||||
|
Object.defineProperty(Element.prototype, "scrollIntoView", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: function () {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
({default: Logging} = await import("../../../src/components/Logging/Logging.tsx"));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseLogs.mockReset();
|
||||||
|
mockFiltersRender.mockReset();
|
||||||
|
mockUseLogs.mockReturnValue({filteredLogs: [], distinctNames: new Set()});
|
||||||
|
resetLoggingStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetLoggingStore() {
|
||||||
|
loggingStoreRef.current?.setState({
|
||||||
|
showRelativeTime: false,
|
||||||
|
scrollToBottom: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRecord(overrides: Partial<LogRecord> = {}): LogRecord {
|
||||||
|
return {
|
||||||
|
name: "pepper.logger",
|
||||||
|
message: "default",
|
||||||
|
levelname: "INFO",
|
||||||
|
levelno: 20,
|
||||||
|
created: 1,
|
||||||
|
relativeCreated: 1,
|
||||||
|
firstCreated: 1,
|
||||||
|
firstRelativeCreated: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCell(overrides: Partial<LogRecord> = {}): Cell<LogRecord> {
|
||||||
|
return cell(makeRecord(overrides));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Logging component", () => {
|
||||||
|
it("renders log messages and toggles the timestamp between absolute and relative view", async () => {
|
||||||
|
const logCell = makeCell({
|
||||||
|
name: "pepper.trace.logging",
|
||||||
|
message: "Ping",
|
||||||
|
levelname: "WARNING",
|
||||||
|
levelno: 30,
|
||||||
|
created: 1_700_000_000,
|
||||||
|
relativeCreated: 12_345,
|
||||||
|
firstCreated: 1_700_000_000,
|
||||||
|
firstRelativeCreated: 12_345,
|
||||||
|
});
|
||||||
|
|
||||||
|
const names = new Set(["pepper.trace.logging"]);
|
||||||
|
mockUseLogs.mockReturnValue({filteredLogs: [logCell], distinctNames: names});
|
||||||
|
|
||||||
|
jest.spyOn(Date.prototype, "toLocaleTimeString").mockReturnValue("ABS TIME");
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<Logging/>);
|
||||||
|
|
||||||
|
expect(screen.getByText("Logs")).toBeDefined();
|
||||||
|
expect(screen.getByText("WARNING")).toBeDefined();
|
||||||
|
expect(screen.getByText("logging")).toBeDefined();
|
||||||
|
expect(screen.getByText("Ping")).toBeDefined();
|
||||||
|
|
||||||
|
let timestamp = screen.queryByText("ABS TIME");
|
||||||
|
if (!timestamp) {
|
||||||
|
// if previous test left the store toggled, click once to show absolute time
|
||||||
|
timestamp = screen.getByText("00:00:12.345");
|
||||||
|
await user.click(timestamp);
|
||||||
|
timestamp = screen.getByText("ABS TIME");
|
||||||
|
}
|
||||||
|
|
||||||
|
await user.click(timestamp);
|
||||||
|
expect(screen.getByText("00:00:12.345")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the scroll-to-bottom button after a manual scroll and scrolls when clicked", async () => {
|
||||||
|
const logs = [
|
||||||
|
makeCell({message: "first", firstRelativeCreated: 1}),
|
||||||
|
makeCell({message: "second", firstRelativeCreated: 2}),
|
||||||
|
];
|
||||||
|
mockUseLogs.mockReturnValue({filteredLogs: logs, distinctNames: new Set()});
|
||||||
|
|
||||||
|
const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const view = render(<Logging/>);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", {name: "Scroll to bottom"})).toBeNull();
|
||||||
|
|
||||||
|
const scrollable = view.container.querySelector(".scroll-y");
|
||||||
|
expect(scrollable).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.wheel(scrollable!);
|
||||||
|
|
||||||
|
const button = await screen.findByRole("button", {name: "Scroll to bottom"});
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
expect(scrollSpy).toHaveBeenCalled();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("button", {name: "Scroll to bottom"})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scrolls the last element into view when a log cell updates", async () => {
|
||||||
|
const logCell = makeCell({message: "Initial", firstRelativeCreated: 42});
|
||||||
|
mockUseLogs.mockReturnValue({filteredLogs: [logCell], distinctNames: new Set()});
|
||||||
|
|
||||||
|
const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {});
|
||||||
|
render(<Logging/>);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
scrollSpy.mockClear();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const current = logCell.get();
|
||||||
|
logCell.set({...current, message: "Updated"});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Updated")).toBeDefined();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes filter state to Filters and re-invokes useLogs when predicates change", async () => {
|
||||||
|
const distinct = new Set(["pepper.core"]);
|
||||||
|
mockUseLogs.mockImplementation((_filters: Map<string, LogFilterPredicate>) => ({
|
||||||
|
filteredLogs: [],
|
||||||
|
distinctNames: distinct,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(<Logging/>);
|
||||||
|
|
||||||
|
expect(mockFiltersRender).toHaveBeenCalledTimes(1);
|
||||||
|
const firstProps = mockFiltersRender.mock.calls[0][0];
|
||||||
|
expect(firstProps.agentNames).toBe(distinct);
|
||||||
|
|
||||||
|
const initialMap = firstProps.filterPredicates;
|
||||||
|
expect(initialMap).toBeInstanceOf(Map);
|
||||||
|
expect(initialMap.size).toBe(0);
|
||||||
|
expect(mockUseLogs).toHaveBeenCalledWith(initialMap);
|
||||||
|
|
||||||
|
const updatedPredicate: LogFilterPredicate = {
|
||||||
|
value: "custom",
|
||||||
|
priority: 0,
|
||||||
|
predicate: () => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
firstProps.setFilterPredicates((prev: Map<string, LogFilterPredicate>) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set("custom", updatedPredicate);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUseLogs).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextFilters = mockUseLogs.mock.calls[1][0];
|
||||||
|
expect(nextFilters.get("custom")).toBe(updatedPredicate);
|
||||||
|
|
||||||
|
const secondProps = mockFiltersRender.mock.calls[mockFiltersRender.mock.calls.length - 1][0];
|
||||||
|
expect(secondProps.filterPredicates).toBe(nextFilters);
|
||||||
|
});
|
||||||
|
});
|
||||||
246
test/components/Logging/useLogs.test.tsx
Normal file
246
test/components/Logging/useLogs.test.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { render, screen, act } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import {type LogRecord, useLogs} from "../../../src/components/Logging/useLogs.ts";
|
||||||
|
import {type cell, useCell} from "../../../src/utils/cellStore.ts";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
|
||||||
|
jest.mock("../../../src/utils/priorityFiltering.ts", () => ({
|
||||||
|
applyPriorityPredicates: jest.fn((_log, preds: any[]) =>
|
||||||
|
preds.every(() => true) // default: pass all
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
import {applyPriorityPredicates} from "../../../src/utils/priorityFiltering.ts";
|
||||||
|
|
||||||
|
class MockEventSource {
|
||||||
|
url: string;
|
||||||
|
onmessage: ((event: { data: string }) => void) | null = null;
|
||||||
|
onerror: ((event: unknown) => void) | null = null;
|
||||||
|
close = jest.fn();
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url;
|
||||||
|
// expose the latest instance for tests:
|
||||||
|
(globalThis as any).__es = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
globalThis.EventSource = MockEventSource as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// reset mock so previous instance not reused accidentally
|
||||||
|
(globalThis as any).__es = undefined;
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function LogsProbe({ filters }: { filters: Map<string, any> }) {
|
||||||
|
const { filteredLogs, distinctNames } = useLogs(filters);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="names-count">{distinctNames.size}</div>
|
||||||
|
<ul data-testid="logs">
|
||||||
|
{filteredLogs.map((c, i) => (
|
||||||
|
<LogItem key={i} cell={c} index={i} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogItem({ cell: c, index }: { cell: ReturnType<typeof cell<LogRecord>>; index: number }) {
|
||||||
|
const value = useCell(c);
|
||||||
|
return (
|
||||||
|
<li data-testid={`log-${index}`}>
|
||||||
|
<span data-testid={`log-${index}-name`}>{value.name}</span>
|
||||||
|
<span data-testid={`log-${index}-msg`}>{value.message}</span>
|
||||||
|
<span data-testid={`log-${index}-first`}>{String(value.firstCreated)}</span>
|
||||||
|
<span data-testid={`log-${index}-created`}>{String(value.created)}</span>
|
||||||
|
<span data-testid={`log-${index}-ref`}>{value.reference ?? ""}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(log: LogRecord) {
|
||||||
|
const eventSource = (globalThis as any).__es as MockEventSource;
|
||||||
|
if (!eventSource || !eventSource.onmessage) throw new Error("EventSource not initialized");
|
||||||
|
act(() => {
|
||||||
|
eventSource.onmessage!({ data: JSON.stringify(log) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useLogs (unit)", () => {
|
||||||
|
it("creates EventSource once and closes on unmount", () => {
|
||||||
|
const filters = new Map(); // allow all by default
|
||||||
|
const { unmount } = render(
|
||||||
|
<StrictMode>
|
||||||
|
<LogsProbe filters={filters} />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
const es = (globalThis as any).__es as MockEventSource;
|
||||||
|
expect(es).toBeTruthy();
|
||||||
|
expect(es.url).toBe("http://localhost:8000/logs/stream");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
expect(es.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends filtered logs and collects distinct names", () => {
|
||||||
|
const filters = new Map();
|
||||||
|
render(
|
||||||
|
<StrictMode>
|
||||||
|
<LogsProbe filters={filters} />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("names-count")).toHaveTextContent("0");
|
||||||
|
|
||||||
|
emit({
|
||||||
|
levelname: "DEBUG",
|
||||||
|
levelno: 10,
|
||||||
|
name: "alpha",
|
||||||
|
message: "m1",
|
||||||
|
created: 1,
|
||||||
|
relativeCreated: 1,
|
||||||
|
firstCreated: 1,
|
||||||
|
firstRelativeCreated: 1,
|
||||||
|
});
|
||||||
|
emit({
|
||||||
|
levelname: "DEBUG",
|
||||||
|
levelno: 10,
|
||||||
|
name: "beta",
|
||||||
|
message: "m2",
|
||||||
|
created: 2,
|
||||||
|
relativeCreated: 2,
|
||||||
|
firstCreated: 2,
|
||||||
|
firstRelativeCreated: 2,
|
||||||
|
});
|
||||||
|
emit({
|
||||||
|
levelname: "DEBUG",
|
||||||
|
levelno: 10,
|
||||||
|
name: "alpha",
|
||||||
|
message: "m3",
|
||||||
|
created: 3,
|
||||||
|
relativeCreated: 3,
|
||||||
|
firstCreated: 3,
|
||||||
|
firstRelativeCreated: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3 messages (no reference), 2 distinct names
|
||||||
|
expect(screen.getAllByRole("listitem")).toHaveLength(3);
|
||||||
|
expect(screen.getByTestId("names-count")).toHaveTextContent("2");
|
||||||
|
|
||||||
|
expect(screen.getByTestId("log-0-name")).toHaveTextContent("alpha");
|
||||||
|
expect(screen.getByTestId("log-1-name")).toHaveTextContent("beta");
|
||||||
|
expect(screen.getByTestId("log-2-name")).toHaveTextContent("alpha");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates first message with reference when a second one with that reference comes", () => {
|
||||||
|
const filters = new Map();
|
||||||
|
render(<LogsProbe filters={filters} />);
|
||||||
|
|
||||||
|
// First message with ref r1
|
||||||
|
emit({
|
||||||
|
levelname: "DEBUG",
|
||||||
|
levelno: 10,
|
||||||
|
name: "svc",
|
||||||
|
message: "first",
|
||||||
|
reference: "r1",
|
||||||
|
created: 10,
|
||||||
|
relativeCreated: 10,
|
||||||
|
firstCreated: 10,
|
||||||
|
firstRelativeCreated: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second message with same ref r1, should still be a single item
|
||||||
|
emit({
|
||||||
|
levelname: "DEBUG",
|
||||||
|
levelno: 10,
|
||||||
|
name: "svc",
|
||||||
|
message: "second",
|
||||||
|
reference: "r1",
|
||||||
|
created: 20,
|
||||||
|
relativeCreated: 20,
|
||||||
|
firstCreated: 20,
|
||||||
|
firstRelativeCreated: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = screen.getAllByRole("listitem");
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
|
||||||
|
// Same single item, but message should be "second"
|
||||||
|
expect(screen.getByTestId("log-0-msg")).toHaveTextContent("second");
|
||||||
|
// The "firstCreated" should remain the original (10), while "created" is now 20
|
||||||
|
expect(screen.getByTestId("log-0-first")).toHaveTextContent("10");
|
||||||
|
expect(screen.getByTestId("log-0-created")).toHaveTextContent("20");
|
||||||
|
expect(screen.getByTestId("log-0-ref")).toHaveTextContent("r1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs recomputeFiltered when filters change", () => {
|
||||||
|
const allowAll = new Map<string, any>();
|
||||||
|
const { rerender } = render(<LogsProbe filters={allowAll} />);
|
||||||
|
|
||||||
|
emit({
|
||||||
|
levelname: "DEBUG",
|
||||||
|
levelno: 10,
|
||||||
|
name: "n1",
|
||||||
|
message: "ok",
|
||||||
|
created: 1,
|
||||||
|
relativeCreated: 1,
|
||||||
|
firstCreated: 1,
|
||||||
|
firstRelativeCreated: 1,
|
||||||
|
});
|
||||||
|
emit({
|
||||||
|
levelname: "DEBUG",
|
||||||
|
levelno: 10,
|
||||||
|
name: "n2",
|
||||||
|
message: "ok",
|
||||||
|
created: 2,
|
||||||
|
relativeCreated: 2,
|
||||||
|
firstCreated: 2,
|
||||||
|
firstRelativeCreated: 2,
|
||||||
|
});
|
||||||
|
emit({
|
||||||
|
levelname: "INFO",
|
||||||
|
levelno: 20,
|
||||||
|
name: "n3",
|
||||||
|
message: "ok1",
|
||||||
|
reference: "r1",
|
||||||
|
created: 3,
|
||||||
|
relativeCreated: 3,
|
||||||
|
firstCreated: 3,
|
||||||
|
firstRelativeCreated: 3,
|
||||||
|
});
|
||||||
|
emit({
|
||||||
|
levelname: "INFO",
|
||||||
|
levelno: 20,
|
||||||
|
name: "n3",
|
||||||
|
message: "ok2",
|
||||||
|
reference: "r1",
|
||||||
|
created: 4,
|
||||||
|
relativeCreated: 4,
|
||||||
|
firstCreated: 4,
|
||||||
|
firstRelativeCreated: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getAllByRole("listitem")).toHaveLength(3);
|
||||||
|
|
||||||
|
// Now change filters to block all < INFO
|
||||||
|
(applyPriorityPredicates as jest.Mock).mockImplementation((l) => l.levelno >= 20);
|
||||||
|
const blockDebug = new Map<string, any>([["dummy", { value: true }]]);
|
||||||
|
rerender(<LogsProbe filters={blockDebug} />);
|
||||||
|
|
||||||
|
// Should recompute with shorter list
|
||||||
|
expect(screen.queryAllByRole("listitem")).toHaveLength(1);
|
||||||
|
|
||||||
|
// Switch back to allow-all
|
||||||
|
(applyPriorityPredicates as jest.Mock).mockImplementation((_log, preds: any[]) =>
|
||||||
|
preds.every(() => true)
|
||||||
|
);
|
||||||
|
rerender(<LogsProbe filters={allowAll} />);
|
||||||
|
|
||||||
|
// recompute should restore all three
|
||||||
|
expect(screen.getAllByRole("listitem")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
0
test/eslint.config.js.ts
Normal file
0
test/eslint.config.js.ts
Normal file
104
test/pages/connectedRobots/ConnectedRobots.test.tsx
Normal file
104
test/pages/connectedRobots/ConnectedRobots.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { render, screen, act, cleanup, waitFor } from '@testing-library/react';
|
||||||
|
import ConnectedRobots from '../../../src/pages/ConnectedRobots/ConnectedRobots';
|
||||||
|
|
||||||
|
// Mock event source
|
||||||
|
const mockInstances: MockEventSource[] = [];
|
||||||
|
class MockEventSource {
|
||||||
|
url: string;
|
||||||
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||||
|
closed = false;
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url;
|
||||||
|
mockInstances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessage(data: string) {
|
||||||
|
// Trigger whatever the component listens to
|
||||||
|
this.onmessage?.({ data } as MessageEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mock event source generation with fake function that returns our fake mock source
|
||||||
|
beforeAll(() => {
|
||||||
|
// Cast globalThis to a type exposing EventSource and assign a mocked constructor.
|
||||||
|
(globalThis as unknown as { EventSource?: typeof EventSource }).EventSource =
|
||||||
|
jest.fn((url: string) => new MockEventSource(url)) as unknown as typeof EventSource;
|
||||||
|
});
|
||||||
|
|
||||||
|
// clean after tests
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
mockInstances.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ConnectedRobots', () => {
|
||||||
|
test('renders initial state correctly', () => {
|
||||||
|
render(<ConnectedRobots />);
|
||||||
|
|
||||||
|
// Check initial texts (before connection)
|
||||||
|
expect(screen.getByText('Is robot currently connected?')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Robot is currently:\s*checking/i)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/If checking continues, make sure CB is properly loaded/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates to connected when message data is true', async () => {
|
||||||
|
render(<ConnectedRobots />);
|
||||||
|
const eventSource = mockInstances[0];
|
||||||
|
expect(eventSource).toBeDefined();
|
||||||
|
|
||||||
|
// Check state after getting 'true' message
|
||||||
|
await act(async () => {
|
||||||
|
eventSource.sendMessage('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/connected! 🟢/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates to not connected when message data is false', async () => {
|
||||||
|
render(<ConnectedRobots />);
|
||||||
|
const eventSource = mockInstances[0];
|
||||||
|
|
||||||
|
// Check statew after getting 'false' message
|
||||||
|
await act(async () => {
|
||||||
|
eventSource.sendMessage('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/not connected.*🔴/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles invalid JSON gracefully', async () => {
|
||||||
|
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
render(<ConnectedRobots />);
|
||||||
|
const eventSource = mockInstances[0];
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
eventSource.sendMessage('not-json');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
|
'Ping message not in correct format:',
|
||||||
|
'not-json'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closes EventSource on unmount', () => {
|
||||||
|
render(<ConnectedRobots />);
|
||||||
|
const eventSource = mockInstances[0];
|
||||||
|
const closeSpy = jest.spyOn(eventSource, 'close');
|
||||||
|
cleanup();
|
||||||
|
expect(closeSpy).toHaveBeenCalled();
|
||||||
|
expect(eventSource.closed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
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,267 @@
|
|||||||
|
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: [],
|
||||||
|
warnings: {
|
||||||
|
warningRegistry: new Map(),
|
||||||
|
severityIndex: new Map()
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
ruleRegistry: new Map(),
|
||||||
|
editorWarningRegistry: new Map(),
|
||||||
|
severityIndex: new Map()
|
||||||
|
});
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
warnings: {
|
||||||
|
warningRegistry: {},
|
||||||
|
severityIndex: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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: [],
|
||||||
|
editorWarningRegistry: new Map(),
|
||||||
|
severityIndex: new Map()
|
||||||
|
});
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
warnings: {
|
||||||
|
warningRegistry: {},
|
||||||
|
severityIndex: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
editorWarningRegistry: new Map(),
|
||||||
|
severityIndex: new Map()
|
||||||
|
});
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
warnings: {
|
||||||
|
warningRegistry: {},
|
||||||
|
severityIndex: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
editorWarningRegistry: new Map(),
|
||||||
|
severityIndex: new Map()
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,986 +1,5 @@
|
|||||||
import type {Edge} from "@xyflow/react";
|
describe('not yet implemented', () => {
|
||||||
import graphReducer, {
|
test('nothing yet', () => {
|
||||||
defaultGraphPreprocessor, defaultPhaseReducer,
|
expect(true);
|
||||||
orderPhases
|
|
||||||
} from "../../../../src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts";
|
|
||||||
import type {PreparedPhase} from "../../../../src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts";
|
|
||||||
import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
|
||||||
import type {AppNode} from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx";
|
|
||||||
|
|
||||||
// sets of default values for nodes and edges to be used for test cases
|
|
||||||
type FlowState = {
|
|
||||||
name: string;
|
|
||||||
nodes: AppNode[];
|
|
||||||
edges: Edge[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// predefined graphs for testing:
|
|
||||||
const onlyOnePhase : FlowState = {
|
|
||||||
name: "onlyOnePhase",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
type: 'start',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {label: 'start'}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges:[
|
|
||||||
{
|
|
||||||
id: 'start-phase-1',
|
|
||||||
source: 'start',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1-end',
|
|
||||||
source: 'phase-1',
|
|
||||||
target: 'end',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
const onlyThreePhases : FlowState = {
|
|
||||||
name: "onlyThreePhases",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
type: 'start',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {label: 'start'}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 3},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges:[
|
|
||||||
{
|
|
||||||
id: 'start-phase-1',
|
|
||||||
source: 'start',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1-phase-2',
|
|
||||||
source: 'phase-1',
|
|
||||||
target: 'phase-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2-phase-3',
|
|
||||||
source: 'phase-2',
|
|
||||||
target: 'phase-3',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3-end',
|
|
||||||
source: 'phase-3',
|
|
||||||
target: 'end',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
const onlySingleEdgeNorms : FlowState = {
|
|
||||||
name: "onlySingleEdgeNorms",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
type: 'start',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {label: 'start'}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-1',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 3},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges:[
|
|
||||||
{
|
|
||||||
id: 'start-phase-1',
|
|
||||||
source: 'start',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-1-phase-2',
|
|
||||||
source: 'norm-1',
|
|
||||||
target: 'phase-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1-phase-2',
|
|
||||||
source: 'phase-1',
|
|
||||||
target: 'phase-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2-phase-3',
|
|
||||||
source: 'phase-2',
|
|
||||||
target: 'phase-3',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2-phase-3',
|
|
||||||
source: 'norm-2',
|
|
||||||
target: 'phase-3',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3-end',
|
|
||||||
source: 'phase-3',
|
|
||||||
target: 'end',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
const multiEdgeNorms : FlowState = {
|
|
||||||
name: "multiEdgeNorms",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
type: 'start',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {label: 'start'}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-1',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 3},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-3',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges:[
|
|
||||||
{
|
|
||||||
id: 'start-phase-1',
|
|
||||||
source: 'start',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-1-phase-2',
|
|
||||||
source: 'norm-1',
|
|
||||||
target: 'phase-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-1-phase-3',
|
|
||||||
source: 'norm-1',
|
|
||||||
target: 'phase-3',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1-phase-2',
|
|
||||||
source: 'phase-1',
|
|
||||||
target: 'phase-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-3-phase-1',
|
|
||||||
source: 'norm-3',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2-phase-3',
|
|
||||||
source: 'phase-2',
|
|
||||||
target: 'phase-3',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2-phase-3',
|
|
||||||
source: 'norm-2',
|
|
||||||
target: 'phase-3',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2-phase-2',
|
|
||||||
source: 'norm-2',
|
|
||||||
target: 'phase-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3-end',
|
|
||||||
source: 'phase-3',
|
|
||||||
target: 'end',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
const onlyStartEnd : FlowState = {
|
|
||||||
name: "onlyStartEnd",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
type: 'start',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {label: 'start'}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges:[
|
|
||||||
{
|
|
||||||
id: 'start-end',
|
|
||||||
source: 'start',
|
|
||||||
target: 'end',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
// states that contain invalid programs for testing if correct errors are thrown:
|
|
||||||
const phaseConnectsToInvalidNodeType : FlowState = {
|
|
||||||
name: "phaseConnectsToInvalidNodeType",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
type: 'start',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {label: 'start'}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'default-1',
|
|
||||||
type: 'default',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm'},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges:[
|
|
||||||
{
|
|
||||||
id: 'start-phase-1',
|
|
||||||
source: 'start',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1-default-1',
|
|
||||||
source: 'phase-1',
|
|
||||||
target: 'default-1',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
};
|
|
||||||
const phaseHasNoOutgoingConnections : FlowState = {
|
|
||||||
name: "phaseHasNoOutgoingConnections",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
type: 'start',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {label: 'start'}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges:[
|
|
||||||
{
|
|
||||||
id: 'start-phase-1',
|
|
||||||
source: 'start',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
};
|
|
||||||
const phaseHasTooManyOutgoingConnections : FlowState = {
|
|
||||||
name: "phaseHasTooManyOutgoingConnections",
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
type: 'start',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {label: 'start'}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
type: 'end',
|
|
||||||
position: {x: 0, y: 300},
|
|
||||||
data: {label: 'End'}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
edges:[
|
|
||||||
{
|
|
||||||
id: 'start-phase-1',
|
|
||||||
source: 'start',
|
|
||||||
target: 'phase-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1-phase-2',
|
|
||||||
source: 'phase-1',
|
|
||||||
target: 'phase-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-1-end',
|
|
||||||
source: 'phase-1',
|
|
||||||
target: 'end',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2-end',
|
|
||||||
source: 'phase-2',
|
|
||||||
target: 'end',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Graph Reducer Tests', () => {
|
|
||||||
describe('defaultGraphPreprocessor', () => {
|
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
state: onlyOnePhase,
|
|
||||||
expected: [
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
connectedNorms: [],
|
|
||||||
connectedGoals: [],
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlyThreePhases,
|
|
||||||
expected: [
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'phase-2',
|
|
||||||
connectedNorms: [],
|
|
||||||
connectedGoals: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'phase-3',
|
|
||||||
connectedNorms: [],
|
|
||||||
connectedGoals: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-3',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 3},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
connectedNorms: [],
|
|
||||||
connectedGoals: [],
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlySingleEdgeNorms,
|
|
||||||
expected: [
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'phase-2',
|
|
||||||
connectedNorms: [],
|
|
||||||
connectedGoals: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'phase-3',
|
|
||||||
connectedNorms: [{
|
|
||||||
id: 'norm-1',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
}],
|
|
||||||
connectedGoals: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-3',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 3},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
connectedNorms: [{
|
|
||||||
id: 'norm-2',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
}],
|
|
||||||
connectedGoals: [],
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: multiEdgeNorms,
|
|
||||||
expected: [
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'phase-2',
|
|
||||||
connectedNorms: [{
|
|
||||||
id: 'norm-3',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
}],
|
|
||||||
connectedGoals: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'phase-3',
|
|
||||||
connectedNorms: [{
|
|
||||||
id: 'norm-1',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
}],
|
|
||||||
connectedGoals: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-3',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 3},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
connectedNorms: [{
|
|
||||||
id: 'norm-1',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
}],
|
|
||||||
connectedGoals: [],
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlyStartEnd,
|
|
||||||
expected: [],
|
|
||||||
}
|
|
||||||
])(`tests state: $state.name`, ({state, expected}) => {
|
|
||||||
const output = defaultGraphPreprocessor(state.nodes, state.edges);
|
|
||||||
expect(output).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
describe("orderPhases", () => {
|
});
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
state: onlyOnePhase,
|
|
||||||
expected: {
|
|
||||||
phaseNodes: [{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
}],
|
|
||||||
connections: new Map<string,string>([["phase-1","end"]])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlyThreePhases,
|
|
||||||
expected: {
|
|
||||||
phaseNodes: [
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 3},
|
|
||||||
}],
|
|
||||||
connections: new Map<string,string>([
|
|
||||||
["phase-1","phase-2"],
|
|
||||||
["phase-2","phase-3"],
|
|
||||||
["phase-3","end"]
|
|
||||||
])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlySingleEdgeNorms,
|
|
||||||
expected: {
|
|
||||||
phaseNodes: [
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 2},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 3},
|
|
||||||
}],
|
|
||||||
connections: new Map<string,string>([
|
|
||||||
["phase-1","phase-2"],
|
|
||||||
["phase-2","phase-3"],
|
|
||||||
["phase-3","end"]
|
|
||||||
])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlyStartEnd,
|
|
||||||
expected: {
|
|
||||||
phaseNodes: [],
|
|
||||||
connections: new Map<string,string>()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])(`tests state: $state.name`, ({state, expected}) => {
|
|
||||||
const output = orderPhases(state.nodes, state.edges);
|
|
||||||
expect(output.phaseNodes).toEqual(expected.phaseNodes);
|
|
||||||
expect(output.connections).toEqual(expected.connections);
|
|
||||||
});
|
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
state: phaseConnectsToInvalidNodeType,
|
|
||||||
expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: phaseHasNoOutgoingConnections,
|
|
||||||
expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: phaseHasTooManyOutgoingConnections,
|
|
||||||
expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets')
|
|
||||||
}
|
|
||||||
])(`tests erroneous state: $state.name`, ({state, expected}) => {
|
|
||||||
const testForError = () => {
|
|
||||||
orderPhases(state.nodes, state.edges);
|
|
||||||
};
|
|
||||||
expect(testForError).toThrow(expected);
|
|
||||||
})
|
|
||||||
})
|
|
||||||
describe("defaultPhaseReducer", () => {
|
|
||||||
test("phaseReducer handles empty norms and goals without failing", () => {
|
|
||||||
const input : PreparedPhase = {
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
connectedNorms: [],
|
|
||||||
connectedGoals: [],
|
|
||||||
}
|
|
||||||
const output = defaultPhaseReducer(input);
|
|
||||||
expect(output).toEqual({
|
|
||||||
id: 'phase-1',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
phaseData: {
|
|
||||||
norms: [],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
test("defaultNormReducer reduces norms correctly", () => {
|
|
||||||
const input : PreparedPhase = {
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
connectedNorms: [{
|
|
||||||
id: 'norm-1',
|
|
||||||
type: 'norm',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Norm', value: "generic"},
|
|
||||||
}],
|
|
||||||
connectedGoals: [],
|
|
||||||
}
|
|
||||||
const output = defaultPhaseReducer(input);
|
|
||||||
expect(output).toEqual({
|
|
||||||
id: 'phase-1',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
phaseData: {
|
|
||||||
norms: [{
|
|
||||||
id: 'norm-1',
|
|
||||||
name: 'Generic Norm',
|
|
||||||
value: "generic"
|
|
||||||
}],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
test("defaultGoalReducer reduces goals correctly", () => {
|
|
||||||
const input : PreparedPhase = {
|
|
||||||
phaseNode: {
|
|
||||||
id: 'phase-1',
|
|
||||||
type: 'phase',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Phase', number: 1},
|
|
||||||
},
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
connectedNorms: [],
|
|
||||||
connectedGoals: [{
|
|
||||||
id: 'goal-1',
|
|
||||||
type: 'goal',
|
|
||||||
position: {x: 0, y: 150},
|
|
||||||
data: {label: 'Generic Goal', value: "generic"},
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
const output = defaultPhaseReducer(input);
|
|
||||||
expect(output).toEqual({
|
|
||||||
id: 'phase-1',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
phaseData: {
|
|
||||||
norms: [],
|
|
||||||
goals: [{
|
|
||||||
id: 'goal-1',
|
|
||||||
name: 'Generic Goal',
|
|
||||||
value: "generic"
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
describe("GraphReducer", () => {
|
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
state: onlyOnePhase,
|
|
||||||
expected: [
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
phaseData: {
|
|
||||||
norms: [],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlyThreePhases,
|
|
||||||
expected: [
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'phase-2',
|
|
||||||
phaseData: {
|
|
||||||
norms: [],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'phase-3',
|
|
||||||
phaseData: {
|
|
||||||
norms: [],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
phaseData: {
|
|
||||||
norms: [],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlySingleEdgeNorms,
|
|
||||||
expected: [
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'phase-2',
|
|
||||||
phaseData: {
|
|
||||||
norms: [],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'phase-3',
|
|
||||||
phaseData: {
|
|
||||||
norms: [
|
|
||||||
{
|
|
||||||
id: 'norm-1',
|
|
||||||
name: 'Generic Norm',
|
|
||||||
value: "generic"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
phaseData: {
|
|
||||||
norms: [{
|
|
||||||
id: 'norm-2',
|
|
||||||
name: 'Generic Norm',
|
|
||||||
value: "generic"
|
|
||||||
}],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: multiEdgeNorms,
|
|
||||||
expected: [
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'phase-2',
|
|
||||||
phaseData: {
|
|
||||||
norms: [{
|
|
||||||
id: 'norm-3',
|
|
||||||
name: 'Generic Norm',
|
|
||||||
value: "generic"
|
|
||||||
}],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'phase-3',
|
|
||||||
phaseData: {
|
|
||||||
norms: [
|
|
||||||
{
|
|
||||||
id: 'norm-1',
|
|
||||||
name: 'Generic Norm',
|
|
||||||
value: "generic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2',
|
|
||||||
name: 'Generic Norm',
|
|
||||||
value: "generic"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-3',
|
|
||||||
name: 'Generic Phase',
|
|
||||||
nextPhaseId: 'end',
|
|
||||||
phaseData: {
|
|
||||||
norms: [{
|
|
||||||
id: 'norm-1',
|
|
||||||
name: 'Generic Norm',
|
|
||||||
value: "generic"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'norm-2',
|
|
||||||
name: 'Generic Norm',
|
|
||||||
value: "generic"
|
|
||||||
}],
|
|
||||||
goals: []
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: onlyStartEnd,
|
|
||||||
expected: [],
|
|
||||||
}
|
|
||||||
])("`tests state: $state.name`", ({state, expected}) => {
|
|
||||||
useFlowStore.setState({nodes: state.nodes, edges: state.edges});
|
|
||||||
const output = graphReducer(); // uses default reducers
|
|
||||||
expect(output).toEqual(expected);
|
|
||||||
})
|
|
||||||
// we run the test for correct error handling for the entire graph reducer as well,
|
|
||||||
// to make sure no errors occur before we intend to handle the errors ourselves
|
|
||||||
test.each([
|
|
||||||
{
|
|
||||||
state: phaseConnectsToInvalidNodeType,
|
|
||||||
expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: phaseHasNoOutgoingConnections,
|
|
||||||
expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
state: phaseHasTooManyOutgoingConnections,
|
|
||||||
expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets')
|
|
||||||
}
|
|
||||||
])(`tests erroneous state: $state.name`, ({state, expected}) => {
|
|
||||||
useFlowStore.setState({nodes: state.nodes, edges: state.edges});
|
|
||||||
const testForError = () => {
|
|
||||||
graphReducer();
|
|
||||||
};
|
|
||||||
expect(testForError).toThrow(expected);
|
|
||||||
})
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {renderHook} from "@testing-library/react";
|
||||||
|
import type {Connection} from "@xyflow/react";
|
||||||
|
import {
|
||||||
|
ruleResult,
|
||||||
|
type RuleResult,
|
||||||
|
useHandleRules
|
||||||
|
} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
|
||||||
|
import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
||||||
|
|
||||||
|
describe('useHandleRules', () => {
|
||||||
|
it('should register rules on mount and validate connection', () => {
|
||||||
|
const rules = [() => ({ isSatisfied: true } as RuleResult)];
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useHandleRules('node1', 'h1', 'target', rules));
|
||||||
|
|
||||||
|
// Confirm rules registered
|
||||||
|
const storedRules = useFlowStore.getState().getTargetRules('node1', 'h1');
|
||||||
|
expect(storedRules).toEqual(rules);
|
||||||
|
|
||||||
|
// Validate a connection
|
||||||
|
const connection = { source: 'node2', sourceHandle: 'h2', target: 'node1', targetHandle: 'h1' };
|
||||||
|
const validation = result.current(connection);
|
||||||
|
expect(validation).toEqual(ruleResult.satisfied);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if targetHandle missing', () => {
|
||||||
|
const rules: any[] = [];
|
||||||
|
const { result } = renderHook(() => useHandleRules('node1', 'h1', 'target', rules));
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
result.current({ source: 'a', target: 'b', targetHandle: null, sourceHandle: null })
|
||||||
|
).toThrow('No target handle was provided');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useHandleRules with multiple failed rules', () => {
|
||||||
|
it('should return the first failed rule message and consider connectionCount', () => {
|
||||||
|
// Mock rules for the target handle
|
||||||
|
const failingRules = [
|
||||||
|
(_conn: any, ctx: any) => {
|
||||||
|
if (ctx.connectionCount >= 1) {
|
||||||
|
return { isSatisfied: false, message: 'Max connections reached' } as RuleResult;
|
||||||
|
}
|
||||||
|
return { isSatisfied: true } as RuleResult;
|
||||||
|
},
|
||||||
|
() => ({ isSatisfied: false, message: 'Other rule failed' } as RuleResult),
|
||||||
|
() => ({ isSatisfied: true } as RuleResult),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Register rules for the target handle
|
||||||
|
useFlowStore.getState().registerRules('targetNode', 'targetHandle', failingRules);
|
||||||
|
|
||||||
|
// Add one existing edge to simulate connectionCount
|
||||||
|
useFlowStore.setState({
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: 'edge-1',
|
||||||
|
source: 'sourceNode',
|
||||||
|
sourceHandle: 'sourceHandle',
|
||||||
|
target: 'targetNode',
|
||||||
|
targetHandle: 'targetHandle',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create hook for a source node handle
|
||||||
|
const rulesForSource = [
|
||||||
|
(_c: Connection) => ({ isSatisfied: true } as RuleResult)
|
||||||
|
];
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useHandleRules('sourceNode', 'sourceHandle', 'source', rulesForSource)
|
||||||
|
);
|
||||||
|
|
||||||
|
const connection = {
|
||||||
|
source: 'sourceNode',
|
||||||
|
sourceHandle: 'sourceHandle',
|
||||||
|
target: 'targetNode',
|
||||||
|
targetHandle: 'targetHandle',
|
||||||
|
};
|
||||||
|
|
||||||
|
const validation = result.current(connection);
|
||||||
|
|
||||||
|
// Should fail with first failing rule message
|
||||||
|
expect(validation).toEqual(ruleResult.notSatisfied('Max connections reached'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {ruleResult} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
|
||||||
|
import {
|
||||||
|
allowOnlyConnectionsFromType,
|
||||||
|
allowOnlyConnectionsFromHandle, noSelfConnections
|
||||||
|
} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts";
|
||||||
|
import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [
|
||||||
|
{ id: 'nodeA', type: 'typeA', position: { x: 0, y: 0 }, data: {} },
|
||||||
|
{ id: 'nodeB', type: 'typeB', position: { x: 0, y: 0 }, data: {} },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('allowOnlyConnectionsFromType', () => {
|
||||||
|
it('should allow connection from allowed node type', () => {
|
||||||
|
const rule = allowOnlyConnectionsFromType(['typeA']);
|
||||||
|
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeB', targetHandle: 'h2' };
|
||||||
|
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
|
||||||
|
|
||||||
|
const result = rule(connection, context);
|
||||||
|
expect(result).toEqual(ruleResult.satisfied);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow connection from disallowed node type', () => {
|
||||||
|
const rule = allowOnlyConnectionsFromType(['typeA']);
|
||||||
|
const connection = { source: 'nodeB', sourceHandle: 'h1', target: 'nodeA', targetHandle: 'h2' };
|
||||||
|
const context = { connectionCount: 0, source: { id: 'nodeB', handleId: 'h1' }, target: { id: 'nodeA', handleId: 'h2' } };
|
||||||
|
|
||||||
|
const result = rule(connection, context);
|
||||||
|
expect(result).toEqual(ruleResult.notSatisfied("the target doesn't allow connections from nodes with type: typeB"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('allowOnlyConnectionsFromHandle', () => {
|
||||||
|
it('should allow connection from node with correct type and handle', () => {
|
||||||
|
const rule = allowOnlyConnectionsFromHandle([{ nodeType: 'typeA', handleId: 'h1' }]);
|
||||||
|
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeB', targetHandle: 'h2' };
|
||||||
|
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
|
||||||
|
|
||||||
|
const result = rule(connection, context);
|
||||||
|
expect(result).toEqual(ruleResult.satisfied);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow connection from node with wrong handle', () => {
|
||||||
|
const rule = allowOnlyConnectionsFromHandle([{ nodeType: 'typeA', handleId: 'h1' }]);
|
||||||
|
const connection = { source: 'nodeA', sourceHandle: 'wrongHandle', target: 'nodeB', targetHandle: 'h2' };
|
||||||
|
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'wrongHandle' }, target: { id: 'nodeB', handleId: 'h2' } };
|
||||||
|
|
||||||
|
const result = rule(connection, context);
|
||||||
|
expect(result).toEqual(ruleResult.notSatisfied("the target doesn't allow connections from nodes with type: typeA"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow connection from node with wrong type', () => {
|
||||||
|
const rule = allowOnlyConnectionsFromHandle([{ nodeType: 'typeA', handleId: 'h1' }]);
|
||||||
|
const connection = { source: 'nodeB', sourceHandle: 'h1', target: 'nodeA', targetHandle: 'h2' };
|
||||||
|
const context = { connectionCount: 0, source: { id: 'nodeB', handleId: 'h1' }, target: { id: 'nodeA', handleId: 'h2' } };
|
||||||
|
|
||||||
|
const result = rule(connection, context);
|
||||||
|
expect(result).toEqual(ruleResult.notSatisfied("the target doesn't allow connections from nodes with type: typeB"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('noSelfConnections', () => {
|
||||||
|
it('should allow connection from node with other type and handle', () => {
|
||||||
|
const rule = noSelfConnections;
|
||||||
|
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeB', targetHandle: 'h2' };
|
||||||
|
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
|
||||||
|
|
||||||
|
const result = rule(connection, context);
|
||||||
|
expect(result).toEqual(ruleResult.satisfied);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow connection from other handle on same node', () => {
|
||||||
|
const rule = noSelfConnections;
|
||||||
|
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeA', targetHandle: 'h2' };
|
||||||
|
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
|
||||||
|
|
||||||
|
const result = rule(connection, context);
|
||||||
|
expect(result).toEqual(ruleResult.notSatisfied("nodes are not allowed to connect to themselves"));
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
import {act} from '@testing-library/react';
|
import {act} from '@testing-library/react';
|
||||||
|
import {
|
||||||
|
type Connection,
|
||||||
|
type Edge,
|
||||||
|
type Node,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import type {HandleRule, RuleResult} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
|
||||||
|
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 useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
|
||||||
import { mockReactFlow } from '../../../setupFlowTests.ts';
|
import { mockReactFlow } from '../../../setupFlowTests.ts';
|
||||||
|
|
||||||
@@ -6,18 +14,187 @@ beforeAll(() => {
|
|||||||
mockReactFlow();
|
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('FlowStore Functionality', () => {
|
||||||
describe('Node changes', () => {
|
describe('Node changes', () => {
|
||||||
// currently just using a single function from the ReactFlow library,
|
// currently just using a single function from the ReactFlow library,
|
||||||
// so testing would mean we are testing already tested behavior.
|
// so testing would mean we are testing already tested behavior.
|
||||||
// if implementation gets modified tests should be added for custom 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', () => {
|
describe('Edge changes', () => {
|
||||||
// currently just using a single function from the ReactFlow library,
|
// currently just using a single function from the ReactFlow library,
|
||||||
// so testing would mean we are testing already tested behavior.
|
// so testing would mean we are testing already tested behavior.
|
||||||
// if implementation gets modified tests should be added for custom behavior
|
// if implementation gets modified tests should be added for custom behavior
|
||||||
})
|
})
|
||||||
describe('ReactFlow onConnect', () => {
|
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', () => {
|
test('adds an edge when onConnect is triggered', () => {
|
||||||
const {onConnect} = useFlowStore.getState();
|
const {onConnect} = useFlowStore.getState();
|
||||||
|
|
||||||
@@ -39,6 +216,53 @@ describe('FlowStore Functionality', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('ReactFlow onReconnect', () => {
|
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', () => {
|
test('reconnects an existing edge when onReconnect is triggered', () => {
|
||||||
const {onReconnect} = useFlowStore.getState();
|
const {onReconnect} = useFlowStore.getState();
|
||||||
const oldEdge = {
|
const oldEdge = {
|
||||||
@@ -93,36 +317,63 @@ describe('FlowStore Functionality', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
test('successfully removes edge if no successful reconnect occurred', () => {
|
test('successfully removes edge if no successful reconnect occurred', () => {
|
||||||
const {onReconnectEnd} = useFlowStore.getState();
|
const {onReconnectEnd} = useFlowStore.getState();
|
||||||
useFlowStore.setState({edgeReconnectSuccessful: false});
|
useFlowStore.setState({
|
||||||
|
edgeReconnectSuccessful: false,
|
||||||
|
edges: testStateReconnectEnd.edges,
|
||||||
|
nodes: testStateReconnectEnd.nodes
|
||||||
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
onReconnectEnd(null, {id: 'xy-edge__A-B'});
|
onReconnectEnd(null, testEdge);
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedState = useFlowStore.getState();
|
const updatedState = useFlowStore.getState();
|
||||||
expect(updatedState.edgeReconnectSuccessful).toBe(true);
|
expect(updatedState.edgeReconnectSuccessful).toBe(true);
|
||||||
expect(updatedState.edges).toHaveLength(0);
|
expect(updatedState.edges).toHaveLength(0);
|
||||||
|
expect(updatedState.nodes[0].data.children).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not remove reconnecting edge if successful reconnect occurred', () => {
|
test('does not remove reconnecting edge if successful reconnect occurred', () => {
|
||||||
const {onReconnectEnd} = useFlowStore.getState();
|
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(() => {
|
act(() => {
|
||||||
onReconnectEnd(null, {id: 'xy-edge__A-B'});
|
onReconnectEnd(null, testEdge);
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedState = useFlowStore.getState();
|
const updatedState = useFlowStore.getState();
|
||||||
expect(updatedState.edgeReconnectSuccessful).toBe(true);
|
expect(updatedState.edgeReconnectSuccessful).toBe(true);
|
||||||
expect(updatedState.edges).toHaveLength(1);
|
expect(updatedState.edges).toHaveLength(1);
|
||||||
expect(updatedState.edges).toMatchObject([
|
expect(updatedState.edges).toMatchObject([testEdge]);
|
||||||
{
|
expect(updatedState.nodes[0].data.children).toEqual(["norm-1"]);
|
||||||
id: 'xy-edge__A-B',
|
|
||||||
source: 'A',
|
|
||||||
target: 'B'
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('ReactFlow deleteNode', () => {
|
describe('ReactFlow deleteNode', () => {
|
||||||
@@ -151,6 +402,7 @@ describe('FlowStore Functionality', () => {
|
|||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
act(()=> {
|
act(()=> {
|
||||||
deleteNode(nodeId);
|
deleteNode(nodeId);
|
||||||
});
|
});
|
||||||
@@ -221,4 +473,175 @@ describe('FlowStore Functionality', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('ReactFlow updateNodeData', () => {
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
state: {
|
||||||
|
name: 'updateName',
|
||||||
|
nodes: [{
|
||||||
|
id: 'phase-1',
|
||||||
|
type: 'phase',
|
||||||
|
position: {x: 0, y: 300},
|
||||||
|
data: {label: 'name', number: '2'}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
id: 'phase-1',
|
||||||
|
changedData: {label: 'new name'}
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
node: {
|
||||||
|
id: 'phase-1',
|
||||||
|
type: 'phase',
|
||||||
|
position: {x: 0, y: 300},
|
||||||
|
data: {label: 'new name', number: '2'}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
state: {
|
||||||
|
name: 'updateNumber',
|
||||||
|
nodes: [{
|
||||||
|
id: 'phase-1',
|
||||||
|
type: 'phase',
|
||||||
|
position: {x: 0, y: 300},
|
||||||
|
data: {label: 'name', number: '2'}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
id: 'phase-1',
|
||||||
|
changedData: {number: '3'}
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
node: {
|
||||||
|
id: 'phase-1',
|
||||||
|
type: 'phase',
|
||||||
|
position: {x: 0, y: 300},
|
||||||
|
data: {label: 'name', number: '3'}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
state: {
|
||||||
|
name: 'updateNameAndNumber',
|
||||||
|
nodes: [{
|
||||||
|
id: 'phase-1',
|
||||||
|
type: 'phase',
|
||||||
|
position: {x: 0, y: 300},
|
||||||
|
data: {label: 'name', number: '2'}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
id: 'phase-1',
|
||||||
|
changedData: {label: 'new name', number: '3'}
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
node: {
|
||||||
|
id: 'phase-1',
|
||||||
|
type: 'phase',
|
||||||
|
position: {x: 0, y: 300},
|
||||||
|
data: {label: 'new name', number: '3'}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
state: {
|
||||||
|
name: 'AddNewEntry',
|
||||||
|
nodes: [{
|
||||||
|
id: 'phase-1',
|
||||||
|
type: 'phase',
|
||||||
|
position: {x: 0, y: 300},
|
||||||
|
data: {label: 'name', number: '2'}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
id: 'phase-1',
|
||||||
|
changedData: {newEntry: 20}
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
node: {
|
||||||
|
id: 'phase-1',
|
||||||
|
type: 'phase',
|
||||||
|
position: {x: 0, y: 300},
|
||||||
|
data: {label: 'name', number: '2', newEntry: 20}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
state: {
|
||||||
|
name: 'AddNewEntryAndUpdateOneValue_UnorderedInput',
|
||||||
|
nodes: [{
|
||||||
|
id: 'phase-1',
|
||||||
|
type: 'phase',
|
||||||
|
position: {x: 0, y: 300},
|
||||||
|
data: {label: 'name', number: '2'}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
id: 'phase-1',
|
||||||
|
changedData: {newEntry: 20, number: '3'}
|
||||||
|
},
|
||||||
|
expected: {
|
||||||
|
node: {
|
||||||
|
id: 'phase-1',
|
||||||
|
type: 'phase',
|
||||||
|
position: {x: 0, y: 300},
|
||||||
|
data: {label: 'name', number: '3', newEntry: 20}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])(`tests state: $state.name`, ({state, input,expected}) => {
|
||||||
|
useFlowStore.setState({ nodes: state.nodes })
|
||||||
|
const {updateNodeData} = useFlowStore.getState();
|
||||||
|
act(() => {
|
||||||
|
updateNodeData(input.id, input.changedData);
|
||||||
|
})
|
||||||
|
const updatedState = useFlowStore.getState();
|
||||||
|
expect(updatedState.nodes).toHaveLength(1);
|
||||||
|
expect(updatedState.nodes[0]).toMatchObject(expected.node);
|
||||||
|
})
|
||||||
|
describe('Handle Rule Registry', () => {
|
||||||
|
it('should register and retrieve rules', () => {
|
||||||
|
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
|
||||||
|
|
||||||
|
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
|
||||||
|
const rules = useFlowStore.getState().getTargetRules('node1', 'handleA');
|
||||||
|
|
||||||
|
expect(rules).toEqual(mockRules);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn and return empty array if rules are missing', () => {
|
||||||
|
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const rules = useFlowStore.getState().getTargetRules('missingNode', 'missingHandle');
|
||||||
|
expect(rules).toEqual([]);
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No rules were registered'));
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unregister a specific handle rule', () => {
|
||||||
|
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
|
||||||
|
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
|
||||||
|
|
||||||
|
useFlowStore.getState().unregisterHandleRules('node1', 'handleA');
|
||||||
|
const rules = useFlowStore.getState().getTargetRules('node1', 'handleA');
|
||||||
|
|
||||||
|
expect(rules).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unregister all rules for a node', () => {
|
||||||
|
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
|
||||||
|
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
|
||||||
|
useFlowStore.getState().registerRules('node1', 'handleB', mockRules);
|
||||||
|
useFlowStore.getState().registerRules('node2', 'handleC', mockRules);
|
||||||
|
|
||||||
|
useFlowStore.getState().unregisterNodeRules('node1');
|
||||||
|
|
||||||
|
expect(useFlowStore.getState().getTargetRules('node1', 'handleA')).toEqual([]);
|
||||||
|
expect(useFlowStore.getState().getTargetRules('node1', 'handleB')).toEqual([]);
|
||||||
|
expect(useFlowStore.getState().getTargetRules('node2', 'handleC')).toEqual(mockRules);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,33 +1,110 @@
|
|||||||
import { mockReactFlow } from '../../../../setupFlowTests.ts';
|
import { getByTestId, render } from '@testing-library/react';
|
||||||
import {act} from "@testing-library/react";
|
import userEvent from '@testing-library/user-event';
|
||||||
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||||
import {addNode} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx";
|
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', () => {
|
describe("Drag & drop node creation", () => {
|
||||||
test.each(['phase', 'phase'])('new nodes get added correctly', (nodeType: string) => {
|
|
||||||
act(()=> {
|
test("drops a phase node inside the canvas and adds it with transformed position", async () => {
|
||||||
addNode(nodeType, {x:100, y:100});
|
const user = userEvent.setup();
|
||||||
})
|
|
||||||
const updatedState = useFlowStore.getState();
|
const { container } = render(<VisProgPage />);
|
||||||
expect(updatedState.nodes.length).toBe(1);
|
|
||||||
expect(updatedState.nodes[0].type).toBe(nodeType);
|
// --- 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");
|
||||||
|
|
||||||
|
// UUID Expression
|
||||||
|
expect(node.id).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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,152 @@
|
|||||||
|
import { describe, it, expect} from '@jest/globals';
|
||||||
|
import {
|
||||||
|
type EditorWarning, warningSummary
|
||||||
|
} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx";
|
||||||
|
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
function makeWarning(
|
||||||
|
overrides?: Partial<EditorWarning>
|
||||||
|
): EditorWarning {
|
||||||
|
return {
|
||||||
|
scope: { id: 'node-1' },
|
||||||
|
type: 'MISSING_INPUT',
|
||||||
|
severity: 'ERROR',
|
||||||
|
description: 'Missing input',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("editorWarnings", () => {
|
||||||
|
describe('registerWarning', () => {
|
||||||
|
it('registers a node-level warning', () => {
|
||||||
|
const warning = makeWarning();
|
||||||
|
const {registerWarning, getWarnings} = useFlowStore.getState()
|
||||||
|
registerWarning(warning);
|
||||||
|
|
||||||
|
const warnings = getWarnings();
|
||||||
|
expect(warnings).toHaveLength(1);
|
||||||
|
expect(warnings[0]).toEqual(warning);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers a handle-level warning with scoped key', () => {
|
||||||
|
const warning = makeWarning({
|
||||||
|
scope: { id: 'node-1', handleId: 'input-1' },
|
||||||
|
});
|
||||||
|
const {registerWarning} = useFlowStore.getState()
|
||||||
|
registerWarning(warning);
|
||||||
|
const nodeWarnings = useFlowStore.getState().editorWarningRegistry.get('node-1');
|
||||||
|
expect(nodeWarnings?.has('MISSING_INPUT:input-1') === true).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates severityIndex correctly', () => {
|
||||||
|
const {registerWarning, severityIndex} = useFlowStore.getState()
|
||||||
|
registerWarning(makeWarning());
|
||||||
|
expect(severityIndex.get('ERROR')!.size).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWarningsBySeverity', () => {
|
||||||
|
it('returns only warnings of requested severity', () => {
|
||||||
|
const {registerWarning, getWarningsBySeverity} = useFlowStore.getState()
|
||||||
|
registerWarning(
|
||||||
|
makeWarning({ severity: 'ERROR' })
|
||||||
|
);
|
||||||
|
|
||||||
|
registerWarning(
|
||||||
|
makeWarning({
|
||||||
|
severity: 'WARNING',
|
||||||
|
type: 'MISSING_OUTPUT',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const errors = getWarningsBySeverity('ERROR');
|
||||||
|
const warnings = getWarningsBySeverity('WARNING');
|
||||||
|
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(warnings).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isProgramValid', () => {
|
||||||
|
it('returns true when no ERROR warnings exist', () => {
|
||||||
|
expect(useFlowStore.getState().isProgramValid()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when ERROR warnings exist', () => {
|
||||||
|
const {registerWarning, isProgramValid} = useFlowStore.getState()
|
||||||
|
registerWarning(makeWarning());
|
||||||
|
expect(isProgramValid()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unregisterWarning', () => {
|
||||||
|
it('removes warning from registry and severityIndex', () => {
|
||||||
|
const warning = makeWarning();
|
||||||
|
const {
|
||||||
|
registerWarning,
|
||||||
|
getWarnings,
|
||||||
|
unregisterWarning,
|
||||||
|
severityIndex
|
||||||
|
} = useFlowStore.getState()
|
||||||
|
|
||||||
|
registerWarning(warning);
|
||||||
|
|
||||||
|
unregisterWarning('node-1', 'MISSING_INPUT');
|
||||||
|
|
||||||
|
expect(getWarnings()).toHaveLength(0);
|
||||||
|
expect(severityIndex.get('ERROR')!.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing if warning does not exist', () => {
|
||||||
|
expect(() =>
|
||||||
|
useFlowStore.getState().unregisterWarning('node-1', 'DOES_NOT_EXIST')
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unregisterWarningsForId', () => {
|
||||||
|
it('removes all warnings for a node', () => {
|
||||||
|
const {registerWarning, unregisterWarningsForId, getWarnings, severityIndex} = useFlowStore.getState()
|
||||||
|
registerWarning(
|
||||||
|
makeWarning({
|
||||||
|
scope: { id: 'node-1', handleId: 'h1' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
registerWarning(
|
||||||
|
makeWarning({
|
||||||
|
scope: { id: 'node-1' },
|
||||||
|
type: 'MISSING_OUTPUT',
|
||||||
|
severity: 'WARNING',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
unregisterWarningsForId('node-1');
|
||||||
|
|
||||||
|
expect(getWarnings()).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
severityIndex.get('ERROR')!.size
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
severityIndex.get('WARNING')!.size
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('warningSummary', () => {
|
||||||
|
it('returns correct counts and validity', () => {
|
||||||
|
const {registerWarning} = useFlowStore.getState()
|
||||||
|
registerWarning(
|
||||||
|
makeWarning({ severity: 'ERROR' })
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = warningSummary();
|
||||||
|
|
||||||
|
expect(summary.error).toBe(1);
|
||||||
|
expect(summary.warning).toBe(0);
|
||||||
|
expect(summary.info).toBe(0);
|
||||||
|
expect(summary.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { renderWithProviders, screen } from '../../../../test-utils/test-utils.tsx';
|
||||||
|
import GestureValueEditor from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor';
|
||||||
|
|
||||||
|
function TestHarness({ initialValue = '', initialType=true, placeholder = 'Gesture name' } : { initialValue?: string, initialType?: boolean, placeholder?: string }) {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
const [_, setType] = useState(initialType)
|
||||||
|
return (
|
||||||
|
<GestureValueEditor value={value} setValue={setValue} setType={setType} placeholder={placeholder} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GestureValueEditor', () => {
|
||||||
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
user = userEvent.setup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders in tag mode by default and allows selecting a tag via button and select', async () => {
|
||||||
|
renderWithProviders(<TestHarness />);
|
||||||
|
|
||||||
|
// Tag selector should be present
|
||||||
|
const select = screen.getByTestId('tagSelectorTestID') as HTMLSelectElement;
|
||||||
|
expect(select).toBeInTheDocument();
|
||||||
|
expect(select.value).toBe('');
|
||||||
|
|
||||||
|
// Choose a tag via select
|
||||||
|
await user.selectOptions(select, 'happy');
|
||||||
|
expect(select.value).toBe('happy');
|
||||||
|
|
||||||
|
// The corresponding tag button should reflect the selection (have the selected class)
|
||||||
|
const happyButton = screen.getByRole('button', { name: /happy/i });
|
||||||
|
expect(happyButton).toBeInTheDocument();
|
||||||
|
expect(happyButton.className).toMatch(/selected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switches to single mode and shows suggestions list', async () => {
|
||||||
|
renderWithProviders(<TestHarness initialValue={'happy'} />);
|
||||||
|
|
||||||
|
const singleButton = screen.getByRole('button', { name: /^single$/i });
|
||||||
|
await user.click(singleButton);
|
||||||
|
|
||||||
|
// Input should be present with placeholder
|
||||||
|
const input = screen.getByPlaceholderText('Gesture name') as HTMLInputElement;
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Because switching to single populates suggestions, we expect at least one suggestion item
|
||||||
|
const suggestion = await screen.findByText(/Listening_1/);
|
||||||
|
expect(suggestion).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('typing filters suggestions and selecting a suggestion commits the value and hides the list', async () => {
|
||||||
|
renderWithProviders(<TestHarness />);
|
||||||
|
|
||||||
|
// Switch to single mode
|
||||||
|
await user.click(screen.getByRole('button', { name: /^single$/i }));
|
||||||
|
const input = screen.getByPlaceholderText('Gesture name') as HTMLInputElement;
|
||||||
|
|
||||||
|
// Type a substring that matches some suggestions
|
||||||
|
await user.type(input, 'Listening_2');
|
||||||
|
|
||||||
|
// The suggestion should appear and include the text we typed
|
||||||
|
const matching = await screen.findByText(/Listening_2/);
|
||||||
|
expect(matching).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click the suggestion
|
||||||
|
await user.click(matching);
|
||||||
|
|
||||||
|
// After selecting, input should contain that suggestion and suggestions should be hidden
|
||||||
|
expect(input.value).toContain('Listening_2');
|
||||||
|
expect(screen.queryByText(/Listening_1/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('typing a non-matching string hides the suggestions list', async () => {
|
||||||
|
renderWithProviders(<TestHarness />);
|
||||||
|
await user.click(screen.getByRole('button', { name: /^single$/i }));
|
||||||
|
const input = screen.getByPlaceholderText('Gesture name') as HTMLInputElement;
|
||||||
|
|
||||||
|
await user.type(input, 'no-match-zzz');
|
||||||
|
|
||||||
|
// There should be no suggestion that includes that gibberish
|
||||||
|
expect(screen.queryByText(/no-match-zzz/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching back to tag mode clears value when it is not a valid tag and preserves it when it is', async () => {
|
||||||
|
renderWithProviders(<TestHarness />);
|
||||||
|
|
||||||
|
// Switch to single mode and pick a suggestion (which is not a semantic tag)
|
||||||
|
await user.click(screen.getByRole('button', { name: /^single$/i }));
|
||||||
|
const input = screen.getByPlaceholderText('Gesture name') as HTMLInputElement;
|
||||||
|
await user.type(input, 'Listening_3');
|
||||||
|
const suggestion = await screen.findByText(/Listening_3/);
|
||||||
|
await user.click(suggestion);
|
||||||
|
|
||||||
|
// Switch back to tag mode -> value should be cleared (not in tag list)
|
||||||
|
await user.click(screen.getByRole('button', { name: /^tag$/i }));
|
||||||
|
const select = screen.getByTestId('tagSelectorTestID') as HTMLSelectElement;
|
||||||
|
expect(select.value).toBe('');
|
||||||
|
|
||||||
|
// Now pick a valid tag and switch to single then back to tag
|
||||||
|
await user.selectOptions(select, 'happy');
|
||||||
|
expect(select.value).toBe('happy');
|
||||||
|
|
||||||
|
// Switch to single and then back to tag; since 'happy' is a valid tag, it should remain
|
||||||
|
await user.click(screen.getByRole('button', { name: /^single$/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /^tag$/i }));
|
||||||
|
expect(select.value).toBe('happy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('focus on input re-shows filtered suggestions when customValue is present', async () => {
|
||||||
|
renderWithProviders(<TestHarness />);
|
||||||
|
|
||||||
|
// Switch to single mode and type to filter
|
||||||
|
await user.click(screen.getByRole('button', { name: /^single$/i }));
|
||||||
|
const input = screen.getByPlaceholderText('Gesture name') as HTMLInputElement;
|
||||||
|
|
||||||
|
await user.type(input, 'Listening_4');
|
||||||
|
const found = await screen.findByText(/Listening_4/);
|
||||||
|
expect(found).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Blur the input
|
||||||
|
input.blur();
|
||||||
|
expect(found).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Focus the input again and ensure the suggestions remain or reappear
|
||||||
|
await user.click(input);
|
||||||
|
const foundAgain = await screen.findByText(/Listening_4/);
|
||||||
|
expect(foundAgain).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { fireEvent, screen } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import {Tooltip} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx";
|
||||||
|
import {renderWithSidebar} from "../../../../test-utils/test-utils.tsx";
|
||||||
|
|
||||||
|
describe('Tooltip component test', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders and shows tooltip content on hover', () => {
|
||||||
|
renderWithSidebar(
|
||||||
|
<Tooltip nodeType="phase">
|
||||||
|
<div>?</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigger = screen.getByText('?');
|
||||||
|
|
||||||
|
// initially hidden
|
||||||
|
expect(
|
||||||
|
screen.queryByText('Phase tooltip text')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// hover shows tooltip
|
||||||
|
fireEvent.mouseOver(trigger);
|
||||||
|
|
||||||
|
expect(screen.getByText('phase')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('A phase is a single stage of the program, during a phase Pepper will behave in accordance with any connected norms, goals and triggers')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// rendered via portal
|
||||||
|
expect(
|
||||||
|
document.body.contains(
|
||||||
|
screen.getByText('A phase is a single stage of the program, during a phase Pepper will behave in accordance with any connected norms, goals and triggers')
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
import { describe, it, beforeEach, jest } from '@jest/globals';
|
||||||
|
import { screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type { Node } from '@xyflow/react';
|
||||||
|
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
||||||
|
import PlanEditorDialog from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor';
|
||||||
|
import { PlanReduce, type Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { GoalReduce, type GoalNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx';
|
||||||
|
import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts';
|
||||||
|
import { insertGoalInPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditingFunctions.tsx';
|
||||||
|
|
||||||
|
|
||||||
|
// Mock structuredClone
|
||||||
|
(globalThis as any).structuredClone = jest.fn((val) => JSON.parse(JSON.stringify(val)));
|
||||||
|
|
||||||
|
// UUID Regex for checking ID's
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
describe('PlanEditorDialog', () => {
|
||||||
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
|
const mockOnSave = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
user = userEvent.setup();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultPlan: Plan = {
|
||||||
|
id: 'plan-1',
|
||||||
|
name: 'Test Plan',
|
||||||
|
steps: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const extendedPlan: Plan = {
|
||||||
|
id: 'extended-plan-1',
|
||||||
|
name: 'extended test plan',
|
||||||
|
steps: [
|
||||||
|
// Step 1: A wave tag gesture
|
||||||
|
{
|
||||||
|
id: 'firststep',
|
||||||
|
type: 'gesture',
|
||||||
|
isTag: true,
|
||||||
|
gesture: "hello"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 2: A single tag gesture
|
||||||
|
{
|
||||||
|
id: 'secondstep',
|
||||||
|
type: 'gesture',
|
||||||
|
isTag: false,
|
||||||
|
gesture: "somefolder/somegesture"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 3: A LLM action
|
||||||
|
{
|
||||||
|
id: 'thirdstep',
|
||||||
|
type: 'llm',
|
||||||
|
goal: 'ask the user something or whatever'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 4: A speech action
|
||||||
|
{
|
||||||
|
id: 'fourthstep',
|
||||||
|
type: 'speech',
|
||||||
|
text: "I'm a cyborg ninja :>"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const planWithSteps: Plan = {
|
||||||
|
id: 'plan-2',
|
||||||
|
name: 'Existing Plan',
|
||||||
|
steps: [
|
||||||
|
{ id: 'step-1', text: 'Hello world', type: 'speech' as const },
|
||||||
|
{ id: 'step-2', gesture: 'Wave', isTag:true, type: 'gesture' as const },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDialog = (props: Partial<React.ComponentProps<typeof PlanEditorDialog>> = {}) => {
|
||||||
|
const defaultProps = {
|
||||||
|
plan: undefined,
|
||||||
|
onSave: mockOnSave,
|
||||||
|
description: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderWithProviders(<PlanEditorDialog {...defaultProps} {...props} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should show "Create Plan" button when no plan is provided', () => {
|
||||||
|
renderDialog();
|
||||||
|
// The button should be visible
|
||||||
|
expect(screen.getByRole('button', { name: 'Create Plan' })).toBeInTheDocument();
|
||||||
|
// The dialog content should NOT be visible initially
|
||||||
|
expect(screen.queryByText(/Add Action/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show "Edit Plan" button when a plan is provided', () => {
|
||||||
|
renderDialog({ plan: defaultPlan });
|
||||||
|
expect(screen.getByRole('button', { name: 'Edit Plan' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show "Create Plan" button when a plan exists', () => {
|
||||||
|
renderDialog({ plan: defaultPlan });
|
||||||
|
// Query for the button text specifically, not dialog title
|
||||||
|
expect(screen.queryByRole('button', { name: 'Create Plan' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dialog Interactions', () => {
|
||||||
|
it('should open dialog with "Create Plan" title when creating new plan', async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||||
|
|
||||||
|
|
||||||
|
// One for button, one for dialog.
|
||||||
|
expect(screen.getAllByText('Create Plan').length).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open dialog with "Edit Plan" title when editing existing plan', async () => {
|
||||||
|
renderDialog({ plan: defaultPlan });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||||
|
|
||||||
|
// One for button, one for dialog
|
||||||
|
expect(screen.getAllByText('Edit Plan').length).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pre-fill plan name when editing', async () => {
|
||||||
|
renderDialog({ plan: defaultPlan });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||||
|
|
||||||
|
const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement;
|
||||||
|
expect(nameInput.value).toBe(defaultPlan.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close dialog when cancel button is clicked', async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||||
|
await user.click(screen.getByText('Cancel'));
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Plan Creation', () => {
|
||||||
|
it('should create a new plan with default values', async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||||
|
|
||||||
|
// One for the button, one for the dialog
|
||||||
|
expect(screen.getAllByText('Create Plan').length).toEqual(2);
|
||||||
|
|
||||||
|
const nameInput = screen.getByPlaceholderText('Plan name');
|
||||||
|
expect(nameInput).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-fill with description when provided', async () => {
|
||||||
|
const description = 'Achieve world peace';
|
||||||
|
renderDialog({ description });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||||
|
|
||||||
|
// Check if plan name is pre-filled with description
|
||||||
|
const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement;
|
||||||
|
expect(nameInput.value).toBe(description);
|
||||||
|
|
||||||
|
// Check if action type is set to LLM
|
||||||
|
const actionTypeSelect = screen.getByLabelText(/Action Type/i) as HTMLSelectElement;
|
||||||
|
expect(actionTypeSelect.value).toBe('llm');
|
||||||
|
|
||||||
|
// Check if suggestion text is shown
|
||||||
|
expect(screen.getByText('Filled in as a suggestion!')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Feel free to change!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow changing plan name', async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||||
|
|
||||||
|
const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement;
|
||||||
|
const newName = 'My Custom Plan';
|
||||||
|
|
||||||
|
// Instead of clear(), select all text and type new value
|
||||||
|
await user.click(nameInput);
|
||||||
|
await user.keyboard('{Control>}a{/Control}'); // Select all (Ctrl+A)
|
||||||
|
await user.keyboard(newName);
|
||||||
|
|
||||||
|
expect(nameInput.value).toBe(newName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Action Management', () => {
|
||||||
|
it('should add a speech action to the plan', async () => {
|
||||||
|
renderDialog({ plan: defaultPlan });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||||
|
|
||||||
|
const actionTypeSelect = screen.getByLabelText(/Action Type/i);
|
||||||
|
const actionValueInput = screen.getByPlaceholderText("Speech text")
|
||||||
|
const addButton = screen.getByText('Add Step');
|
||||||
|
|
||||||
|
// Set up a speech action
|
||||||
|
await user.selectOptions(actionTypeSelect, 'speech');
|
||||||
|
await user.type(actionValueInput, 'Hello there!');
|
||||||
|
|
||||||
|
await user.click(addButton);
|
||||||
|
|
||||||
|
// Check if step was added
|
||||||
|
expect(screen.getByText('speech:')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Hello there!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add a gesture action to the plan', async () => {
|
||||||
|
renderDialog({ plan: defaultPlan });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /edit plan/i }));
|
||||||
|
|
||||||
|
const actionTypeSelect = screen.getByLabelText(/Action Type/i);
|
||||||
|
const addButton = screen.getByText('Add Step');
|
||||||
|
|
||||||
|
// Set up a gesture action
|
||||||
|
await user.selectOptions(actionTypeSelect, 'gesture');
|
||||||
|
|
||||||
|
// Find the input field after type change
|
||||||
|
const select = screen.getByTestId("tagSelectorTestID")
|
||||||
|
const options = within(select).getAllByRole('option')
|
||||||
|
|
||||||
|
await user.selectOptions(select, options[1])
|
||||||
|
await user.click(addButton);
|
||||||
|
|
||||||
|
// Check if step was added
|
||||||
|
expect(screen.getByText('gesture:')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add an LLM action to the plan', async () => {
|
||||||
|
renderDialog({ plan: defaultPlan });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||||
|
|
||||||
|
const actionTypeSelect = screen.getByLabelText(/Action Type/i);
|
||||||
|
const addButton = screen.getByText('Add Step');
|
||||||
|
|
||||||
|
// Set up an LLM action
|
||||||
|
await user.selectOptions(actionTypeSelect, 'llm');
|
||||||
|
|
||||||
|
// Find the input field after type change
|
||||||
|
const llmInput = screen.getByPlaceholderText(/LLM goal|text/i);
|
||||||
|
await user.type(llmInput, 'Generate a story');
|
||||||
|
|
||||||
|
await user.click(addButton);
|
||||||
|
|
||||||
|
// Check if step was added
|
||||||
|
expect(screen.getByText('llm:')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Generate a story')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable "Add Step" button when action value is empty', async () => {
|
||||||
|
renderDialog({ plan: defaultPlan });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||||
|
|
||||||
|
const addButton = screen.getByText('Add Step');
|
||||||
|
expect(addButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset action form after adding a step', async () => {
|
||||||
|
renderDialog({ plan: defaultPlan });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||||
|
|
||||||
|
|
||||||
|
const actionValueInput = screen.getByPlaceholderText("Speech text")
|
||||||
|
const addButton = screen.getByText('Add Step');
|
||||||
|
|
||||||
|
await user.type(actionValueInput, 'Test speech');
|
||||||
|
await user.click(addButton);
|
||||||
|
|
||||||
|
// Action value should be cleared
|
||||||
|
expect(actionValueInput).toHaveValue('');
|
||||||
|
// Action type should be reset to speech (default)
|
||||||
|
const actionTypeSelect = screen.getByLabelText(/Action Type/i) as HTMLSelectElement;
|
||||||
|
expect(actionTypeSelect.value).toBe('speech');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Step Management', () => {
|
||||||
|
it('should show existing steps when editing a plan', async () => {
|
||||||
|
renderDialog({ plan: planWithSteps });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||||
|
|
||||||
|
// Check if existing steps are shown
|
||||||
|
expect(screen.getByText('speech:')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Hello world')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('gesture:')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Wave')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show "No steps yet" message when plan has no steps', async () => {
|
||||||
|
renderDialog({ plan: defaultPlan });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||||
|
|
||||||
|
expect(screen.getByText('No steps yet')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove a step when clicked', async () => {
|
||||||
|
renderDialog({ plan: planWithSteps });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||||
|
|
||||||
|
// Initially have 2 steps
|
||||||
|
expect(screen.getByText('speech:')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('gesture:')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click on the first step to remove it
|
||||||
|
await user.click(screen.getByText('Hello world'));
|
||||||
|
|
||||||
|
// First step should be removed
|
||||||
|
expect(screen.queryByText('Hello world')).not.toBeInTheDocument();
|
||||||
|
// Second step should still exist
|
||||||
|
expect(screen.getByText('Wave')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Save Functionality', () => {
|
||||||
|
it('should call onSave with new plan when creating', async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||||
|
|
||||||
|
// Set plan name
|
||||||
|
const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement;
|
||||||
|
await user.click(nameInput);
|
||||||
|
await user.keyboard('{Control>}a{/Control}');
|
||||||
|
await user.keyboard('My New Plan');
|
||||||
|
|
||||||
|
// Add a step
|
||||||
|
const actionValueInput = screen.getByPlaceholderText(/text/i);
|
||||||
|
await user.type(actionValueInput, 'First step');
|
||||||
|
await user.click(screen.getByText('Add Step'));
|
||||||
|
|
||||||
|
// Save the plan
|
||||||
|
await user.click(screen.getByText('Create'));
|
||||||
|
|
||||||
|
expect(mockOnSave).toHaveBeenCalledWith({
|
||||||
|
id: expect.stringMatching(uuidRegex),
|
||||||
|
name: 'My New Plan',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: expect.stringMatching(uuidRegex),
|
||||||
|
text: 'First step',
|
||||||
|
type: 'speech',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onSave with updated plan when editing', async () => {
|
||||||
|
renderDialog({ plan: defaultPlan });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||||
|
|
||||||
|
// Change plan name
|
||||||
|
const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement;
|
||||||
|
await user.click(nameInput);
|
||||||
|
await user.keyboard('{Control>}a{/Control}');
|
||||||
|
await user.keyboard('Updated Plan Name');
|
||||||
|
|
||||||
|
// Add a step
|
||||||
|
const actionValueInput = screen.getByPlaceholderText(/text/i);
|
||||||
|
await user.type(actionValueInput, 'New speech action');
|
||||||
|
await user.click(screen.getByText('Add Step'));
|
||||||
|
|
||||||
|
// Save the plan
|
||||||
|
await user.click(screen.getByText('Confirm'));
|
||||||
|
|
||||||
|
expect(mockOnSave).toHaveBeenCalledWith({
|
||||||
|
id: defaultPlan.id,
|
||||||
|
name: 'Updated Plan Name',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: expect.stringMatching(uuidRegex),
|
||||||
|
text: 'New speech action',
|
||||||
|
type: 'speech',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onSave with undefined when reset button is clicked', async () => {
|
||||||
|
renderDialog({ plan: defaultPlan });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||||
|
await user.click(screen.getByText('Reset'));
|
||||||
|
|
||||||
|
expect(mockOnSave).toHaveBeenCalledWith(undefined);
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable save button when no draft plan exists', async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||||
|
|
||||||
|
// The save button should be enabled since draftPlan exists after clicking Create Plan
|
||||||
|
const saveButton = screen.getByText('Create');
|
||||||
|
expect(saveButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Step Indexing', () => {
|
||||||
|
it('should show correct step numbers', async () => {
|
||||||
|
renderDialog({ plan: defaultPlan });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||||
|
|
||||||
|
// Add multiple steps
|
||||||
|
const actionValueInput = screen.getByPlaceholderText(/text/i);
|
||||||
|
const addButton = screen.getByText('Add Step');
|
||||||
|
|
||||||
|
await user.type(actionValueInput, 'First');
|
||||||
|
await user.click(addButton);
|
||||||
|
|
||||||
|
await user.type(actionValueInput, 'Second');
|
||||||
|
await user.click(addButton);
|
||||||
|
|
||||||
|
await user.type(actionValueInput, 'Third');
|
||||||
|
await user.click(addButton);
|
||||||
|
|
||||||
|
// Check step numbers
|
||||||
|
expect(screen.getByText('1.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('3.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Action Type Switching', () => {
|
||||||
|
it('should update placeholder text when action type changes', async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||||
|
|
||||||
|
const actionTypeSelect = screen.getByLabelText(/Action Type/i);
|
||||||
|
|
||||||
|
// Check speech placeholder
|
||||||
|
await user.selectOptions(actionTypeSelect, 'speech');
|
||||||
|
// The placeholder might be set dynamically, so we need to check the input
|
||||||
|
const speechInput = screen.getByPlaceholderText(/text/i);
|
||||||
|
expect(speechInput).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check gesture placeholder
|
||||||
|
await user.selectOptions(actionTypeSelect, 'gesture');
|
||||||
|
const gestureInput = screen.getByTestId("valueEditorTestID")
|
||||||
|
expect(gestureInput).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check LLM placeholder
|
||||||
|
await user.selectOptions(actionTypeSelect, 'llm');
|
||||||
|
const llmInput = screen.getByPlaceholderText(/LLM|text/i);
|
||||||
|
expect(llmInput).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Plan reducing', () => {
|
||||||
|
it('should correctly reduce the plan given the elements of the plan', () => {
|
||||||
|
// Create a plan for testing
|
||||||
|
const testplan = extendedPlan
|
||||||
|
const mockGoalNode: Node<GoalNodeData> = {
|
||||||
|
id: 'goal-1',
|
||||||
|
type: 'goal',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'mock goal', plan: defaultPlan },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insert the goal and retrieve its expected data
|
||||||
|
const newTestPlan = insertGoalInPlan(testplan, mockGoalNode)
|
||||||
|
const goalReduced = GoalReduce(mockGoalNode, [mockGoalNode])
|
||||||
|
const expectedResult = {
|
||||||
|
id: "extended-plan-1",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: "firststep",
|
||||||
|
gesture: {
|
||||||
|
type: "tag",
|
||||||
|
name: "hello"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "secondstep",
|
||||||
|
gesture: {
|
||||||
|
type: "single",
|
||||||
|
name: "somefolder/somegesture"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "thirdstep",
|
||||||
|
goal: "ask the user something or whatever"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fourthstep",
|
||||||
|
text: "I'm a cyborg ninja :>"
|
||||||
|
},
|
||||||
|
goalReduced,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check to see it the goal got added, and its reduced data was added to the goals'
|
||||||
|
const actualResult = PlanReduce([mockGoalNode], newTestPlan)
|
||||||
|
expect(actualResult).toEqual(expectedResult)
|
||||||
|
});
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user