Compare commits
1 Commits
feat/save-
...
fix/text-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3aec5928c |
@@ -1,77 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# This script checks if the current branch name follows the specified format.
|
|
||||||
# It's designed to be used as a 'pre-commit' git hook.
|
|
||||||
|
|
||||||
# Format: <type>/<short-description>
|
|
||||||
# Example: feat/add-user-login
|
|
||||||
|
|
||||||
# --- Configuration ---
|
|
||||||
# An array of allowed commit types
|
|
||||||
ALLOWED_TYPES=(feat fix refactor perf style test docs build chore revert)
|
|
||||||
# An array of branches to ignore
|
|
||||||
IGNORED_BRANCHES=(main dev demo)
|
|
||||||
|
|
||||||
# --- Colors for Output ---
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# --- Helper Functions ---
|
|
||||||
error_exit() {
|
|
||||||
echo -e "${RED}ERROR: $1${NC}" >&2
|
|
||||||
echo -e "${YELLOW}Branch name format is incorrect. Aborting commit.${NC}" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Main Logic ---
|
|
||||||
|
|
||||||
# 1. Get the current branch name
|
|
||||||
BRANCH_NAME=$(git symbolic-ref --short HEAD)
|
|
||||||
|
|
||||||
# 2. Check if the current branch is in the ignored list
|
|
||||||
for ignored_branch in "${IGNORED_BRANCHES[@]}"; do
|
|
||||||
if [ "$BRANCH_NAME" == "$ignored_branch" ]; then
|
|
||||||
echo -e "${GREEN}Branch check skipped for default branch: $BRANCH_NAME${NC}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# 3. Validate the overall structure: <type>/<description>
|
|
||||||
if ! [[ "$BRANCH_NAME" =~ ^[a-z]+/.+$ ]]; then
|
|
||||||
error_exit "Branch name must be in the format: <type>/<short-description>\nExample: feat/add-user-login"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 4. Extract the type and description
|
|
||||||
TYPE=$(echo "$BRANCH_NAME" | cut -d'/' -f1)
|
|
||||||
DESCRIPTION=$(echo "$BRANCH_NAME" | cut -d'/' -f2-)
|
|
||||||
|
|
||||||
# 5. Validate the <type>
|
|
||||||
type_valid=false
|
|
||||||
for allowed_type in "${ALLOWED_TYPES[@]}"; do
|
|
||||||
if [ "$TYPE" == "$allowed_type" ]; then
|
|
||||||
type_valid=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$type_valid" == false ]; then
|
|
||||||
error_exit "Invalid type '$TYPE'.\nAllowed types are: ${ALLOWED_TYPES[*]}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 6. Validate the <short-description>
|
|
||||||
# Regex breakdown:
|
|
||||||
# ^[a-z0-9]+ - Starts with one or more lowercase letters/numbers (the first word).
|
|
||||||
# (-[a-z0-9]+){0,5} - Followed by a group of (dash + word) 0 to 5 times.
|
|
||||||
# $ - End of the string.
|
|
||||||
# This entire pattern enforces 1 to 6 words total, separated by dashes.
|
|
||||||
DESCRIPTION_REGEX="^[a-z0-9]+(-[a-z0-9]+){0,5}$"
|
|
||||||
|
|
||||||
if ! [[ "$DESCRIPTION" =~ $DESCRIPTION_REGEX ]]; then
|
|
||||||
error_exit "Invalid short description '$DESCRIPTION'.\nIt must be a maximum of 6 words, all lowercase, separated by dashes.\nExample: add-new-user-authentication-feature"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If all checks pass, exit successfully
|
|
||||||
echo -e "${GREEN}Branch name '$BRANCH_NAME' is valid.${NC}"
|
|
||||||
exit 0
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# This script checks if a commit message follows the specified format.
|
|
||||||
# It's designed to be used as a 'commit-msg' git hook.
|
|
||||||
|
|
||||||
# Format:
|
|
||||||
# <type>: <short description>
|
|
||||||
#
|
|
||||||
# [optional]<body>
|
|
||||||
#
|
|
||||||
# [ref/close]: <issue identifier>
|
|
||||||
|
|
||||||
# --- Configuration ---
|
|
||||||
# An array of allowed commit types
|
|
||||||
ALLOWED_TYPES=(feat fix refactor perf style test docs build chore revert)
|
|
||||||
|
|
||||||
# --- Colors for Output ---
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# The first argument to the hook is the path to the file containing the commit message
|
|
||||||
COMMIT_MSG_FILE=$1
|
|
||||||
|
|
||||||
# --- Automated Commit Detection ---
|
|
||||||
|
|
||||||
# Read the first line (header) for initial checks
|
|
||||||
HEADER=$(head -n 1 "$COMMIT_MSG_FILE")
|
|
||||||
|
|
||||||
echo 'Given commit message:'
|
|
||||||
echo $HEADER
|
|
||||||
|
|
||||||
# Check for Merge commits (covers 'git merge' and PR merges from GitHub/GitLab)
|
|
||||||
# Examples: "Merge branch 'main' into ...", "Merge pull request #123 from ..."
|
|
||||||
MERGE_PATTERN="^Merge (remote-tracking )?(branch|pull request|tag) .*"
|
|
||||||
if [[ "$HEADER" =~ $MERGE_PATTERN ]]; then
|
|
||||||
echo -e "${GREEN}Merge commit detected by message content. Skipping validation.${NC}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for Revert commits
|
|
||||||
# Example: "Revert "feat: add new feature""
|
|
||||||
REVERT_PATTERN="^Revert \".*\""
|
|
||||||
if [[ "$HEADER" =~ $REVERT_PATTERN ]]; then
|
|
||||||
echo -e "${GREEN}Revert commit detected by message content. Skipping validation.${NC}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for Cherry-pick commits (this pattern appears at the end of the message)
|
|
||||||
# Example: "(cherry picked from commit deadbeef...)"
|
|
||||||
# We use grep -q to search the whole file quietly.
|
|
||||||
CHERRY_PICK_PATTERN="\(cherry picked from commit [a-f0-9]{7,40}\)"
|
|
||||||
if grep -qE "$CHERRY_PICK_PATTERN" "$COMMIT_MSG_FILE"; then
|
|
||||||
echo -e "${GREEN}Cherry-pick detected by message content. Skipping validation.${NC}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for Squash
|
|
||||||
# Example: "Squash commits ..."
|
|
||||||
SQUASH_PATTERN="^Squash .+"
|
|
||||||
if [[ "$HEADER" =~ $SQUASH_PATTERN ]]; then
|
|
||||||
echo -e "${GREEN}Squash commit detected by message content. Skipping validation.${NC}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Validation Functions ---
|
|
||||||
|
|
||||||
# Function to print an error message and exit
|
|
||||||
# Usage: error_exit "Your error message here"
|
|
||||||
error_exit() {
|
|
||||||
# >&2 redirects echo to stderr
|
|
||||||
echo -e "${RED}ERROR: $1${NC}" >&2
|
|
||||||
echo -e "${YELLOW}Commit message format is incorrect. Aborting commit.${NC}" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Main Logic ---
|
|
||||||
|
|
||||||
# 1. Read the header (first line) of the commit message
|
|
||||||
HEADER=$(head -n 1 "$COMMIT_MSG_FILE")
|
|
||||||
|
|
||||||
# 2. Validate the header format: <type>: <description>
|
|
||||||
# Regex breakdown:
|
|
||||||
# ^(type1|type2|...) - Starts with one of the allowed types
|
|
||||||
# : - Followed by a literal colon
|
|
||||||
# \s - Followed by a single space
|
|
||||||
# .+ - Followed by one or more characters for the description
|
|
||||||
# $ - End of the line
|
|
||||||
TYPES_REGEX=$(
|
|
||||||
IFS="|"
|
|
||||||
echo "${ALLOWED_TYPES[*]}"
|
|
||||||
)
|
|
||||||
HEADER_REGEX="^($TYPES_REGEX): .+$"
|
|
||||||
|
|
||||||
if ! [[ "$HEADER" =~ $HEADER_REGEX ]]; then
|
|
||||||
error_exit "Invalid header format.\n\nHeader must be in the format: <type>: <short description>\nAllowed types: ${ALLOWED_TYPES[*]}\nExample: feat: add new user authentication feature"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Only validate footer if commit type is not chore
|
|
||||||
TYPE=$(echo "$HEADER" | cut -d':' -f1)
|
|
||||||
if [ "$TYPE" != "chore" ]; then
|
|
||||||
# 3. Validate the footer (last line) of the commit message
|
|
||||||
FOOTER=$(tail -n 1 "$COMMIT_MSG_FILE")
|
|
||||||
|
|
||||||
# Regex breakdown:
|
|
||||||
# ^(ref|close) - Starts with 'ref' or 'close'
|
|
||||||
# : - Followed by a literal colon
|
|
||||||
# \s - Followed by a single space
|
|
||||||
# N25B- - Followed by the literal string 'N25B-'
|
|
||||||
# [0-9]+ - Followed by one or more digits
|
|
||||||
# $ - End of the line
|
|
||||||
FOOTER_REGEX="^(ref|close): N25B-[0-9]+$"
|
|
||||||
|
|
||||||
if ! [[ "$FOOTER" =~ $FOOTER_REGEX ]]; then
|
|
||||||
error_exit "Invalid footer format.\n\nFooter must be in the format: [ref/close]: <issue identifier>\nExample: ref: N25B-123"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 4. If the message has more than 2 lines, validate the separator
|
|
||||||
# A blank line must exist between the header and the body.
|
|
||||||
LINE_COUNT=$(wc -l <"$COMMIT_MSG_FILE" | xargs) # xargs trims whitespace
|
|
||||||
|
|
||||||
# We only care if there is a body. Header + Footer = 2 lines.
|
|
||||||
# Header + Blank Line + Body... + Footer > 2 lines.
|
|
||||||
if [ "$LINE_COUNT" -gt 2 ]; then
|
|
||||||
# Get the second line
|
|
||||||
SECOND_LINE=$(sed -n '2p' "$COMMIT_MSG_FILE")
|
|
||||||
|
|
||||||
# Check if the second line is NOT empty. If it's not, it's an error.
|
|
||||||
if [ -n "$SECOND_LINE" ]; then
|
|
||||||
error_exit "Missing blank line between header and body.\n\nThe second line of your commit message must be empty if a body is present."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If all checks pass, exit with success
|
|
||||||
echo -e "${GREEN}Commit message is valid.${NC}"
|
|
||||||
exit 0
|
|
||||||
16
.githooks/commit-msg
Executable file
16
.githooks/commit-msg
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
commit_msg_file=$1
|
||||||
|
commit_msg=$(cat "$commit_msg_file")
|
||||||
|
|
||||||
|
if echo "$commit_msg" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert): .+"; then
|
||||||
|
if echo "$commit_msg" | grep -Eq "^(ref|close):\sN25B-.+"; then
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "❌ Commit message invalid! Must end with [ref/close]: N25B-000"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "❌ Commit message invalid! Must start with <type>: <description>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
17
.githooks/pre-commit
Executable file
17
.githooks/pre-commit
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Get current branch
|
||||||
|
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
|
||||||
|
if echo "$branch" | grep -Eq "(dev|main)"; then
|
||||||
|
echo 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# allowed pattern <type/>
|
||||||
|
if echo "$branch" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert)\/\w+(-\w+){0,5}$"; then
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "❌ Invalid branch name: $branch"
|
||||||
|
echo "Branch must be named <type>/<description-of-branch> (must have one to six words separated by a dash)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
9
.githooks/prepare-commit-msg
Executable file
9
.githooks/prepare-commit-msg
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "#<type>: <description>
|
||||||
|
|
||||||
|
#[optional body]
|
||||||
|
|
||||||
|
#[optional footer(s)]
|
||||||
|
|
||||||
|
#[ref/close]: <issue identifier>" > $1
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -24,7 +24,4 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
# Coverage report
|
# Coverage report
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
# Documentation pages (can be generated)
|
|
||||||
docs
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# ---------- GLOBAL SETUP ---------- #
|
|
||||||
workflow:
|
|
||||||
rules:
|
|
||||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
||||||
|
|
||||||
stages:
|
|
||||||
- install
|
|
||||||
- lint
|
|
||||||
- test
|
|
||||||
|
|
||||||
variables:
|
|
||||||
NODE_VERSION: "24.11.1"
|
|
||||||
BASE_LAYER: trixie-slim
|
|
||||||
|
|
||||||
default:
|
|
||||||
image: docker.io/library/node:${NODE_VERSION}-${BASE_LAYER}
|
|
||||||
cache:
|
|
||||||
key: "${CI_COMMIT_REF_SLUG}"
|
|
||||||
paths:
|
|
||||||
- node_modules/
|
|
||||||
policy: pull-push
|
|
||||||
|
|
||||||
# --------- INSTALLING --------- #
|
|
||||||
install:
|
|
||||||
stage: install
|
|
||||||
tags:
|
|
||||||
- install
|
|
||||||
script:
|
|
||||||
- npm ci
|
|
||||||
artifacts:
|
|
||||||
paths:
|
|
||||||
- node_modules/
|
|
||||||
expire_in: 1h
|
|
||||||
|
|
||||||
# ---------- LINTING ---------- #
|
|
||||||
lint:
|
|
||||||
stage: lint
|
|
||||||
needs:
|
|
||||||
- install
|
|
||||||
tags:
|
|
||||||
- lint
|
|
||||||
script:
|
|
||||||
- npm run lint
|
|
||||||
|
|
||||||
# ---------- TESTING ---------- #
|
|
||||||
test:
|
|
||||||
stage: test
|
|
||||||
needs:
|
|
||||||
- install
|
|
||||||
tags:
|
|
||||||
- test
|
|
||||||
script:
|
|
||||||
- npm run test
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
sh .githooks/check-commit-msg.sh $1
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
sh .githooks/check-branch-name.sh
|
|
||||||
|
|
||||||
npm run lint
|
|
||||||
28
README.md
28
README.md
@@ -28,26 +28,16 @@ npm run dev
|
|||||||
|
|
||||||
It should automatically reload when you save changes.
|
It should automatically reload when you save changes.
|
||||||
|
|
||||||
## Git Hooks
|
## GitHooks
|
||||||
|
|
||||||
To activate automatic linting, branch name checks and commit message checks, run:
|
To activate automatic commits/branch name checks run:
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run prepare
|
|
||||||
```
|
|
||||||
|
|
||||||
You might get an error along the lines of `Can't install pre-commit with core.hooksPath` set. To fix this, simply unset the hooksPath by running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git config --local --unset core.hooksPath
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run the pre-commit install commands again.
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Generate documentation webpages with the command:
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
npx typedoc --entryPointStrategy Expand src
|
git config --local core.hooksPath .githooks
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If your commit fails its either:
|
||||||
|
branch name != <type>/description-of-branch ,
|
||||||
|
commit name != <type>: description of the commit.
|
||||||
|
<ref>: N25B-Num's
|
||||||
|
|
||||||
|
|||||||
265
package-lock.json
generated
265
package-lock.json
generated
@@ -28,12 +28,10 @@
|
|||||||
"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"
|
||||||
@@ -1350,20 +1348,6 @@
|
|||||||
"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",
|
||||||
@@ -1476,9 +1460,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
||||||
"version": "3.14.2",
|
"version": "3.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
|
||||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2367,55 +2351,6 @@
|
|||||||
"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",
|
||||||
@@ -2702,16 +2637,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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",
|
||||||
@@ -2813,13 +2738,6 @@
|
|||||||
"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",
|
||||||
@@ -3406,12 +3324,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xyflow/react": {
|
"node_modules/@xyflow/react": {
|
||||||
"version": "12.9.1",
|
"version": "12.8.6",
|
||||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.6.tgz",
|
||||||
"integrity": "sha512-JRPCT5p7NnPdVSIh15AFvUSSm+8GUyz2I6iuBEC1LG2lKgig/L48AM/ImMHCc3ZUCg+AgTOJDaX2fcRyPA9BTA==",
|
"integrity": "sha512-SksAm2m4ySupjChphMmzvm55djtgMDPr+eovPDdTnyGvShf73cvydfoBfWDFllooIQ4IaiUL5yfxHRwU0c37EA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xyflow/system": "0.0.72",
|
"@xyflow/system": "0.0.70",
|
||||||
"classcat": "^5.0.3",
|
"classcat": "^5.0.3",
|
||||||
"zustand": "^4.4.0"
|
"zustand": "^4.4.0"
|
||||||
},
|
},
|
||||||
@@ -3449,9 +3367,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xyflow/system": {
|
"node_modules/@xyflow/system": {
|
||||||
"version": "0.0.72",
|
"version": "0.0.70",
|
||||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz",
|
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz",
|
||||||
"integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==",
|
"integrity": "sha512-PpC//u9zxdjj0tfTSmZrg3+sRbTz6kop/Amky44U2Dl51sxzDTIUfXMwETOYpmr2dqICWXBIJwXL2a9QWtX2XA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-drag": "^3.0.7",
|
"@types/d3-drag": "^3.0.7",
|
||||||
@@ -5052,22 +4970,6 @@
|
|||||||
"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",
|
||||||
@@ -6006,9 +5908,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6153,16 +6055,6 @@
|
|||||||
"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",
|
||||||
@@ -6203,13 +6095,6 @@
|
|||||||
"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",
|
||||||
@@ -6267,44 +6152,6 @@
|
|||||||
"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",
|
||||||
@@ -6857,16 +6704,6 @@
|
|||||||
"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",
|
||||||
@@ -7765,56 +7602,6 @@
|
|||||||
"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",
|
||||||
@@ -7853,13 +7640,6 @@
|
|||||||
"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",
|
||||||
@@ -7958,9 +7738,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.6.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||||
"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"
|
||||||
@@ -8362,19 +8142,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -6,10 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint src test",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview"
|
||||||
"test": "jest",
|
|
||||||
"prepare": "husky"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neodrag/react": "^2.3.1",
|
"@neodrag/react": "^2.3.1",
|
||||||
@@ -32,12 +30,10 @@
|
|||||||
"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"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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 {useState} from "react";
|
||||||
import Logging from "./components/Logging/Logging.tsx";
|
import Logging from "./components/Logging/Logging.tsx";
|
||||||
@@ -24,8 +23,7 @@ function App(){
|
|||||||
<Route path="/template" element={<TemplatePage />} />
|
<Route path="/template" element={<TemplatePage />} />
|
||||||
<Route path="/editor" element={<VisProg />} />
|
<Route path="/editor" element={<VisProg />} />
|
||||||
<Route path="/robot" element={<Robot />} />
|
<Route path="/robot" element={<Robot />} />
|
||||||
<Route path="/ConnectedRobots" element={<ConnectedRobots />} />
|
</Routes>
|
||||||
</Routes>
|
|
||||||
</main>
|
</main>
|
||||||
{showLogs && <Logging />}
|
{showLogs && <Logging />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,15 +4,8 @@ import type {LogFilterPredicate} from "./useLogs.ts";
|
|||||||
|
|
||||||
import styles from "./Filters.module.css";
|
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;
|
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([
|
const optionMapping = new Map([
|
||||||
["ALL", 0],
|
["ALL", 0],
|
||||||
["DEBUG", 10],
|
["DEBUG", 10],
|
||||||
@@ -23,17 +16,6 @@ const optionMapping = new Map([
|
|||||||
["NONE", 999_999_999_999], // It is technically possible to have a higher level, but this is fine
|
["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({
|
function LevelPredicateElement({
|
||||||
name,
|
name,
|
||||||
level,
|
level,
|
||||||
@@ -72,19 +54,8 @@ function LevelPredicateElement({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Key used for the global log-level predicate in the filter map. */
|
|
||||||
const GLOBAL_LOG_LEVEL_PREDICATE_KEY = "global_log_level";
|
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({
|
function GlobalLevelFilter({
|
||||||
filterPredicates,
|
filterPredicates,
|
||||||
setFilterPredicates,
|
setFilterPredicates,
|
||||||
@@ -107,7 +78,6 @@ function GlobalLevelFilter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize default global level on mount.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filterPredicates.has(GLOBAL_LOG_LEVEL_PREDICATE_KEY)) return;
|
if (filterPredicates.has(GLOBAL_LOG_LEVEL_PREDICATE_KEY)) return;
|
||||||
setSelected("INFO");
|
setSelected("INFO");
|
||||||
@@ -121,21 +91,8 @@ function GlobalLevelFilter({
|
|||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prefix for agent-specific log-level predicate keys in the filter map. */
|
|
||||||
const AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX = "agent_log_level_";
|
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({
|
function AgentLevelFilters({
|
||||||
filterPredicates,
|
filterPredicates,
|
||||||
setFilterPredicates,
|
setFilterPredicates,
|
||||||
@@ -148,7 +105,7 @@ function AgentLevelFilters({
|
|||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
// Close dropdown or panels when clicking outside or pressing Escape.
|
// Click outside to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onDocClick = (e: MouseEvent) => {
|
const onDocClick = (e: MouseEvent) => {
|
||||||
@@ -167,16 +124,13 @@ function AgentLevelFilters({
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// Identify which predicates correspond to agents.
|
|
||||||
const agentPredicates = [...filterPredicates.keys()].filter((key) =>
|
const agentPredicates = [...filterPredicates.keys()].filter((key) =>
|
||||||
key.startsWith(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX));
|
key.startsWith(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates or updates the log filter predicate for a specific agent.
|
* Create or change the predicate for an agent. If the level is not given, the global level is used.
|
||||||
* Falls back to the global log level if no level is specified.
|
* @param agentName The name of the agent.
|
||||||
*
|
* @param level The level to filter by. If not given, the global level is used.
|
||||||
* @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 ) => {
|
const setAgentPredicate = (agentName: string, level?: string ) => {
|
||||||
level = level ?? filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value ?? "ALL";
|
level = level ?? filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value ?? "ALL";
|
||||||
@@ -193,11 +147,6 @@ function AgentLevelFilters({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes the log filter predicate for a specific agent.
|
|
||||||
*
|
|
||||||
* @param agentName - The name of the agent whose filter should be removed.
|
|
||||||
*/
|
|
||||||
const deleteAgentPredicate = (agentName: string) => {
|
const deleteAgentPredicate = (agentName: string) => {
|
||||||
setFilterPredicates((curr) => {
|
setFilterPredicates((curr) => {
|
||||||
const fullName = AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName;
|
const fullName = AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName;
|
||||||
@@ -235,17 +184,6 @@ function AgentLevelFilters({
|
|||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Main Filters component that aggregates global and per-agent log filters.
|
|
||||||
*
|
|
||||||
* Combines the global log-level filter and agent-specific filters into a unified UI.
|
|
||||||
* Updates a shared `Map<string, LogFilterPredicate>` to determine which logs are shown.
|
|
||||||
*
|
|
||||||
* @param filterPredicates - The map of all active log filter predicates.
|
|
||||||
* @param setFilterPredicates - Setter to update the map of predicates.
|
|
||||||
* @param agentNames - Set of available agent names to display filters for.
|
|
||||||
* @returns A React component that renders all log filter controls.
|
|
||||||
*/
|
|
||||||
export default function Filters({
|
export default function Filters({
|
||||||
filterPredicates,
|
filterPredicates,
|
||||||
setFilterPredicates,
|
setFilterPredicates,
|
||||||
|
|||||||
@@ -8,26 +8,13 @@ import {type Cell, useCell} from "../../utils/cellStore.ts";
|
|||||||
|
|
||||||
import styles from "./Logging.module.css";
|
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 = {
|
type LoggingSettings = {
|
||||||
/** Whether to display log timestamps as relative (e.g., "2m 15s ago") instead of absolute. */
|
|
||||||
showRelativeTime: boolean;
|
showRelativeTime: boolean;
|
||||||
/** Updates the `showRelativeTime` setting. */
|
|
||||||
setShowRelativeTime: (showRelativeTime: boolean) => void;
|
setShowRelativeTime: (showRelativeTime: boolean) => void;
|
||||||
/** Whether the log view should automatically scroll to the newest entry. */
|
|
||||||
scrollToBottom: boolean;
|
scrollToBottom: boolean;
|
||||||
/** Updates the `scrollToBottom` setting. */
|
|
||||||
setScrollToBottom: (scrollToBottom: boolean) => void;
|
setScrollToBottom: (scrollToBottom: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Global Zustand store for logging UI preferences.
|
|
||||||
*/
|
|
||||||
const useLoggingSettings = create<LoggingSettings>((set) => ({
|
const useLoggingSettings = create<LoggingSettings>((set) => ({
|
||||||
showRelativeTime: false,
|
showRelativeTime: false,
|
||||||
setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }),
|
setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }),
|
||||||
@@ -35,16 +22,6 @@ const useLoggingSettings = create<LoggingSettings>((set) => ({
|
|||||||
setScrollToBottom: (scrollToBottom: boolean) => set({ scrollToBottom }),
|
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({
|
function LogMessage({
|
||||||
recordCell,
|
recordCell,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
@@ -56,8 +33,7 @@ function LogMessage({
|
|||||||
const record = useCell(recordCell);
|
const record = useCell(recordCell);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes the log level number to a multiple of 10,
|
* Normalizes the log level number to a multiple of 10, for which there are CSS styles.
|
||||||
* for which there are CSS styles. (e.g., INFO = 20, ERROR = 40).
|
|
||||||
*/
|
*/
|
||||||
const normalizedLevelNo = (() => {
|
const normalizedLevelNo = (() => {
|
||||||
// By default, the highest level is 50 (CRITICAL). Custom levels can be higher, but we don't have more critical color.
|
// By default, the highest level is 50 (CRITICAL). Custom levels can be higher, but we don't have more critical color.
|
||||||
@@ -66,10 +42,8 @@ function LogMessage({
|
|||||||
return Math.round(record.levelno / 10) * 10;
|
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;
|
const normalizedName = record.name.split(".").pop() || record.name;
|
||||||
|
|
||||||
// Notify parent component (e.g. for scroll updates) when this record changes.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onUpdate) onUpdate();
|
if (onUpdate) onUpdate();
|
||||||
}, [record, onUpdate]);
|
}, [record, onUpdate]);
|
||||||
@@ -91,23 +65,11 @@ function LogMessage({
|
|||||||
</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>[] }) {
|
function LogMessages({ recordCells }: { recordCells: Cell<LogRecord>[] }) {
|
||||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||||
const lastElementRef = useRef<HTMLLIElement>(null)
|
const lastElementRef = useRef<HTMLLIElement>(null)
|
||||||
const { scrollToBottom, setScrollToBottom } = useLoggingSettings();
|
const { scrollToBottom, setScrollToBottom } = useLoggingSettings();
|
||||||
|
|
||||||
// Disable auto-scroll if the user manually scrolls.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scrollableRef.current) return;
|
if (!scrollableRef.current) return;
|
||||||
const currentScrollableRef = scrollableRef.current;
|
const currentScrollableRef = scrollableRef.current;
|
||||||
@@ -123,12 +85,6 @@ function LogMessages({ recordCells }: { recordCells: Cell<LogRecord>[] }) {
|
|||||||
}
|
}
|
||||||
}, [scrollableRef, setScrollToBottom]);
|
}, [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) {
|
function scrollLastElementIntoView(force = false) {
|
||||||
if ((!scrollToBottom && !force) || !lastElementRef.current) return;
|
if ((!scrollToBottom && !force) || !lastElementRef.current) return;
|
||||||
lastElementRef.current.scrollIntoView({ behavior: "smooth" });
|
lastElementRef.current.scrollIntoView({ behavior: "smooth" });
|
||||||
@@ -155,19 +111,6 @@ function LogMessages({ recordCells }: { recordCells: Cell<LogRecord>[] }) {
|
|||||||
</div>;
|
</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() {
|
export default function Logging() {
|
||||||
const [filterPredicates, setFilterPredicates] = useState(new Map<string, LogFilterPredicate>());
|
const [filterPredicates, setFilterPredicates] = useState(new Map<string, LogFilterPredicate>());
|
||||||
const { filteredLogs, distinctNames } = useLogs(filterPredicates)
|
const { filteredLogs, distinctNames } = useLogs(filterPredicates)
|
||||||
|
|||||||
@@ -3,19 +3,6 @@ import {useCallback, useEffect, useRef, useState} from "react";
|
|||||||
import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../utils/priorityFiltering.ts";
|
import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../utils/priorityFiltering.ts";
|
||||||
import {cell, type Cell} from "../../utils/cellStore.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 = {
|
export type LogRecord = {
|
||||||
name: string;
|
name: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -28,68 +15,29 @@ export type LogRecord = {
|
|||||||
firstRelativeCreated: number;
|
firstRelativeCreated: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
* A log filter predicate with priority support, used to determine whether
|
export type LogFilterPredicate = PriorityFilterPredicate<LogRecord> & { value: any };
|
||||||
* 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>) {
|
export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
|
||||||
/** Distinct logger names encountered across all logs. */
|
|
||||||
const [distinctNames, setDistinctNames] = useState<Set<string>>(new Set());
|
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>[]>([]);
|
const [filtered, setFiltered] = useState<Cell<LogRecord>[]>([]);
|
||||||
|
|
||||||
/** Persistent reference to the active EventSource connection. */
|
|
||||||
const sseRef = useRef<EventSource | null>(null);
|
const sseRef = useRef<EventSource | null>(null);
|
||||||
/** Keeps a stable reference to the current filter map (avoids re-renders). */
|
|
||||||
const filtersRef = useRef(filterPredicates);
|
const filtersRef = useRef(filterPredicates);
|
||||||
/** Stores all received logs (the unfiltered full history). */
|
|
||||||
const logsRef = useRef<LogRecord[]>([]);
|
const logsRef = useRef<LogRecord[]>([]);
|
||||||
|
|
||||||
/** Map to store the first message for each reference, instance can be updated to change contents. */
|
/** 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());
|
const firstByRefRef = useRef<Map<string, Cell<LogRecord>>>(new Map());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply all active filter predicates to a log record.
|
* Apply the filter predicates to a log record.
|
||||||
* @param log The log record to apply the filters to.
|
* @param log The log record to apply the filters to.
|
||||||
* @returns `true` if the record passes all filters; otherwise `false`.
|
* @returns `true` if the record passes.
|
||||||
*/
|
*/
|
||||||
const applyFilters = useCallback((log: LogRecord) =>
|
const applyFilters = useCallback((log: LogRecord) =>
|
||||||
applyPriorityPredicates(log, [...filtersRef.current.values()]), []);
|
applyPriorityPredicates(log, [...filtersRef.current.values()]), []);
|
||||||
|
|
||||||
/**
|
/** Recomputes the entire filtered list. Use when filter predicates change. */
|
||||||
* Fully recomputes the filtered log list based on the current
|
|
||||||
* filter predicates and historical logs.
|
|
||||||
*
|
|
||||||
* Should be invoked whenever the filter map changes.
|
|
||||||
*/
|
|
||||||
const recomputeFiltered = useCallback(() => {
|
const recomputeFiltered = useCallback(() => {
|
||||||
const newFiltered: Cell<LogRecord>[] = [];
|
const newFiltered: Cell<LogRecord>[] = [];
|
||||||
firstByRefRef.current = new Map();
|
firstByRefRef.current = new Map();
|
||||||
@@ -101,7 +49,6 @@ export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
|
|||||||
firstRelativeCreated: message.relativeCreated,
|
firstRelativeCreated: message.relativeCreated,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle reference grouping: update the first message in the group.
|
|
||||||
if (message.reference) {
|
if (message.reference) {
|
||||||
const first = firstByRefRef.current.get(message.reference);
|
const first = firstByRefRef.current.get(message.reference);
|
||||||
if (first) {
|
if (first) {
|
||||||
@@ -112,14 +59,14 @@ export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
|
|||||||
firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated,
|
firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
continue; // Don't add it to the list again (it's a duplicate).
|
// Don't add it to the list again
|
||||||
|
continue;
|
||||||
} else {
|
} else {
|
||||||
// Add the first message with this reference to the registry
|
// Add the first message with this reference to the registry
|
||||||
firstByRefRef.current.set(message.reference, messageCell);
|
firstByRefRef.current.set(message.reference, messageCell);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include only if it passes current filters.
|
|
||||||
if (applyFilters(message)) {
|
if (applyFilters(message)) {
|
||||||
newFiltered.push(messageCell);
|
newFiltered.push(messageCell);
|
||||||
}
|
}
|
||||||
@@ -128,23 +75,20 @@ export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
|
|||||||
setFiltered(newFiltered);
|
setFiltered(newFiltered);
|
||||||
}, [applyFilters, setFiltered]);
|
}, [applyFilters, setFiltered]);
|
||||||
|
|
||||||
// Re-filter all logs whenever filter predicates change.
|
// Reapply filters to all logs, only when filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
filtersRef.current = filterPredicates;
|
filtersRef.current = filterPredicates;
|
||||||
recomputeFiltered();
|
recomputeFiltered();
|
||||||
}, [filterPredicates, recomputeFiltered]);
|
}, [filterPredicates, recomputeFiltered]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles a newly received log record.
|
* Handle a new log message. Updates the filtered list and to the full history.
|
||||||
* Updates the full log history, distinct names set, and filtered log list.
|
* @param message The new log message.
|
||||||
*
|
|
||||||
* @param message - The new log record to process.
|
|
||||||
*/
|
*/
|
||||||
const handleNewMessage = useCallback((message: LogRecord) => {
|
const handleNewMessage = useCallback((message: LogRecord) => {
|
||||||
// Store in complete history for future refiltering.
|
// Add to the full history for re-filtering on filter changes
|
||||||
logsRef.current.push(message);
|
logsRef.current.push(message);
|
||||||
|
|
||||||
// Track distinct logger names.
|
|
||||||
setDistinctNames((prev) => {
|
setDistinctNames((prev) => {
|
||||||
if (prev.has(message.name)) return prev;
|
if (prev.has(message.name)) return prev;
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
@@ -152,14 +96,12 @@ export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
|
|||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wrap in a reactive cell for UI binding.
|
|
||||||
const messageCell = cell<LogRecord>({
|
const messageCell = cell<LogRecord>({
|
||||||
...message,
|
...message,
|
||||||
firstCreated: message.created,
|
firstCreated: message.created,
|
||||||
firstRelativeCreated: message.relativeCreated,
|
firstRelativeCreated: message.relativeCreated,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle reference-linked updates.
|
|
||||||
if (message.reference) {
|
if (message.reference) {
|
||||||
const first = firstByRefRef.current.get(message.reference);
|
const first = firstByRefRef.current.get(message.reference);
|
||||||
if (first) {
|
if (first) {
|
||||||
@@ -170,28 +112,20 @@ export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
|
|||||||
firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated,
|
firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return; // Do not duplicate reference group entries.
|
// Don't add it to the list again
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
// Add the first message with this reference to the registry
|
||||||
firstByRefRef.current.set(message.reference, messageCell);
|
firstByRefRef.current.set(message.reference, messageCell);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only append if message passes filters.
|
|
||||||
if (applyFilters(message)) {
|
if (applyFilters(message)) {
|
||||||
setFiltered((curr) => [...curr, messageCell]);
|
setFiltered((curr) => [...curr, messageCell]);
|
||||||
}
|
}
|
||||||
}, [applyFilters, setFiltered]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
// Only create one SSE connection for the lifetime of the hook.
|
|
||||||
if (sseRef.current) return;
|
if (sseRef.current) return;
|
||||||
|
|
||||||
const es = new EventSource("http://localhost:8000/logs/stream");
|
const es = new EventSource("http://localhost:8000/logs/stream");
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import {useEffect, useRef} from "react";
|
import {useEffect, useRef} from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A React component that automatically scrolls itself into view whenever rendered.
|
* An element that always scrolls into view when it is rendered. When added to a list, the entire list will scroll to show this element.
|
||||||
*
|
|
||||||
* 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() {
|
export default function ScrollIntoView() {
|
||||||
/** Ref to the DOM element that will be scrolled into view. */
|
|
||||||
const elementRef = useRef<HTMLDivElement>(null);
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,22 +2,15 @@ import {useEffect, useState} from "react";
|
|||||||
import styles from "./TextField.module.css";
|
import styles from "./TextField.module.css";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A styled text input that updates its value **in real time** at every keystroke.
|
* A text input element in our own style that calls `setValue` at every keystroke.
|
||||||
*
|
*
|
||||||
* Automatically toggles between read-only and editable modes to integrate with
|
* @param {Object} props - The component props.
|
||||||
* drag-based UIs (like React Flow). Calls `onCommit` when editing is completed.
|
* @param {string} props.value - The value of the text input.
|
||||||
*
|
* @param {(value: string) => void} props.setValue - A function that sets the value of the text input.
|
||||||
* @param props - Component properties.
|
* @param {string} [props.placeholder] - The placeholder text for the text input.
|
||||||
* @param props.value - The current text input value.
|
* @param {string} [props.className] - Additional CSS classes for the text input.
|
||||||
* @param props.setValue - Callback invoked on every keystroke to update the value.
|
* @param {string} [props.id] - The ID of the text input.
|
||||||
* @param props.onCommit - Callback invoked when editing is finalized (on blur or Enter).
|
* @param {string} [props.ariaLabel] - The ARIA label for the text input.
|
||||||
* @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({
|
export function RealtimeTextField({
|
||||||
value = "",
|
value = "",
|
||||||
@@ -38,19 +31,14 @@ export function RealtimeTextField({
|
|||||||
ariaLabel?: string,
|
ariaLabel?: string,
|
||||||
invalid?: boolean,
|
invalid?: boolean,
|
||||||
}) {
|
}) {
|
||||||
/** Tracks whether the input is currently read-only (for drag compatibility). */
|
|
||||||
const [readOnly, setReadOnly] = useState(true);
|
const [readOnly, setReadOnly] = useState(true);
|
||||||
|
|
||||||
/** Finalizes editing and calls `onCommit` when the user exits the field. */
|
|
||||||
const updateData = () => {
|
const updateData = () => {
|
||||||
setReadOnly(true);
|
setReadOnly(true);
|
||||||
onCommit();
|
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(); };
|
||||||
const updateOnEnter = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (event.key === "Enter")
|
|
||||||
(event.target as HTMLInputElement).blur(); };
|
|
||||||
|
|
||||||
return <input
|
return <input
|
||||||
type={"text"}
|
type={"text"}
|
||||||
@@ -69,22 +57,15 @@ export function RealtimeTextField({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A styled text input that updates its value **only on commit** (when the user
|
* A text input element in our own style that calls `setValue` once the user presses the enter key or clicks outside the input.
|
||||||
* presses Enter or clicks outside the input).
|
|
||||||
*
|
*
|
||||||
* Internally wraps `RealtimeTextField` and buffers input changes locally,
|
* @param {Object} props - The component props.
|
||||||
* calling `setValue` only once editing is complete.
|
* @param {string} props.value - The value of the text input.
|
||||||
*
|
* @param {(value: string) => void} props.setValue - A function that sets the value of the text input.
|
||||||
* @param props - Component properties.
|
* @param {string} [props.placeholder] - The placeholder text for the text input.
|
||||||
* @param props.value - The current text input value.
|
* @param {string} [props.className] - Additional CSS classes for the text input.
|
||||||
* @param props.setValue - Callback invoked when the user commits the change.
|
* @param {string} [props.id] - The ID of the text input.
|
||||||
* @param props.placeholder - Optional placeholder text displayed when the input is empty.
|
* @param {string} [props.ariaLabel] - The ARIA label for the text input.
|
||||||
* @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({
|
export function TextField({
|
||||||
value = "",
|
value = "",
|
||||||
@@ -105,8 +86,10 @@ export function TextField({
|
|||||||
}) {
|
}) {
|
||||||
const [inputValue, setInputValue] = useState(value);
|
const [inputValue, setInputValue] = useState(value);
|
||||||
|
|
||||||
// Re-render when the value gets updated externally
|
// The value may be changed from the outside, for example, when a program is loaded. We need to update the input value accordingly.
|
||||||
useEffect(() => setInputValue(value), [setInputValue, value]);
|
useEffect(() => {
|
||||||
|
setInputValue(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
const onCommit = () => setValue(inputValue);
|
const onCommit = () => setValue(inputValue);
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
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 (
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ html, body, #root {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
font-weight: 500;
|
||||||
color: canvastext;
|
color: canvastext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays the current connection status of a robot in real time.
|
|
||||||
*
|
|
||||||
* Opens an SSE connection to the backend (`/robot/ping_stream`) that emits
|
|
||||||
* simple boolean JSON messages (`true` or `false`). Updates automatically when
|
|
||||||
* the robot connects or disconnects.
|
|
||||||
*
|
|
||||||
* @returns A React element showing the current robot connection status.
|
|
||||||
*/
|
|
||||||
export default function ConnectedRobots() {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The current connection state:
|
|
||||||
* - `true`: Robot is connected.
|
|
||||||
* - `false`: Robot is not connected.
|
|
||||||
* - `null`: Connection status is unknown (initial check in progress).
|
|
||||||
*/
|
|
||||||
const [connected, setConnected] = useState<boolean | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Open a Server-Sent Events (SSE) connection to receive live ping updates.
|
|
||||||
// We're expecting a stream of data like that looks like this: `data = False` or `data = True`
|
|
||||||
const eventSource = new EventSource("http://localhost:8000/robot/ping_stream");
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
|
|
||||||
// Expecting messages in JSON format: `true` or `false`
|
|
||||||
console.log("received message:", event.data);
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
|
|
||||||
try {
|
|
||||||
setConnected(data)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
console.log("couldnt extract connected from incoming ping data")
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
console.log("Ping message not in correct format:", event.data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clean up the SSE connection when the component unmounts.
|
|
||||||
return () => eventSource.close();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>Is robot currently connected?</h1>
|
|
||||||
<div>
|
|
||||||
<h2>Robot is currently: {connected == null ? "checking..." : (connected ? "connected! 🟢" : "not connected... 🔴")} </h2>
|
|
||||||
<h3>
|
|
||||||
{connected == null ? "If checking continues, make sure CB is properly loaded with robot at least once." : ""}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,6 @@ 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}`}>
|
||||||
@@ -22,7 +14,6 @@ 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,34 +1,13 @@
|
|||||||
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);
|
||||||
/** The ongoing conversation history as a sequence of user/assistant messages. */
|
const [conversation, setConversation] = useState<{"role": "user" | "assistant", "content": string}[]>([])
|
||||||
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", {
|
||||||
@@ -45,17 +24,6 @@ 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");
|
||||||
|
|
||||||
@@ -75,10 +43,6 @@ 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;
|
||||||
|
|||||||
@@ -7,6 +7,31 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.node-text-input {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 5pt;
|
||||||
|
padding: 4px 8px;
|
||||||
|
outline: none;
|
||||||
|
background-color: white;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-text-input:focus {
|
||||||
|
border-color: gainsboro;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-text-input:read-only {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: whitesmoke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-text-input:read-only:hover {
|
||||||
|
border-color: gainsboro;
|
||||||
|
}
|
||||||
|
|
||||||
.dnd-panel {
|
.dnd-panel {
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
margin-inline-end: auto;
|
margin-inline-end: auto;
|
||||||
@@ -42,7 +67,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.node-norm {
|
.node-norm {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,3 +151,4 @@
|
|||||||
outline: red solid 2pt;
|
outline: red solid 2pt;
|
||||||
filter: drop-shadow(0 0 0.25rem red);
|
filter: drop-shadow(0 0 0.25rem red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,34 @@ import {
|
|||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import {useShallow} from 'zustand/react/shallow';
|
import {useShallow} from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import {
|
||||||
|
StartNodeComponent,
|
||||||
|
EndNodeComponent,
|
||||||
|
PhaseNodeComponent,
|
||||||
|
NormNodeComponent,
|
||||||
|
GoalNodeComponent,
|
||||||
|
} from './visualProgrammingUI/components/NodeDefinitions.tsx';
|
||||||
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
||||||
|
import graphReducer from "./visualProgrammingUI/GraphReducer.ts";
|
||||||
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 { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
|
import TriggerNodeComponent from "./visualProgrammingUI/components/TriggerNodeComponent.tsx";
|
||||||
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,
|
||||||
|
goal: GoalNodeComponent,
|
||||||
|
trigger: TriggerNodeComponent,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* defines how the default edge looks inside the editor
|
* defines how the default edge looks inside the editor
|
||||||
@@ -70,7 +89,7 @@ const VisProgUI = () => {
|
|||||||
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}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onReconnect={onReconnect}
|
onReconnect={onReconnect}
|
||||||
@@ -83,10 +102,7 @@ const VisProgUI = () => {
|
|||||||
>
|
>
|
||||||
<Panel position="top-center" className={styles.dndPanel}>
|
<Panel position="top-center" className={styles.dndPanel}>
|
||||||
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
|
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel position = "bottom-left" className={styles.saveLoadPanel}>
|
|
||||||
<SaveLoadPanel></SaveLoadPanel>
|
|
||||||
</Panel>
|
|
||||||
<Controls/>
|
<Controls/>
|
||||||
<Background/>
|
<Background/>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
@@ -95,7 +111,6 @@ const VisProgUI = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Places the VisProgUI component inside a ReactFlowProvider
|
* Places the VisProgUI component inside a ReactFlowProvider
|
||||||
*
|
*
|
||||||
@@ -113,33 +128,9 @@ function VisualProgrammingUI() {
|
|||||||
|
|
||||||
// currently outputs the prepared program to the console
|
// currently outputs the prepared program to the console
|
||||||
function runProgram() {
|
function runProgram() {
|
||||||
const phases = graphReducer();
|
const program = graphReducer();
|
||||||
const program = {phases}
|
console.log(program);
|
||||||
console.log(JSON.stringify(program, null, 2));
|
console.log(JSON.stringify(program, null, 2));
|
||||||
fetch(
|
|
||||||
"http://localhost:8000/program",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {"Content-Type": "application/json"},
|
|
||||||
body: JSON.stringify(program),
|
|
||||||
}
|
|
||||||
).then((res) => {
|
|
||||||
if (!res.ok) throw new Error("Failed communicating with the backend.")
|
|
||||||
console.log("Successfully sent the program to the backend.");
|
|
||||||
}).catch(() => console.log("Failed to send program to the backend."));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reduces the graph into its phases' information and recursively calls their reducing function
|
|
||||||
*/
|
|
||||||
function graphReducer() {
|
|
||||||
const { nodes } = useFlowStore.getState();
|
|
||||||
return nodes
|
|
||||||
.filter((n) => n.type == 'phase')
|
|
||||||
.map((n) => {
|
|
||||||
const reducer = NodeReduces['phase'];
|
|
||||||
return reducer(n, nodes)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -156,4 +147,4 @@ function VisProgPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default VisProgPage
|
export default VisProgPage
|
||||||
205
src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts
Normal file
205
src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import {
|
||||||
|
type Edge,
|
||||||
|
getIncomers,
|
||||||
|
getOutgoers
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import useFlowStore from "./VisProgStores.tsx";
|
||||||
|
import type {
|
||||||
|
BehaviorProgram,
|
||||||
|
GoalData,
|
||||||
|
GoalReducer,
|
||||||
|
GraphPreprocessor,
|
||||||
|
NormData,
|
||||||
|
NormReducer,
|
||||||
|
OrderedPhases,
|
||||||
|
Phase,
|
||||||
|
PhaseReducer,
|
||||||
|
PreparedGraph,
|
||||||
|
PreparedPhase, Reduced, TriggerReducer
|
||||||
|
} from "./GraphReducerTypes.ts";
|
||||||
|
import type {
|
||||||
|
AppNode,
|
||||||
|
GoalNode,
|
||||||
|
NormNode,
|
||||||
|
PhaseNode, TriggerNode
|
||||||
|
} from "./VisProgTypes.tsx";
|
||||||
|
import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces the current graph inside the visual programming editor into a BehaviorProgram
|
||||||
|
*
|
||||||
|
* @param {GraphPreprocessor} graphPreprocessor
|
||||||
|
* @param {PhaseReducer} phaseReducer
|
||||||
|
* @param {NormReducer} normReducer
|
||||||
|
* @param {GoalReducer} goalReducer
|
||||||
|
* @param {TriggerReducer} triggerReducer
|
||||||
|
* @returns {BehaviorProgram}
|
||||||
|
*/
|
||||||
|
export default function graphReducer(
|
||||||
|
graphPreprocessor: GraphPreprocessor = defaultGraphPreprocessor,
|
||||||
|
phaseReducer: PhaseReducer = defaultPhaseReducer,
|
||||||
|
normReducer: NormReducer = defaultNormReducer,
|
||||||
|
goalReducer: GoalReducer = defaultGoalReducer,
|
||||||
|
triggerReducer: TriggerReducer = defaultTriggerReducer,
|
||||||
|
) : BehaviorProgram {
|
||||||
|
const nodes: AppNode[] = useFlowStore.getState().nodes;
|
||||||
|
const edges: Edge[] = useFlowStore.getState().edges;
|
||||||
|
const preparedGraph: PreparedGraph = graphPreprocessor(nodes, edges);
|
||||||
|
|
||||||
|
return preparedGraph.map((preparedPhase: PreparedPhase) : Phase =>
|
||||||
|
phaseReducer(
|
||||||
|
preparedPhase,
|
||||||
|
normReducer,
|
||||||
|
goalReducer,
|
||||||
|
triggerReducer,
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reduces a single preparedPhase to a Phase object
|
||||||
|
* the Phase object describes a single phase in a BehaviorProgram
|
||||||
|
*
|
||||||
|
* @param {PreparedPhase} phase
|
||||||
|
* @param {NormReducer} normReducer
|
||||||
|
* @param {GoalReducer} goalReducer
|
||||||
|
* @param {TriggerReducer} triggerReducer
|
||||||
|
* @returns {Phase}
|
||||||
|
*/
|
||||||
|
export function defaultPhaseReducer(
|
||||||
|
phase: PreparedPhase,
|
||||||
|
normReducer: NormReducer = defaultNormReducer,
|
||||||
|
goalReducer: GoalReducer = defaultGoalReducer,
|
||||||
|
triggerReducer: TriggerReducer = defaultTriggerReducer,
|
||||||
|
) : Phase {
|
||||||
|
return {
|
||||||
|
id: phase.phaseNode.id,
|
||||||
|
name: phase.phaseNode.data.label,
|
||||||
|
nextPhaseId: phase.nextPhaseId,
|
||||||
|
phaseData: {
|
||||||
|
norms: phase.connectedNorms.map(normReducer),
|
||||||
|
goals: phase.connectedGoals.map(goalReducer),
|
||||||
|
triggers: phase.connectedTriggers.map(triggerReducer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the default implementation of the goalNode reducer function
|
||||||
|
*
|
||||||
|
* @param {GoalNode} node
|
||||||
|
* @returns {GoalData}
|
||||||
|
*/
|
||||||
|
function defaultGoalReducer(node: GoalNode) : Reduced<GoalData> {
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
name: node.data.label,
|
||||||
|
description: node.data.description,
|
||||||
|
achieved: node.data.achieved,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* the default implementation of the normNode reducer function
|
||||||
|
*
|
||||||
|
* @param {NormNode} node
|
||||||
|
* @returns {NormData}
|
||||||
|
*/
|
||||||
|
function defaultNormReducer(node: NormNode) :Reduced<NormData> {
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
name: node.data.label,
|
||||||
|
value: node.data.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultTriggerReducer(node: TriggerNode): Reduced<TriggerNodeProps> {
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
...node.data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graph preprocessing functions:
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preprocesses the provide state of the behavior editor graph, preparing it for further processing in
|
||||||
|
* the graphReducer function
|
||||||
|
*
|
||||||
|
* @param {AppNode[]} nodes
|
||||||
|
* @param {Edge[]} edges
|
||||||
|
* @returns {PreparedGraph}
|
||||||
|
*/
|
||||||
|
export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : PreparedGraph {
|
||||||
|
const norms : NormNode[] = nodes.filter((node) => node.type === 'norm') as NormNode[];
|
||||||
|
const goals : GoalNode[] = nodes.filter((node) => node.type === 'goal') as GoalNode[];
|
||||||
|
const triggers : TriggerNode[] = nodes.filter((node) => node.type === 'trigger') as TriggerNode[];
|
||||||
|
const orderedPhases : OrderedPhases = orderPhases(nodes, edges);
|
||||||
|
|
||||||
|
return orderedPhases.phaseNodes.map((phase: PhaseNode) : PreparedPhase => {
|
||||||
|
const nextPhase = orderedPhases.connections.get(phase.id);
|
||||||
|
return {
|
||||||
|
phaseNode: phase,
|
||||||
|
nextPhaseId: nextPhase as string,
|
||||||
|
connectedNorms: getIncomers({id: phase.id}, norms,edges),
|
||||||
|
connectedGoals: getIncomers({id: phase.id}, goals,edges),
|
||||||
|
connectedTriggers: getIncomers({id: phase.id}, triggers, edges),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* orderPhases takes the state of the graph created by the editor and turns it into an OrderedPhases object.
|
||||||
|
*
|
||||||
|
* @param {AppNode[]} nodes
|
||||||
|
* @param {Edge[]} edges
|
||||||
|
* @returns {OrderedPhases}
|
||||||
|
*/
|
||||||
|
export function orderPhases(nodes: AppNode[],edges: Edge[]) : OrderedPhases {
|
||||||
|
// find the first Phase node
|
||||||
|
const phaseNodes : PhaseNode[] = nodes.filter((node) => node.type === 'phase') as PhaseNode[];
|
||||||
|
const startNodeIndex = nodes.findIndex((node : AppNode):boolean => {return (node.type === 'start');});
|
||||||
|
const firstPhaseNode = getOutgoers({ id: nodes[startNodeIndex].id },phaseNodes,edges);
|
||||||
|
|
||||||
|
// recursively adds the phase nodes to a list in the order they are connected in the graph
|
||||||
|
const nextPhase = (
|
||||||
|
currentIndex: number,
|
||||||
|
{ phaseNodes: phases, connections: connections} : OrderedPhases
|
||||||
|
) : OrderedPhases => {
|
||||||
|
// get the current phase and the next phases;
|
||||||
|
const currentPhase = phases[currentIndex];
|
||||||
|
const nextPhaseNodes = getOutgoers(currentPhase,phaseNodes,edges);
|
||||||
|
const nextNodes = getOutgoers(currentPhase,nodes, edges);
|
||||||
|
|
||||||
|
// handles adding of the next phase to the chain, and error handle if an invalid state is received
|
||||||
|
if (nextPhaseNodes.length === 1 && nextNodes.length === 1) {
|
||||||
|
connections.set(currentPhase.id, nextPhaseNodes[0].id);
|
||||||
|
return nextPhase(phases.push(nextPhaseNodes[0] as PhaseNode) - 1, {phaseNodes: phases, connections: connections});
|
||||||
|
} else {
|
||||||
|
// handle erroneous states
|
||||||
|
if (nextNodes.length === 0){
|
||||||
|
throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" doesn't have any outgoing connections`);
|
||||||
|
} else {
|
||||||
|
if (nextNodes.length > 1) {
|
||||||
|
throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" connects to too many targets`);
|
||||||
|
} else {
|
||||||
|
if (nextNodes[0].type === "end"){
|
||||||
|
connections.set(currentPhase.id, "end");
|
||||||
|
// returns the final output of the function
|
||||||
|
return { phaseNodes: phases, connections: connections};
|
||||||
|
} else {
|
||||||
|
throw new Error(`| INVALID PROGRAM | the node "${nextNodes[0].id}" that "${currentPhase.id}" connects to is not a phase or end node`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// initializes the Map describing the connections between phase nodes
|
||||||
|
// we need this Map to make sure we preserve this information,
|
||||||
|
// so we don't need to do checks on the entire set of edges in further stages of the reduction algorithm
|
||||||
|
const connections : Map<string, string> = new Map();
|
||||||
|
|
||||||
|
// returns an empty list if no phase nodes are present, otherwise returns an ordered list of phaseNodes
|
||||||
|
if (firstPhaseNode.length > 0) {
|
||||||
|
return nextPhase(0, {phaseNodes: [firstPhaseNode[0] as PhaseNode], connections: connections})
|
||||||
|
} else { return {phaseNodes: [], connections: connections} }
|
||||||
|
}
|
||||||
112
src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts
Normal file
112
src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import type {Edge} from "@xyflow/react";
|
||||||
|
import type {AppNode, GoalNode, NormNode, PhaseNode, TriggerNode} from "./VisProgTypes.tsx";
|
||||||
|
import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
export type Reduced<T> = { id: string } & T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* defines how a norm is represented in the simplified behavior program
|
||||||
|
*/
|
||||||
|
export type NormData = {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* defines how a goal is represented in the simplified behavior program
|
||||||
|
*/
|
||||||
|
export type GoalData = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
achieved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* definition of a PhaseData object, it contains all phaseData that is relevant
|
||||||
|
* for further processing and execution of a phase.
|
||||||
|
*/
|
||||||
|
export type PhaseData = {
|
||||||
|
norms: NormData[];
|
||||||
|
goals: GoalData[];
|
||||||
|
triggers: TriggerNodeProps[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes a single phase within the simplified representation of a behavior program,
|
||||||
|
*
|
||||||
|
* Contains:
|
||||||
|
* - the id of the described phase,
|
||||||
|
* - the name of the described phase,
|
||||||
|
* - the id of the next phase in the user defined behavior program
|
||||||
|
* - the data property of the described phase node
|
||||||
|
*
|
||||||
|
* @NOTE at the moment the type definitions do not support branching programs,
|
||||||
|
* if branching of phases is to be supported in the future, the type definition for Phase has to be updated
|
||||||
|
*/
|
||||||
|
export type Phase = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nextPhaseId: string;
|
||||||
|
phaseData: PhaseData;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes a simplified behavior program as a list of Phase objects
|
||||||
|
*/
|
||||||
|
export type BehaviorProgram = Phase[];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type NormReducer = (node: NormNode) => Reduced<NormData>;
|
||||||
|
export type GoalReducer = (node: GoalNode) => Reduced<GoalData>;
|
||||||
|
export type TriggerReducer = (node: TriggerNode) => Reduced<TriggerNodeProps>;
|
||||||
|
export type PhaseReducer = (
|
||||||
|
preparedPhase: PreparedPhase,
|
||||||
|
normReducer: NormReducer,
|
||||||
|
goalReducer: GoalReducer,
|
||||||
|
triggerReducer: TriggerReducer,
|
||||||
|
) => Phase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* contains:
|
||||||
|
*
|
||||||
|
* - list of phases, sorted based on position in chain between the start and end node
|
||||||
|
* - a dictionary containing all outgoing connections,
|
||||||
|
* to other phase or end nodes, for each phase node uses the id of the source node as key
|
||||||
|
* and the id of the target node as value
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type OrderedPhases = {
|
||||||
|
phaseNodes: PhaseNode[];
|
||||||
|
connections: Map<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single prepared phase,
|
||||||
|
* contains:
|
||||||
|
* - the described phaseNode,
|
||||||
|
* - the id of the next phaseNode or "end" for the end node
|
||||||
|
* - a list of the normNodes that are connected to the described phase
|
||||||
|
* - a list of the goalNodes that are connected to the described phase
|
||||||
|
*/
|
||||||
|
export type PreparedPhase = {
|
||||||
|
phaseNode: PhaseNode;
|
||||||
|
nextPhaseId: string;
|
||||||
|
connectedNorms: NormNode[];
|
||||||
|
connectedGoals: GoalNode[];
|
||||||
|
connectedTriggers: TriggerNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a list of PreparedPhase objects,
|
||||||
|
* describes the preprocessed state of a program,
|
||||||
|
* before the contents of the node
|
||||||
|
*/
|
||||||
|
export type PreparedGraph = PreparedPhase[];
|
||||||
|
|
||||||
|
export type GraphPreprocessor = (nodes: AppNode[], edges: Edge[]) => PreparedGraph;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import StartNode, { StartConnects, StartReduce } from "./nodes/StartNode";
|
|
||||||
import EndNode, { EndConnects, EndReduce } from "./nodes/EndNode";
|
|
||||||
import PhaseNode, { PhaseConnects, PhaseReduce } from "./nodes/PhaseNode";
|
|
||||||
import NormNode, { NormConnects, NormReduce } from "./nodes/NormNode";
|
|
||||||
import { EndNodeDefaults } from "./nodes/EndNode.default";
|
|
||||||
import { StartNodeDefaults } from "./nodes/StartNode.default";
|
|
||||||
import { PhaseNodeDefaults } from "./nodes/PhaseNode.default";
|
|
||||||
import { NormNodeDefaults } from "./nodes/NormNode.default";
|
|
||||||
import GoalNode, { GoalConnects, GoalReduce } from "./nodes/GoalNode";
|
|
||||||
import { GoalNodeDefaults } from "./nodes/GoalNode.default";
|
|
||||||
import TriggerNode, { TriggerConnects, TriggerReduce } from "./nodes/TriggerNode";
|
|
||||||
import { TriggerNodeDefaults } from "./nodes/TriggerNode.default";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registered node types in the visual programming system.
|
|
||||||
*
|
|
||||||
* Key: the node type string used in the flow graph.
|
|
||||||
* Value: the corresponding React component for rendering the node.
|
|
||||||
*/
|
|
||||||
export const NodeTypes = {
|
|
||||||
start: StartNode,
|
|
||||||
end: EndNode,
|
|
||||||
phase: PhaseNode,
|
|
||||||
norm: NormNode,
|
|
||||||
goal: GoalNode,
|
|
||||||
trigger: TriggerNode,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default data and settings for each node type.
|
|
||||||
* These are defined in the <node>.default.ts files.
|
|
||||||
* These defaults are used when a new node is created to initialize its properties.
|
|
||||||
*/
|
|
||||||
export const NodeDefaults = {
|
|
||||||
start: StartNodeDefaults,
|
|
||||||
end: EndNodeDefaults,
|
|
||||||
phase: PhaseNodeDefaults,
|
|
||||||
norm: NormNodeDefaults,
|
|
||||||
goal: GoalNodeDefaults,
|
|
||||||
trigger: TriggerNodeDefaults,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reduce functions for each node type.
|
|
||||||
*
|
|
||||||
* A reduce function extracts the relevant data from a node and its children.
|
|
||||||
* Used during graph evaluation or export.
|
|
||||||
*/
|
|
||||||
export const NodeReduces = {
|
|
||||||
start: StartReduce,
|
|
||||||
end: EndReduce,
|
|
||||||
phase: PhaseReduce,
|
|
||||||
norm: NormReduce,
|
|
||||||
goal: GoalReduce,
|
|
||||||
trigger: TriggerReduce,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connection functions for each node type.
|
|
||||||
*
|
|
||||||
* These functions define how nodes of a particular type can connect to other nodes.
|
|
||||||
*/
|
|
||||||
export const NodeConnects = {
|
|
||||||
start: StartConnects,
|
|
||||||
end: EndConnects,
|
|
||||||
phase: PhaseConnects,
|
|
||||||
norm: NormConnects,
|
|
||||||
goal: GoalConnects,
|
|
||||||
trigger: TriggerConnects,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Defines whether a node type can be deleted.
|
|
||||||
*
|
|
||||||
* Returns a function per node type. Nodes not explicitly listed are deletable by default.
|
|
||||||
*/
|
|
||||||
export const NodeDeletes = {
|
|
||||||
start: () => false,
|
|
||||||
end: () => false,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Defines which node types are considered variables in a phase node.
|
|
||||||
*
|
|
||||||
* Any node type not listed here is automatically treated as part of a phase.
|
|
||||||
* This allows the system to dynamically group nodes under a phase node.
|
|
||||||
*/
|
|
||||||
export const NodesInPhase = {
|
|
||||||
start: () => false,
|
|
||||||
end: () => false,
|
|
||||||
phase: () => false,
|
|
||||||
}
|
|
||||||
@@ -1,172 +1,142 @@
|
|||||||
import { create } from 'zustand';
|
import {create} from 'zustand';
|
||||||
import {
|
import {
|
||||||
applyNodeChanges,
|
applyNodeChanges,
|
||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
addEdge,
|
addEdge,
|
||||||
reconnectEdge,
|
reconnectEdge, type Edge, type Connection
|
||||||
type Node,
|
|
||||||
type Edge,
|
|
||||||
type XYPosition,
|
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import type { FlowState } from './VisProgTypes';
|
|
||||||
import { NodeDefaults, NodeConnects, NodeDeletes } from './NodeRegistry';
|
|
||||||
|
|
||||||
|
import {type AppNode, type FlowState} from './VisProgTypes.tsx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Function to create a new node with the correct default data and properties.
|
* contains the nodes that are created when the editor is loaded,
|
||||||
*
|
* 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.
|
|
||||||
*/
|
*/
|
||||||
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable? : boolean) {
|
const initialNodes = [
|
||||||
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
|
{
|
||||||
const newData = {
|
id: 'start',
|
||||||
id: id,
|
type: 'start',
|
||||||
type: type,
|
position: {x: 0, y: 0},
|
||||||
position: position,
|
data: {label: 'start'}
|
||||||
data: data,
|
},
|
||||||
deletable: deletable,
|
{
|
||||||
|
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'}
|
||||||
}
|
}
|
||||||
return {...defaultData, ...newData}
|
|
||||||
}
|
|
||||||
|
|
||||||
//* Initial nodes to populate the flow at startup.
|
|
||||||
const initialNodes : Node[] = [
|
|
||||||
createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false),
|
|
||||||
createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false),
|
|
||||||
createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children : []}),
|
|
||||||
createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
//* Initial edges to connect the startup nodes.
|
|
||||||
const initialEdges: Edge[] = [
|
|
||||||
{ id: 'start-phase-1', source: 'start', target: 'phase-1' },
|
|
||||||
{ id: 'phase-1-end', source: 'phase-1', target: 'end' },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How we have defined the functions for our FlowState.
|
* contains the initial edges that are created when the editor is loaded
|
||||||
* We have the normal functionality of a default FlowState with some exceptions to account for extra functionality.
|
*/
|
||||||
* The biggest changes are in onConnect and onDelete, which we have given extra functionality based on the nodes defined functions.
|
const initialEdges = [
|
||||||
*
|
{
|
||||||
* * Provides:
|
id: 'start-phase-1',
|
||||||
* - Node and edge state management
|
source: 'start',
|
||||||
* - Node creation, deletion, and updates
|
target: 'phase-1',
|
||||||
* - Custom connection handling via NodeConnects
|
},
|
||||||
* - Edge reconnection handling
|
{
|
||||||
|
id: 'phase-1-end',
|
||||||
|
source: 'phase-1',
|
||||||
|
target: 'end',
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The useFlowStore hook contains the implementation for editor functionality and state
|
||||||
|
* we can use this inside our editor component to access the current state
|
||||||
|
* and use any implemented functionality
|
||||||
*/
|
*/
|
||||||
const useFlowStore = create<FlowState>((set, get) => ({
|
const useFlowStore = create<FlowState>((set, get) => ({
|
||||||
nodes: initialNodes,
|
nodes: initialNodes,
|
||||||
edges: initialEdges,
|
edges: initialEdges,
|
||||||
edgeReconnectSuccessful: true,
|
edgeReconnectSuccessful: true,
|
||||||
|
onNodesChange: (changes) => {
|
||||||
/**
|
|
||||||
* Handles changes to nodes triggered by ReactFlow.
|
|
||||||
*/
|
|
||||||
onNodesChange: (changes) =>
|
|
||||||
set({nodes: applyNodeChanges(changes, get().nodes)}),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles changes to edges triggered by ReactFlow.
|
|
||||||
*/
|
|
||||||
onEdgesChange: (changes) => set({ edges: applyEdgeChanges(changes, get().edges) }),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles creating a new connection between nodes.
|
|
||||||
* Updates edges and calls the node-specific connection functions.
|
|
||||||
*/
|
|
||||||
onConnect: (connection) => {
|
|
||||||
const edges = addEdge(connection, get().edges);
|
|
||||||
const nodes = get().nodes;
|
|
||||||
// connection has: { source, sourceHandle, target, targetHandle }
|
|
||||||
// Let's find the source and target ID's.
|
|
||||||
const sourceNode = nodes.find((n) => n.id == connection.source);
|
|
||||||
const targetNode = nodes.find((n) => n.id == connection.target);
|
|
||||||
|
|
||||||
// In case the nodes weren't found, return basic functionality.
|
|
||||||
if (sourceNode == undefined || targetNode == undefined || sourceNode.type == undefined || targetNode.type == undefined) {
|
|
||||||
set({ nodes, edges });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We should find out how their data changes by calling their respective functions.
|
|
||||||
const sourceConnectFunction = NodeConnects[sourceNode.type as keyof typeof NodeConnects]
|
|
||||||
const targetConnectFunction = NodeConnects[targetNode.type as keyof typeof NodeConnects]
|
|
||||||
|
|
||||||
// We're going to have to update their data based on how they want to update it.
|
|
||||||
sourceConnectFunction(sourceNode, targetNode, true)
|
|
||||||
targetConnectFunction(targetNode, sourceNode, false)
|
|
||||||
set({ nodes, edges });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles reconnecting an edge between nodes.
|
|
||||||
*/
|
|
||||||
onReconnect: (oldEdge, newConnection) => {
|
|
||||||
get().edgeReconnectSuccessful = true;
|
|
||||||
set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) });
|
|
||||||
},
|
|
||||||
|
|
||||||
onReconnectStart: () => set({ edgeReconnectSuccessful: false }),
|
|
||||||
onReconnectEnd: (_evt, edge) => {
|
|
||||||
if (!get().edgeReconnectSuccessful) {
|
|
||||||
set({ edges: get().edges.filter((e) => e.id !== edge.id) });
|
|
||||||
}
|
|
||||||
set({ edgeReconnectSuccessful: true });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a node by ID, respecting NodeDeletes rules.
|
|
||||||
* Also removes all edges connected to that node.
|
|
||||||
*/
|
|
||||||
deleteNode: (nodeId) => {
|
|
||||||
// Let's find our node to check if they have a special deletion function
|
|
||||||
const ourNode = get().nodes.find((n)=>n.id==nodeId);
|
|
||||||
const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1]
|
|
||||||
|
|
||||||
// If there's no function, OR, our function tells us we can delete it, let's do so...
|
|
||||||
if (ourFunction == undefined || ourFunction()) {
|
|
||||||
set({
|
|
||||||
nodes: get().nodes.filter((n) => n.id !== nodeId),
|
|
||||||
edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
|
|
||||||
})}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces the entire nodes array in the store.
|
|
||||||
*/
|
|
||||||
setNodes: (nodes) => set({ nodes }),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces the entire edges array in the store.
|
|
||||||
*/
|
|
||||||
setEdges: (edges) => set({ edges }),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the data of a node by merging new data with existing data.
|
|
||||||
*/
|
|
||||||
updateNodeData: (nodeId, data) => {
|
|
||||||
set({
|
set({
|
||||||
nodes: get().nodes.map((node) => {
|
nodes: applyNodeChanges(changes, get().nodes)
|
||||||
if (node.id === nodeId) {
|
|
||||||
node = { ...node, data: { ...node.data, ...data }};
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onEdgesChange: (changes) => {
|
||||||
/**
|
set({
|
||||||
* Adds a new node to the flow store.
|
edges: applyEdgeChanges(changes, get().edges)
|
||||||
*/
|
});
|
||||||
addNode: (node: Node) => {
|
|
||||||
set({ nodes: [...get().nodes, node] });
|
|
||||||
},
|
},
|
||||||
}));
|
// 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});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* handles updating the data component of a node,
|
||||||
|
* if the provided data object contains entries that aren't present in the updated node's data component
|
||||||
|
* those entries are added to the data component,
|
||||||
|
* entries that do exist within the node's data component,
|
||||||
|
* are simply updated to contain the new value
|
||||||
|
*
|
||||||
|
* the data object
|
||||||
|
* @param {string} nodeId
|
||||||
|
* @param {object} data
|
||||||
|
*/
|
||||||
|
updateNodeData: (nodeId: string, data) => {
|
||||||
|
set({
|
||||||
|
nodes: get().nodes.map((node) : AppNode => {
|
||||||
|
if (node.id === nodeId) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
...data
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else { return node; }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export default useFlowStore;
|
export default useFlowStore;
|
||||||
@@ -1,77 +1,50 @@
|
|||||||
// VisProgTypes.ts
|
import {
|
||||||
import type { Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node } from '@xyflow/react';
|
type Edge,
|
||||||
import type { NodeTypes } from './NodeRegistry';
|
type Node,
|
||||||
|
type OnNodesChange,
|
||||||
|
type OnEdgesChange,
|
||||||
|
type OnConnect,
|
||||||
|
type OnReconnect,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import type {TriggerNodeProps} from "./components/TriggerNodeComponent.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
type defaultNodeData = {
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OurNode<T> = Node<T & defaultNodeData>;
|
||||||
|
|
||||||
|
export type StartNode = Node<defaultNodeData, 'start'>;
|
||||||
|
export type EndNode = Node<defaultNodeData, 'end'>;
|
||||||
|
export type GoalNode = Node<defaultNodeData & { description: string; achieved: boolean; }, 'goal'>;
|
||||||
|
export type NormNode = Node<defaultNodeData & { value: string; }, 'norm'>;
|
||||||
|
export type PhaseNode = Node<defaultNodeData & { number: number; }, 'phase'>;
|
||||||
|
export type TriggerNode = OurNode<TriggerNodeProps>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type representing all registered node types.
|
* a type meant to house different node types, currently not used
|
||||||
* This corresponds to the keys of NodeTypes in NodeRegistry.
|
* but will allow us to more clearly define nodeTypes when we implement
|
||||||
|
* computation of the Graph inside the ReactFlow editor
|
||||||
*/
|
*/
|
||||||
export type AppNode = typeof NodeTypes;
|
export type AppNode = Node | StartNode | EndNode | NormNode | GoalNode | PhaseNode;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The FlowState type defines the shape of the Zustand store used for managing the visual programming flow.
|
* The type for the Zustand store object used to manage the state of the ReactFlow editor
|
||||||
*
|
|
||||||
* 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: Node[];
|
nodes: AppNode[];
|
||||||
edges: Edge[];
|
edges: Edge[];
|
||||||
edgeReconnectSuccessful: boolean;
|
edgeReconnectSuccessful: boolean;
|
||||||
|
|
||||||
/** Handler for changes to nodes triggered by ReactFlow */
|
|
||||||
onNodesChange: OnNodesChange;
|
onNodesChange: OnNodesChange;
|
||||||
|
|
||||||
/** 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;
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when an edge reconnect process ends.
|
|
||||||
* @param _ - event or unused parameter
|
|
||||||
* @param edge - the edge that finished reconnecting
|
|
||||||
*/
|
|
||||||
onReconnectEnd: (_: unknown, edge: { id: string }) => void;
|
onReconnectEnd: (_: unknown, edge: { id: string }) => void;
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a node and any connected edges.
|
|
||||||
* @param nodeId - the ID of the node to delete
|
|
||||||
*/
|
|
||||||
deleteNode: (nodeId: string) => void;
|
deleteNode: (nodeId: string) => void;
|
||||||
|
setNodes: (nodes: AppNode[]) => void;
|
||||||
/**
|
|
||||||
* Replaces the current nodes array in the store.
|
|
||||||
* @param nodes - new array of nodes
|
|
||||||
*/
|
|
||||||
setNodes: (nodes: Node[]) => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces the current edges array in the store.
|
|
||||||
* @param edges - new array of edges
|
|
||||||
*/
|
|
||||||
setEdges: (edges: Edge[]) => void;
|
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;
|
updateNodeData: (nodeId: string, data: object) => void;
|
||||||
|
};
|
||||||
/**
|
|
||||||
* Adds a new node to the flow.
|
|
||||||
* @param node - the Node object to add
|
|
||||||
*/
|
|
||||||
addNode: (node: Node) => void;
|
|
||||||
};
|
|
||||||
@@ -1,48 +1,61 @@
|
|||||||
import { useDraggable } from '@neodrag/react';
|
import {useDraggable} from '@neodrag/react';
|
||||||
import { useReactFlow, type XYPosition } from '@xyflow/react';
|
import {
|
||||||
import { type ReactNode, useCallback, useRef, useState } from 'react';
|
useReactFlow,
|
||||||
import useFlowStore from '../VisProgStores';
|
type XYPosition
|
||||||
import styles from '../../VisProg.module.css';
|
} from '@xyflow/react';
|
||||||
import { NodeDefaults, type NodeTypes } from '../NodeRegistry'
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from 'react';
|
||||||
|
import useFlowStore from "../VisProgStores.tsx";
|
||||||
|
import styles from "../../VisProg.module.css"
|
||||||
|
import type {AppNode, PhaseNode, NormNode, GoalNode, TriggerNode} from "../VisProgTypes.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for a draggable node within the drag-and-drop toolbar.
|
* DraggableNodeProps dictates the type properties of a DraggableNode
|
||||||
*
|
|
||||||
* @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: keyof typeof NodeTypes;
|
nodeType: string;
|
||||||
onDrop: (nodeType: keyof typeof NodeTypes, position: XYPosition) => void;
|
onDrop: (nodeType: string, position: XYPosition) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A draggable node element used in the drag-and-drop toolbar.
|
* Definition of a node inside the drag and drop toolbar,
|
||||||
|
* these nodes require an onDrop function to be supplied
|
||||||
|
* that dictates how the node is created in the graph.
|
||||||
*
|
*
|
||||||
* Integrates with the NeoDrag library to handle drag events.
|
* @param className
|
||||||
* On drop, it calls the provided `onDrop` function with the node type and drop position.
|
* @param children
|
||||||
*
|
* @param nodeType
|
||||||
* @param props - The draggable node configuration.
|
* @param onDrop
|
||||||
* @returns A React element representing a draggable node.
|
* @constructor
|
||||||
*/
|
*/
|
||||||
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});
|
||||||
|
|
||||||
// The NeoDrag hook enables smooth drag functionality for this element.
|
// @ts-expect-error comes from a package and doesn't appear to play nicely with strict typescript typing
|
||||||
// @ts-expect-error: NeoDrag typing incompatibility — safe to ignore.
|
|
||||||
useDraggable(draggableRef, {
|
useDraggable(draggableRef, {
|
||||||
position,
|
position: position,
|
||||||
onDrag: ({ offsetX, offsetY }) => {
|
onDrag: ({offsetX, offsetY}) => {
|
||||||
setPosition({ x: offsetX, y: offsetY });
|
// Calculate position relative to the viewport
|
||||||
|
setPosition({
|
||||||
|
x: offsetX,
|
||||||
|
y: offsetY,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onDragEnd: ({ event }) => {
|
onDragEnd: ({event}) => {
|
||||||
setPosition({ x: 0, y: 0 });
|
setPosition({x: 0, y: 0});
|
||||||
onDrop(nodeType, { x: event.clientX, y: event.clientY });
|
onDrop(nodeType, {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,69 +66,111 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a new node to the flow graph.
|
|
||||||
*
|
|
||||||
* Handles:
|
|
||||||
* - Automatic node ID generation based on existing nodes of the same type.
|
|
||||||
* - Loading of default data from the `NodeDefaults` registry.
|
|
||||||
* - Integration with the flow store to update global node state.
|
|
||||||
*
|
|
||||||
* @param nodeType - The type of node to create (from `NodeTypes`).
|
|
||||||
* @param position - The XY position in the flow canvas where the node will appear.
|
|
||||||
*/
|
|
||||||
function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) {
|
|
||||||
const { nodes, setNodes } = useFlowStore.getState();
|
|
||||||
|
|
||||||
// Load any predefined data for this node type.
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const defaultData = NodeDefaults[nodeType] ?? {}
|
export function addNode(nodeType: string, position: XYPosition) {
|
||||||
|
const {setNodes} = useFlowStore.getState();
|
||||||
// Currently, we find out what the Id is by checking the last node and adding one.
|
const nds : AppNode[] = useFlowStore.getState().nodes;
|
||||||
const sameTypeNodes = nodes.filter((node) => node.type === nodeType);
|
const newNode = () => {
|
||||||
const nextNumber =
|
switch (nodeType) {
|
||||||
sameTypeNodes.length > 0
|
case "phase":
|
||||||
? (() => {
|
{
|
||||||
const lastNode = sameTypeNodes[sameTypeNodes.length - 1];
|
const phaseNodes= nds.filter((node) => node.type === 'phase');
|
||||||
const parts = lastNode.id.split('-');
|
let phaseNumber;
|
||||||
const lastNum = Number(parts[1]);
|
if (phaseNodes.length > 0) {
|
||||||
return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1;
|
const finalPhaseId : number = +(phaseNodes[phaseNodes.length - 1].id.split('-')[1]);
|
||||||
})()
|
phaseNumber = finalPhaseId + 1;
|
||||||
: 1;
|
} else {
|
||||||
const id = `${nodeType}-${nextNumber}`;
|
phaseNumber = 1;
|
||||||
|
}
|
||||||
// Create new node
|
const phaseNode : PhaseNode = {
|
||||||
const newNode = {
|
id: `phase-${phaseNumber}`,
|
||||||
id: id,
|
type: nodeType,
|
||||||
type: nodeType,
|
position,
|
||||||
position,
|
data: {label: 'new', number: phaseNumber},
|
||||||
data: {...defaultData}
|
}
|
||||||
|
return phaseNode;
|
||||||
|
}
|
||||||
|
case "norm":
|
||||||
|
{
|
||||||
|
const normNodes= nds.filter((node) => node.type === 'norm');
|
||||||
|
let normNumber
|
||||||
|
if (normNodes.length > 0) {
|
||||||
|
const finalNormId : number = +(normNodes[normNodes.length - 1].id.split('-')[1]);
|
||||||
|
normNumber = finalNormId + 1;
|
||||||
|
} else {
|
||||||
|
normNumber = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normNode : NormNode = {
|
||||||
|
id: `norm-${normNumber}`,
|
||||||
|
type: nodeType,
|
||||||
|
position,
|
||||||
|
data: {label: `new norm node`, value: ""},
|
||||||
|
}
|
||||||
|
return normNode;
|
||||||
|
}
|
||||||
|
case "goal":
|
||||||
|
{
|
||||||
|
const goalNodes= nds.filter((node) => node.type === 'goal');
|
||||||
|
let goalNumber
|
||||||
|
if (goalNodes.length > 0) {
|
||||||
|
const finalGoalId : number = +(goalNodes[goalNodes.length - 1].id.split('-')[1]);
|
||||||
|
goalNumber = finalGoalId + 1;
|
||||||
|
} else {
|
||||||
|
goalNumber = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const goalNode : GoalNode = {
|
||||||
|
id: `goal-${goalNumber}`,
|
||||||
|
type: nodeType,
|
||||||
|
position,
|
||||||
|
data: {label: `new goal node`, description: "", achieved: false},
|
||||||
|
}
|
||||||
|
return goalNode;
|
||||||
|
}
|
||||||
|
case "trigger":
|
||||||
|
{
|
||||||
|
const triggerNodes= nds.filter((node) => node.type === 'trigger');
|
||||||
|
let triggerNumber
|
||||||
|
if (triggerNodes.length > 0) {
|
||||||
|
const finalGoalId : number = +(triggerNodes[triggerNodes.length - 1].id.split('-')[1]);
|
||||||
|
triggerNumber = finalGoalId + 1;
|
||||||
|
} else {
|
||||||
|
triggerNumber = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerNode : TriggerNode = {
|
||||||
|
id: `trigger-${triggerNumber}`,
|
||||||
|
type: nodeType,
|
||||||
|
position,
|
||||||
|
data: {label: `new trigger node`, type: "keywords", value: []},
|
||||||
|
}
|
||||||
|
return triggerNode;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new Error(`Node ${nodeType} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setNodes([...nodes, newNode]);
|
|
||||||
|
setNodes(nds.concat(newNode()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The drag-and-drop toolbar component for the visual programming interface.
|
* the DndToolbar defines how the drag and drop toolbar component works
|
||||||
*
|
* and includes the default onDrop behavior through handleNodeDrop
|
||||||
* Displays draggable node templates based on entries in `NodeDefaults`.
|
* @constructor
|
||||||
* 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();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles dropping a node onto the flow pane.
|
* handleNodeDrop implements the default onDrop behavior
|
||||||
* Translates screen coordinates into flow coordinates using React Flow utilities.
|
|
||||||
*/
|
*/
|
||||||
const handleNodeDrop = useCallback(
|
const handleNodeDrop = useCallback(
|
||||||
(nodeType: keyof typeof NodeTypes, screenPosition: XYPosition) => {
|
(nodeType: string, 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 &&
|
||||||
@@ -123,6 +178,7 @@ 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);
|
addNode(nodeType, position);
|
||||||
@@ -131,32 +187,25 @@ export function DndToolbar() {
|
|||||||
[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}`}>
|
||||||
<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}`}>
|
||||||
{/* Maps over all the nodes that are droppable, and puts them in the panel */}
|
<DraggableNode className={styles.draggableNodePhase} nodeType="phase" onDrop={handleNodeDrop}>
|
||||||
{droppableNodes.map(({type, data}) => (
|
phase Node
|
||||||
<DraggableNode
|
</DraggableNode>
|
||||||
className={styles[`draggable-node-${type}`]} // Our current style signature for nodes
|
<DraggableNode className={styles.draggableNodeNorm} nodeType="norm" onDrop={handleNodeDrop}>
|
||||||
nodeType={type}
|
norm Node
|
||||||
onDrop={handleNodeDrop}
|
</DraggableNode>
|
||||||
>
|
<DraggableNode className={styles.draggableNodeGoal} nodeType="goal" onDrop={handleNodeDrop}>
|
||||||
{data.label}
|
goal Node
|
||||||
</DraggableNode>
|
</DraggableNode>
|
||||||
))}
|
<DraggableNode className={styles.draggableNodeTrigger} nodeType="trigger" onDrop={handleNodeDrop}>
|
||||||
|
trigger Node
|
||||||
|
</DraggableNode>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { NodeToolbar } from '@xyflow/react';
|
|
||||||
import '@xyflow/react/dist/style.css';
|
|
||||||
import useFlowStore from "../VisProgStores.tsx";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the Toolbar component.
|
|
||||||
*
|
|
||||||
* @property nodeId - The ID of the node this toolbar is attached to.
|
|
||||||
* @property allowDelete - If `true`, the delete button is enabled; otherwise disabled.
|
|
||||||
*/
|
|
||||||
type ToolbarProps = {
|
|
||||||
nodeId: string;
|
|
||||||
allowDelete: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Node Toolbar definition:
|
|
||||||
* Handles: node deleting functionality
|
|
||||||
* Can be integrated to any custom node component as a React component
|
|
||||||
*
|
|
||||||
* @param {string} nodeId - The ID of the node for which the toolbar is rendered.
|
|
||||||
* @param {boolean} allowDelete - Enables or disables the delete functionality.
|
|
||||||
* @returns {React.JSX.Element} A JSX element representing the toolbar.
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
|
|
||||||
const {deleteNode} = useFlowStore();
|
|
||||||
|
|
||||||
const deleteParentNode = ()=> {
|
|
||||||
deleteNode(nodeId);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<NodeToolbar>
|
|
||||||
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
|
|
||||||
</NodeToolbar>);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import {
|
||||||
|
Handle,
|
||||||
|
type NodeProps,
|
||||||
|
NodeToolbar,
|
||||||
|
Position
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import styles from '../../VisProg.module.css';
|
||||||
|
import useFlowStore from "../VisProgStores.tsx";
|
||||||
|
import type {
|
||||||
|
StartNode,
|
||||||
|
EndNode,
|
||||||
|
PhaseNode,
|
||||||
|
NormNode, GoalNode
|
||||||
|
} from "../VisProgTypes.tsx";
|
||||||
|
import {TextField} from "../../../../components/TextField.tsx";
|
||||||
|
|
||||||
|
//Toolbar definitions
|
||||||
|
|
||||||
|
type ToolbarProps = {
|
||||||
|
nodeId: string;
|
||||||
|
allowDelete: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node Toolbar definition:
|
||||||
|
* handles: node deleting functionality
|
||||||
|
* can be added to any custom node component as a React component
|
||||||
|
*
|
||||||
|
* @param {string} nodeId
|
||||||
|
* @param {boolean} allowDelete
|
||||||
|
* @returns {React.JSX.Element}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
|
||||||
|
const {deleteNode} = useFlowStore();
|
||||||
|
|
||||||
|
const deleteParentNode = ()=> {
|
||||||
|
deleteNode(nodeId);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<NodeToolbar>
|
||||||
|
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
|
||||||
|
</NodeToolbar>);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Definitions of Nodes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start Node definition:
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {defaultNodeData} data
|
||||||
|
* @returns {React.JSX.Element}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export const StartNodeComponent = ({id, data}: NodeProps<StartNode>) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toolbar nodeId={id} allowDelete={false}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeStart}`}>
|
||||||
|
<div> data test {data.label} </div>
|
||||||
|
<Handle type="source" position={Position.Right} id="start"/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End node definition:
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {defaultNodeData} data
|
||||||
|
* @returns {React.JSX.Element}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export const EndNodeComponent = ({id, data}: NodeProps<EndNode>) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toolbar nodeId={id} allowDelete={false}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeEnd}`}>
|
||||||
|
<div> {data.label} </div>
|
||||||
|
<Handle type="target" position={Position.Left} id="end"/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase node definition:
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {defaultNodeData & {number: number}} data
|
||||||
|
* @returns {React.JSX.Element}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export const PhaseNodeComponent = ({id, data}: NodeProps<PhaseNode>) => {
|
||||||
|
const {updateNodeData} = useFlowStore();
|
||||||
|
|
||||||
|
const updateLabel = (value: string) => updateNodeData(id, {...data, label: value});
|
||||||
|
|
||||||
|
const label_input_id = `phase_${id}_label_input`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toolbar nodeId={id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodePhase}`}>
|
||||||
|
<div className={"flex-row gap-sm"}>
|
||||||
|
<label htmlFor={label_input_id}>Name:</label>
|
||||||
|
<TextField
|
||||||
|
id={label_input_id}
|
||||||
|
value={data.label}
|
||||||
|
setValue={updateLabel}
|
||||||
|
placeholder={"Phase ..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Handle type="target" position={Position.Left} id="target"/>
|
||||||
|
<Handle type="target" position={Position.Bottom} id="norms"/>
|
||||||
|
<Handle type="source" position={Position.Right} id="source"/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Norm node definition:
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {defaultNodeData & {value: string}} data
|
||||||
|
*/
|
||||||
|
export const NormNodeComponent = ({id, data}: NodeProps<NormNode>) => {
|
||||||
|
const {updateNodeData} = useFlowStore();
|
||||||
|
|
||||||
|
const text_input_id = `norm_${id}_text_input`;
|
||||||
|
|
||||||
|
const setValue = (value: string) => {
|
||||||
|
updateNodeData(id, {value: value});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Toolbar nodeId={id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
|
||||||
|
<div className={"flex-row gap-sm"}>
|
||||||
|
<label htmlFor={text_input_id}>Norm:</label>
|
||||||
|
<TextField
|
||||||
|
id={text_input_id}
|
||||||
|
value={data.value}
|
||||||
|
setValue={(val) => setValue(val)}
|
||||||
|
placeholder={"Pepper should ..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Right} id="NormSource"/>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GoalNodeComponent = ({id, data}: NodeProps<GoalNode>) => {
|
||||||
|
const {updateNodeData} = useFlowStore();
|
||||||
|
|
||||||
|
const text_input_id = `goal_${id}_text_input`;
|
||||||
|
const checkbox_id = `goal_${id}_checkbox`;
|
||||||
|
|
||||||
|
const setDescription = (value: string) => {
|
||||||
|
updateNodeData(id, {...data, description: value});
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAchieved = (value: boolean) => {
|
||||||
|
updateNodeData(id, {...data, achieved: value});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Toolbar nodeId={id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeGoal} flex-col gap-sm`}>
|
||||||
|
<div className={"flex-row gap-md"}>
|
||||||
|
<label htmlFor={text_input_id}>Goal:</label>
|
||||||
|
<TextField
|
||||||
|
id={text_input_id}
|
||||||
|
value={data.description}
|
||||||
|
setValue={(val) => setDescription(val)}
|
||||||
|
placeholder={"To ..."}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={"flex-row gap-md align-center"}>
|
||||||
|
<label htmlFor={checkbox_id}>Achieved:</label>
|
||||||
|
<input
|
||||||
|
id={checkbox_id}
|
||||||
|
type={"checkbox"}
|
||||||
|
value={data.achieved ? "checked" : ""}
|
||||||
|
onChange={(e) => setAchieved(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Right} id="GoalSource"/>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
.save-load-panel {
|
|
||||||
border-radius: 0 0 5pt 5pt;
|
|
||||||
background-color: canvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
label.file-input-button {
|
|
||||||
cursor: pointer;
|
|
||||||
outline: forestgreen solid 2pt;
|
|
||||||
filter: drop-shadow(0 0 0.25rem forestgreen);
|
|
||||||
transition: filter 200ms;
|
|
||||||
|
|
||||||
input[type="file"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
filter: drop-shadow(0 0 0.5rem forestgreen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-button {
|
|
||||||
text-decoration: none;
|
|
||||||
outline: dodgerblue solid 2pt;
|
|
||||||
filter: drop-shadow(0 0 0.25rem dodgerblue);
|
|
||||||
transition: filter 200ms;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
filter: drop-shadow(0 0 0.5rem dodgerblue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import {type ChangeEvent, useRef, useState} from "react";
|
|
||||||
import useFlowStore from "../VisProgStores";
|
|
||||||
import visProgStyles from "../../VisProg.module.css";
|
|
||||||
import styles from "./SaveLoadPanel.module.css";
|
|
||||||
import { makeProjectBlob, type SavedProject } from "../../../../utils/SaveLoad";
|
|
||||||
|
|
||||||
export default function SaveLoadPanel() {
|
|
||||||
const nodes = useFlowStore((s) => s.nodes);
|
|
||||||
const edges = useFlowStore((s) => s.edges);
|
|
||||||
const setNodes = useFlowStore((s) => s.setNodes);
|
|
||||||
const setEdges = useFlowStore((s) => s.setEdges);
|
|
||||||
|
|
||||||
const [saveUrl, setSaveUrl] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// ref to the file input
|
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
const onSave = async (nameGuess = "visual-program") => {
|
|
||||||
const blob = makeProjectBlob(nameGuess, nodes, edges);
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
setSaveUrl(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
// input change handler updates the graph with a parsed JSON file
|
|
||||||
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
try {
|
|
||||||
const text = await file.text();
|
|
||||||
const parsed = JSON.parse(text) as SavedProject;
|
|
||||||
if (!parsed.nodes || !parsed.edges) throw new Error("Invalid file format");
|
|
||||||
setNodes(parsed.nodes);
|
|
||||||
setEdges(parsed.edges);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
alert("Loading failed. See console.");
|
|
||||||
} finally {
|
|
||||||
// allow re-selecting same file next time
|
|
||||||
if (inputRef.current) inputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultName = "visual-program";
|
|
||||||
return (
|
|
||||||
<div className={`flex-col gap-lg padding-md border-lg ${styles.saveLoadPanel}`}>
|
|
||||||
<div className="description">You can save and load your graph here.</div>
|
|
||||||
<div className={`flex-row gap-lg justify-center`}>
|
|
||||||
<a
|
|
||||||
href={saveUrl ?? "#"}
|
|
||||||
onClick={() => onSave(defaultName)}
|
|
||||||
download={`${defaultName}.json`}
|
|
||||||
className={`${visProgStyles.draggableNode} ${styles.saveButton}`}
|
|
||||||
>
|
|
||||||
Save Graph
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<label className={`${visProgStyles.draggableNode} ${styles.fileInputButton}`}>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".visprog.json,.json,.txt,application/json,text/plain"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
Load Graph
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import {Handle, type NodeProps, Position} from "@xyflow/react";
|
||||||
|
import type {TriggerNode} from "../VisProgTypes.tsx";
|
||||||
|
import useFlowStore from "../VisProgStores.tsx";
|
||||||
|
import styles from "../../VisProg.module.css";
|
||||||
|
import {RealtimeTextField, TextField} from "../../../../components/TextField.tsx";
|
||||||
|
import {Toolbar} from "./NodeDefinitions.tsx";
|
||||||
|
import {useState} from "react";
|
||||||
|
import duplicateIndices from "../../../../utils/duplicateIndices.ts";
|
||||||
|
|
||||||
|
export type EmotionTriggerNodeProps = {
|
||||||
|
type: "emotion";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Keyword = { id: string, keyword: string };
|
||||||
|
|
||||||
|
export type KeywordTriggerNodeProps = {
|
||||||
|
type: "keywords";
|
||||||
|
value: Keyword[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;
|
||||||
|
|
||||||
|
function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
|
||||||
|
const text_input_id = "keyword_adder_input";
|
||||||
|
|
||||||
|
return <div className={"flex-row gap-md"}>
|
||||||
|
<label htmlFor={text_input_id}>New Keyword:</label>
|
||||||
|
<RealtimeTextField
|
||||||
|
id={text_input_id}
|
||||||
|
value={input}
|
||||||
|
setValue={setInput}
|
||||||
|
onCommit={() => {
|
||||||
|
if (!input) return;
|
||||||
|
addKeyword(input);
|
||||||
|
setInput("");
|
||||||
|
}}
|
||||||
|
placeholder={"..."}
|
||||||
|
className={"flex-1"}
|
||||||
|
/>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Keywords({
|
||||||
|
keywords,
|
||||||
|
setKeywords,
|
||||||
|
}: {
|
||||||
|
keywords: Keyword[];
|
||||||
|
setKeywords: (keywords: Keyword[]) => void;
|
||||||
|
}) {
|
||||||
|
type Interpolatable = string | number | boolean | bigint | null | undefined;
|
||||||
|
|
||||||
|
const inputElementId = (id: Interpolatable) => `keyword_${id}_input`;
|
||||||
|
|
||||||
|
/** Indices of duplicates in the keyword array. */
|
||||||
|
const [duplicates, setDuplicates] = useState<number[]>([]);
|
||||||
|
|
||||||
|
function replace(id: string, value: string) {
|
||||||
|
value = value.trim();
|
||||||
|
const newKeywords = value === ""
|
||||||
|
? keywords.filter((kw) => kw.id != id)
|
||||||
|
: keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw);
|
||||||
|
setKeywords(newKeywords);
|
||||||
|
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(value: string) {
|
||||||
|
value = value.trim();
|
||||||
|
if (value === "") return;
|
||||||
|
const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}];
|
||||||
|
setKeywords(newKeywords);
|
||||||
|
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<span>Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken.</span>
|
||||||
|
{[...keywords].map(({id, keyword}, index) => {
|
||||||
|
return <div key={id} className={"flex-row gap-md"}>
|
||||||
|
<label htmlFor={inputElementId(id)}>Keyword:</label>
|
||||||
|
<TextField
|
||||||
|
id={inputElementId(id)}
|
||||||
|
value={keyword}
|
||||||
|
setValue={(val) => replace(id, val)}
|
||||||
|
placeholder={"..."}
|
||||||
|
className={"flex-1"}
|
||||||
|
invalid={duplicates.includes(index)}
|
||||||
|
/>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
<KeywordAdder addKeyword={add} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TriggerNodeComponent({
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
}: NodeProps<TriggerNode>) {
|
||||||
|
const {updateNodeData} = useFlowStore();
|
||||||
|
|
||||||
|
const setKeywords = (keywords: Keyword[]) => {
|
||||||
|
updateNodeData(id, {...data, value: keywords});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Toolbar nodeId={id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
|
||||||
|
{data.type === "emotion" && (
|
||||||
|
<div className={"flex-row gap-md"}>Emotion?</div>
|
||||||
|
)}
|
||||||
|
{data.type === "keywords" && (
|
||||||
|
<Keywords
|
||||||
|
keywords={data.value}
|
||||||
|
setKeywords={setKeywords}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Handle type="source" position={Position.Right} id="TriggerSource"/>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { EndNodeData } from "./EndNode";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default data for this node.
|
|
||||||
*/
|
|
||||||
export const EndNodeDefaults: EndNodeData = {
|
|
||||||
label: "End Node",
|
|
||||||
droppable: false,
|
|
||||||
hasReduce: true
|
|
||||||
};
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import {
|
|
||||||
Handle,
|
|
||||||
type NodeProps,
|
|
||||||
Position,
|
|
||||||
type Node,
|
|
||||||
} from '@xyflow/react';
|
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
|
||||||
import styles from '../../VisProg.module.css';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The typing of this node's data
|
|
||||||
*/
|
|
||||||
export type EndNodeData = {
|
|
||||||
label: string;
|
|
||||||
droppable: boolean;
|
|
||||||
hasReduce: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EndNode = Node<EndNodeData>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default function to render an end node given its properties
|
|
||||||
* @param props the node's properties
|
|
||||||
* @returns React.JSX.Element
|
|
||||||
*/
|
|
||||||
export default function EndNode(props: NodeProps<EndNode>) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Toolbar nodeId={props.id} allowDelete={false}/>
|
|
||||||
<div className={`${styles.defaultNode} ${styles.nodeEnd}`}>
|
|
||||||
<div className={"flex-row gap-sm"}>
|
|
||||||
End
|
|
||||||
</div>
|
|
||||||
<Handle type="target" position={Position.Left} id="target"/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Functionality for reducing this node into its more compact json program
|
|
||||||
* @param node the node to reduce
|
|
||||||
* @param nodes all nodes present
|
|
||||||
* @returns Dictionary, {id: node.id}
|
|
||||||
*/
|
|
||||||
export function EndReduce(node: Node, nodes: Node[]) {
|
|
||||||
// Replace this for nodes functionality
|
|
||||||
if (nodes.length <= -1) {
|
|
||||||
console.warn("Impossible nodes length in EndReduce")
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: node.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Any connection functionality that should get called when a connection is made to this node type (end)
|
|
||||||
* @param thisNode the node of which the functionality gets called
|
|
||||||
* @param otherNode the other node which has connected
|
|
||||||
* @param isThisSource whether this node is the one that is the source of the connection
|
|
||||||
*/
|
|
||||||
export function EndConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
|
|
||||||
// Replace this for connection logic
|
|
||||||
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
|
|
||||||
console.warn("Impossible node connection called in EndConnects")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { GoalNodeData } from "./GoalNode";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default data for this node
|
|
||||||
*/
|
|
||||||
export const GoalNodeDefaults: GoalNodeData = {
|
|
||||||
label: "Goal Node",
|
|
||||||
droppable: true,
|
|
||||||
description: "The robot will strive towards this goal",
|
|
||||||
achieved: false,
|
|
||||||
hasReduce: true,
|
|
||||||
};
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import {
|
|
||||||
Handle,
|
|
||||||
type NodeProps,
|
|
||||||
Position,
|
|
||||||
type Node,
|
|
||||||
} from '@xyflow/react';
|
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
|
||||||
import styles from '../../VisProg.module.css';
|
|
||||||
import { TextField } from '../../../../components/TextField';
|
|
||||||
import useFlowStore from '../VisProgStores';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The default data dot a phase node
|
|
||||||
* @param label: the label of this phase
|
|
||||||
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
|
||||||
* @param desciption: description of the goal
|
|
||||||
* @param hasReduce: whether this node has reducing functionality (true by default)
|
|
||||||
*/
|
|
||||||
export type GoalNodeData = {
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
droppable: boolean;
|
|
||||||
achieved: boolean;
|
|
||||||
hasReduce: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GoalNode = Node<GoalNodeData>
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Defines how a Goal node should be rendered
|
|
||||||
* @param props NodeProps, like id, label, children
|
|
||||||
* @returns React.JSX.Element
|
|
||||||
*/
|
|
||||||
export default function GoalNode(props: NodeProps<GoalNode>) {
|
|
||||||
const data = props.data
|
|
||||||
const {updateNodeData} = useFlowStore();
|
|
||||||
|
|
||||||
const text_input_id = `goal_${props.id}_text_input`;
|
|
||||||
const checkbox_id = `goal_${props.id}_checkbox`;
|
|
||||||
|
|
||||||
const setDescription = (value: string) => {
|
|
||||||
updateNodeData(props.id, {...data, description: value});
|
|
||||||
}
|
|
||||||
|
|
||||||
const setAchieved = (value: boolean) => {
|
|
||||||
updateNodeData(props.id, {...data, achieved: value});
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>
|
|
||||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
|
||||||
<div className={`${styles.defaultNode} ${styles.nodeGoal} flex-col gap-sm`}>
|
|
||||||
<div className={"flex-row gap-md"}>
|
|
||||||
<label htmlFor={text_input_id}>Goal:</label>
|
|
||||||
<TextField
|
|
||||||
id={text_input_id}
|
|
||||||
value={data.description}
|
|
||||||
setValue={(val) => setDescription(val)}
|
|
||||||
placeholder={"To ..."}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={"flex-row gap-md align-center"}>
|
|
||||||
<label htmlFor={checkbox_id}>Achieved:</label>
|
|
||||||
<input
|
|
||||||
id={checkbox_id}
|
|
||||||
type={"checkbox"}
|
|
||||||
value={data.achieved ? "checked" : ""}
|
|
||||||
onChange={(e) => setAchieved(e.target.checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Handle type="source" position={Position.Right} id="GoalSource"/>
|
|
||||||
</div>
|
|
||||||
</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reduces each Goal, including its children down into its relevant data.
|
|
||||||
* @param node: The Node Properties of this node.
|
|
||||||
* @param nodes: all the nodes in the graph
|
|
||||||
*/
|
|
||||||
export function GoalReduce(node: Node, nodes: Node[]) {
|
|
||||||
// Replace this for nodes functionality
|
|
||||||
if (nodes.length <= -1) {
|
|
||||||
console.warn("Impossible nodes length in GoalReduce")
|
|
||||||
}
|
|
||||||
const data = node.data as GoalNodeData;
|
|
||||||
return {
|
|
||||||
id: node.id,
|
|
||||||
label: data.label,
|
|
||||||
description: data.description,
|
|
||||||
achieved: data.achieved,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function is called whenever a connection is made with this node type (Goal)
|
|
||||||
* @param thisNode the node of this node type which function is called
|
|
||||||
* @param otherNode the other node which was part of the connection
|
|
||||||
* @param isThisSource whether this instance of the node was the source in the connection, true = yes.
|
|
||||||
*/
|
|
||||||
export function GoalConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
|
|
||||||
// Replace this for connection logic
|
|
||||||
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
|
|
||||||
console.warn("Impossible node connection called in EndConnects")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { NormNodeData } from "./NormNode";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default data for this node
|
|
||||||
*/
|
|
||||||
export const NormNodeDefaults: NormNodeData = {
|
|
||||||
label: "Norm Node",
|
|
||||||
droppable: true,
|
|
||||||
norm: "",
|
|
||||||
hasReduce: true,
|
|
||||||
};
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import {
|
|
||||||
Handle,
|
|
||||||
type NodeProps,
|
|
||||||
Position,
|
|
||||||
type Node,
|
|
||||||
} from '@xyflow/react';
|
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
|
||||||
import styles from '../../VisProg.module.css';
|
|
||||||
import { TextField } from '../../../../components/TextField';
|
|
||||||
import useFlowStore from '../VisProgStores';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The default data dot a phase node
|
|
||||||
* @param label: the label of this phase
|
|
||||||
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
|
||||||
* @param norm: list of strings of norms for this node
|
|
||||||
* @param hasReduce: whether this node has reducing functionality (true by default)
|
|
||||||
*/
|
|
||||||
export type NormNodeData = {
|
|
||||||
label: string;
|
|
||||||
droppable: boolean;
|
|
||||||
norm: string;
|
|
||||||
hasReduce: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NormNode = Node<NormNodeData>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Defines how a Norm node should be rendered
|
|
||||||
* @param props NodeProps, like id, label, children
|
|
||||||
* @returns React.JSX.Element
|
|
||||||
*/
|
|
||||||
export default function NormNode(props: NodeProps<NormNode>) {
|
|
||||||
const data = props.data;
|
|
||||||
const {updateNodeData} = useFlowStore();
|
|
||||||
|
|
||||||
const text_input_id = `norm_${props.id}_text_input`;
|
|
||||||
|
|
||||||
const setValue = (value: string) => {
|
|
||||||
updateNodeData(props.id, {norm: value});
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>
|
|
||||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
|
||||||
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
|
|
||||||
<div className={"flex-row gap-sm"}>
|
|
||||||
<label htmlFor={text_input_id}>Norm :</label>
|
|
||||||
<TextField
|
|
||||||
id={text_input_id}
|
|
||||||
value={data.norm}
|
|
||||||
setValue={(val) => setValue(val)}
|
|
||||||
placeholder={"Pepper should ..."}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Handle type="source" position={Position.Right} id="norms"/>
|
|
||||||
</div>
|
|
||||||
</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reduces each Norm, including its children down into its relevant data.
|
|
||||||
* @param node: The Node Properties of this node.
|
|
||||||
* @param nodes: all the nodes in the graph
|
|
||||||
*/
|
|
||||||
export function NormReduce(node: Node, nodes: Node[]) {
|
|
||||||
// Replace this for nodes functionality
|
|
||||||
if (nodes.length <= -1) {
|
|
||||||
console.warn("Impossible nodes length in NormReduce")
|
|
||||||
}
|
|
||||||
const data = node.data as NormNodeData;
|
|
||||||
return {
|
|
||||||
id: node.id,
|
|
||||||
label: data.label,
|
|
||||||
norm: data.norm,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function is called whenever a connection is made with this node type (Norm)
|
|
||||||
* @param thisNode the node of this node type which function is called
|
|
||||||
* @param otherNode the other node which was part of the connection
|
|
||||||
* @param isThisSource whether this instance of the node was the source in the connection, true = yes.
|
|
||||||
*/
|
|
||||||
export function NormConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
|
|
||||||
// Replace this for connection logic
|
|
||||||
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
|
|
||||||
console.warn("Impossible node connection called in EndConnects")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { PhaseNodeData } from "./PhaseNode";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default data for this node
|
|
||||||
*/
|
|
||||||
export const PhaseNodeDefaults: PhaseNodeData = {
|
|
||||||
label: "Phase Node",
|
|
||||||
droppable: true,
|
|
||||||
children: [],
|
|
||||||
hasReduce: true,
|
|
||||||
};
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import {
|
|
||||||
Handle,
|
|
||||||
type NodeProps,
|
|
||||||
Position,
|
|
||||||
type Node,
|
|
||||||
} from '@xyflow/react';
|
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
|
||||||
import styles from '../../VisProg.module.css';
|
|
||||||
import { NodeReduces, NodesInPhase, NodeTypes } from '../NodeRegistry';
|
|
||||||
import useFlowStore from '../VisProgStores';
|
|
||||||
import { TextField } from '../../../../components/TextField';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The default data dot a phase node
|
|
||||||
* @param label: the label of this phase
|
|
||||||
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
|
||||||
* @param children: ID's of children of this node
|
|
||||||
* @param hasReduce: whether this node has reducing functionality (true by default)
|
|
||||||
*/
|
|
||||||
export type PhaseNodeData = {
|
|
||||||
label: string;
|
|
||||||
droppable: boolean;
|
|
||||||
children: string[];
|
|
||||||
hasReduce: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PhaseNode = Node<PhaseNodeData>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Defines how a phase node should be rendered
|
|
||||||
* @param props NodeProps, like id, label, children
|
|
||||||
* @returns React.JSX.Element
|
|
||||||
*/
|
|
||||||
export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
|
||||||
const data = props.data;
|
|
||||||
const {updateNodeData} = useFlowStore();
|
|
||||||
const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value});
|
|
||||||
const label_input_id = `phase_${props.id}_label_input`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
|
||||||
<div className={`${styles.defaultNode} ${styles.nodePhase}`}>
|
|
||||||
<div className={"flex-row gap-sm"}>
|
|
||||||
<label htmlFor={label_input_id}>Name:</label>
|
|
||||||
<TextField
|
|
||||||
id={label_input_id}
|
|
||||||
value={data.label}
|
|
||||||
setValue={updateLabel}
|
|
||||||
placeholder={"Phase ..."}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Handle type="target" position={Position.Left} id="target"/>
|
|
||||||
<Handle type="target" position={Position.Bottom} id="norms"/>
|
|
||||||
<Handle type="source" position={Position.Right} id="source"/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reduces each phase, including its children down into its relevant data.
|
|
||||||
* @param node the node which is being reduced
|
|
||||||
* @param nodes all the nodes currently in the flow.
|
|
||||||
* @returns A collection of all reduced nodes in this phase, starting with this phases' reduced data.
|
|
||||||
*/
|
|
||||||
export function PhaseReduce(node: Node, nodes: Node[]) {
|
|
||||||
const thisnode = node as PhaseNode;
|
|
||||||
const data = thisnode.data as PhaseNodeData;
|
|
||||||
|
|
||||||
// node typings that are not in phase
|
|
||||||
const nodesNotInPhase: string[] = Object.entries(NodesInPhase)
|
|
||||||
.filter(([, f]) => !f())
|
|
||||||
.map(([t]) => t);
|
|
||||||
|
|
||||||
// node typings that then are in phase
|
|
||||||
const nodesInPhase: string[] = Object.entries(NodeTypes)
|
|
||||||
.filter(([t]) => !nodesNotInPhase.includes(t))
|
|
||||||
.map(([t]) => t);
|
|
||||||
|
|
||||||
// children nodes
|
|
||||||
const childrenNodes = nodes.filter((node) => data.children.includes(node.id));
|
|
||||||
|
|
||||||
// Build the result object
|
|
||||||
const result: Record<string, unknown> = {
|
|
||||||
id: thisnode.id,
|
|
||||||
label: data.label,
|
|
||||||
};
|
|
||||||
|
|
||||||
nodesInPhase.forEach((type) => {
|
|
||||||
const typedChildren = childrenNodes.filter((child) => child.type == type);
|
|
||||||
const reducer = NodeReduces[type as keyof typeof NodeReduces];
|
|
||||||
if (!reducer) {
|
|
||||||
console.warn(`No reducer found for node type ${type}`);
|
|
||||||
result[type + "s"] = [];
|
|
||||||
} else {
|
|
||||||
result[type + "s"] = typedChildren.map((child) => reducer(child, nodes));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function is called whenever a connection is made with this node type (phase)
|
|
||||||
* @param thisNode the node of this node type which function is called
|
|
||||||
* @param otherNode the other node which was part of the connection
|
|
||||||
* @param isThisSource whether this instance of the node was the source in the connection, true = yes.
|
|
||||||
*/
|
|
||||||
export function PhaseConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
|
|
||||||
console.log("Connect functionality called.")
|
|
||||||
const node = thisNode as PhaseNode
|
|
||||||
const data = node.data as PhaseNodeData
|
|
||||||
if (!isThisSource)
|
|
||||||
data.children.push(otherNode.id)
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { StartNodeData } from "./StartNode";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default data for this node.
|
|
||||||
*/
|
|
||||||
export const StartNodeDefaults: StartNodeData = {
|
|
||||||
label: "Start Node",
|
|
||||||
droppable: false,
|
|
||||||
hasReduce: true
|
|
||||||
};
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import {
|
|
||||||
Handle,
|
|
||||||
type NodeProps,
|
|
||||||
Position,
|
|
||||||
type Node,
|
|
||||||
} from '@xyflow/react';
|
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
|
||||||
import styles from '../../VisProg.module.css';
|
|
||||||
|
|
||||||
|
|
||||||
export type StartNodeData = {
|
|
||||||
label: string;
|
|
||||||
droppable: boolean;
|
|
||||||
hasReduce: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export type StartNode = Node<StartNodeData>
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Defines how a Norm node should be rendered
|
|
||||||
* @param props NodeProps, like id, label, children
|
|
||||||
* @returns React.JSX.Element
|
|
||||||
*/
|
|
||||||
export default function StartNode(props: NodeProps<StartNode>) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Toolbar nodeId={props.id} allowDelete={false}/>
|
|
||||||
<div className={`${styles.defaultNode} ${styles.nodeStart}`}>
|
|
||||||
<div className={"flex-row gap-sm"}>
|
|
||||||
Start
|
|
||||||
</div>
|
|
||||||
<Handle type="source" position={Position.Right} id="source"/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The reduce function for this node type.
|
|
||||||
* @param node this node
|
|
||||||
* @param nodes all the nodes in the graph
|
|
||||||
* @returns a reduced structure of this node
|
|
||||||
*/
|
|
||||||
export function StartReduce(node: Node, nodes: Node[]) {
|
|
||||||
// Replace this for nodes functionality
|
|
||||||
if (nodes.length <= -1) {
|
|
||||||
console.warn("Impossible nodes length in StartReduce")
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: node.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function is called whenever a connection is made with this node type (start)
|
|
||||||
* @param thisNode the node of this node type which function is called
|
|
||||||
* @param otherNode the other node which was part of the connection
|
|
||||||
* @param isThisSource whether this instance of the node was the source in the connection, true = yes.
|
|
||||||
*/
|
|
||||||
export function StartConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
|
|
||||||
// Replace this for connection logic
|
|
||||||
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
|
|
||||||
console.warn("Impossible node connection called in EndConnects")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { TriggerNodeData } from "./TriggerNode";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default data for this node
|
|
||||||
*/
|
|
||||||
export const TriggerNodeDefaults: TriggerNodeData = {
|
|
||||||
label: "Trigger Node",
|
|
||||||
droppable: true,
|
|
||||||
triggers: [],
|
|
||||||
triggerType: "keywords",
|
|
||||||
hasReduce: true,
|
|
||||||
};
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
import {
|
|
||||||
Handle,
|
|
||||||
type NodeProps,
|
|
||||||
Position,
|
|
||||||
type Connection,
|
|
||||||
type Edge,
|
|
||||||
type Node,
|
|
||||||
} from '@xyflow/react';
|
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
|
||||||
import styles from '../../VisProg.module.css';
|
|
||||||
import useFlowStore from '../VisProgStores';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { RealtimeTextField, TextField } from '../../../../components/TextField';
|
|
||||||
import duplicateIndices from '../../../../utils/duplicateIndices';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The default data structure for a Trigger node
|
|
||||||
*
|
|
||||||
* Represents configuration for a node that activates when a specific condition is met,
|
|
||||||
* such as keywords being spoken or emotions detected.
|
|
||||||
*
|
|
||||||
* @property label: the display label of this Trigger node.
|
|
||||||
* @property droppable: Whether this node can be dropped from the toolbar (default: true).
|
|
||||||
* @property triggerType - The type of trigger ("keywords" or a custom string).
|
|
||||||
* @property triggers - The list of keyword triggers (if applicable).
|
|
||||||
* @property hasReduce - Whether this node supports reduction logic.
|
|
||||||
*/
|
|
||||||
export type TriggerNodeData = {
|
|
||||||
label: string;
|
|
||||||
droppable: boolean;
|
|
||||||
triggerType: "keywords" | string;
|
|
||||||
triggers: Keyword[] | never;
|
|
||||||
hasReduce: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export type TriggerNode = Node<TriggerNodeData>
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines whether a Trigger node can connect to another node or edge.
|
|
||||||
*
|
|
||||||
* @param connection - The connection or edge being attempted to connect towards.
|
|
||||||
* @returns `true` if the connection is defined; otherwise, `false`.
|
|
||||||
*/
|
|
||||||
export function TriggerNodeCanConnect(connection: Connection | Edge): boolean {
|
|
||||||
return (connection != undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Defines how a Trigger node should be rendered
|
|
||||||
* @param props - Node properties provided by React Flow, including `id` and `data`.
|
|
||||||
* @returns The rendered TriggerNode React element (React.JSX.Element).
|
|
||||||
*/
|
|
||||||
export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
|
||||||
const data = props.data;
|
|
||||||
const {updateNodeData} = useFlowStore();
|
|
||||||
|
|
||||||
const setKeywords = (keywords: Keyword[]) => {
|
|
||||||
updateNodeData(props.id, {...data, triggers: keywords});
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>
|
|
||||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
|
||||||
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
|
|
||||||
{data.triggerType === "emotion" && (
|
|
||||||
<div className={"flex-row gap-md"}>Emotion?</div>
|
|
||||||
)}
|
|
||||||
{data.triggerType === "keywords" && (
|
|
||||||
<Keywords
|
|
||||||
keywords={data.triggers}
|
|
||||||
setKeywords={setKeywords}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Handle type="source" position={Position.Right} id="TriggerSource"/>
|
|
||||||
</div>
|
|
||||||
</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reduces each Trigger, including its children down into its core data.
|
|
||||||
* @param node - The Trigger node to reduce.
|
|
||||||
* @param nodes - The list of all nodes in the current flow graph.
|
|
||||||
* @returns A simplified object containing the node label and its list of triggers.
|
|
||||||
*/
|
|
||||||
export function TriggerReduce(node: Node, nodes: Node[]) {
|
|
||||||
// Replace this for nodes functionality
|
|
||||||
if (nodes.length <= -1) {
|
|
||||||
console.warn("Impossible nodes length in TriggerReduce")
|
|
||||||
}
|
|
||||||
const data = node.data;
|
|
||||||
switch (data.triggerType) {
|
|
||||||
case "keywords":
|
|
||||||
return {
|
|
||||||
id: node.id,
|
|
||||||
type: "keywords",
|
|
||||||
label: data.label,
|
|
||||||
keywords: data.triggers,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
id: node.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles logic that occurs when a connection is made involving a Trigger node.
|
|
||||||
*
|
|
||||||
* @param thisNode - The current Trigger node being connected.
|
|
||||||
* @param otherNode - The other node involved in the connection.
|
|
||||||
* @param isThisSource - Whether this node was the source of the connection.
|
|
||||||
*/
|
|
||||||
export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
|
|
||||||
// Replace this for connection logic
|
|
||||||
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
|
|
||||||
console.warn("Impossible node connection called in EndConnects")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Definitions for the possible triggers, being keywords and emotions
|
|
||||||
|
|
||||||
/** Represents a single keyword trigger entry. */
|
|
||||||
type Keyword = { id: string, keyword: string };
|
|
||||||
|
|
||||||
/** Properties for an emotion-type trigger node. */
|
|
||||||
export type EmotionTriggerNodeProps = {
|
|
||||||
type: "emotion";
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Props for a keyword-type trigger node. */
|
|
||||||
export type KeywordTriggerNodeProps = {
|
|
||||||
type: "keywords";
|
|
||||||
value: Keyword[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Union type for all possible Trigger node configurations. */
|
|
||||||
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders an input element that allows users to add new keyword triggers.
|
|
||||||
*
|
|
||||||
* When the input is committed, the `addKeyword` callback is called with the new keyword.
|
|
||||||
*
|
|
||||||
* @param param0 - An object containing the `addKeyword` function.
|
|
||||||
* @returns A React element(React.JSX.Element) providing an input for adding keywords.
|
|
||||||
*/
|
|
||||||
function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) {
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
|
|
||||||
const text_input_id = "keyword_adder_input";
|
|
||||||
|
|
||||||
return <div className={"flex-row gap-md"}>
|
|
||||||
<label htmlFor={text_input_id}>New Keyword:</label>
|
|
||||||
<RealtimeTextField
|
|
||||||
id={text_input_id}
|
|
||||||
value={input}
|
|
||||||
setValue={setInput}
|
|
||||||
onCommit={() => {
|
|
||||||
if (!input) return;
|
|
||||||
addKeyword(input);
|
|
||||||
setInput("");
|
|
||||||
}}
|
|
||||||
placeholder={"..."}
|
|
||||||
className={"flex-1"}
|
|
||||||
/>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays and manages a list of keyword triggers for a Trigger node.
|
|
||||||
* Handles adding, editing, and removing keywords, as well as detecting duplicate entries.
|
|
||||||
*
|
|
||||||
* @param keywords - The current list of keyword triggers.
|
|
||||||
* @param setKeywords - A callback to update the keyword list in the parent node.
|
|
||||||
* @returns A React element(React.JSX.Element) for editing keyword triggers.
|
|
||||||
*/
|
|
||||||
function Keywords({
|
|
||||||
keywords,
|
|
||||||
setKeywords,
|
|
||||||
}: {
|
|
||||||
keywords: Keyword[];
|
|
||||||
setKeywords: (keywords: Keyword[]) => void;
|
|
||||||
}) {
|
|
||||||
type Interpolatable = string | number | boolean | bigint | null | undefined;
|
|
||||||
|
|
||||||
const inputElementId = (id: Interpolatable) => `keyword_${id}_input`;
|
|
||||||
|
|
||||||
/** Indices of duplicates in the keyword array. */
|
|
||||||
const [duplicates, setDuplicates] = useState<number[]>([]);
|
|
||||||
|
|
||||||
function replace(id: string, value: string) {
|
|
||||||
value = value.trim();
|
|
||||||
const newKeywords = value === ""
|
|
||||||
? keywords.filter((kw) => kw.id != id)
|
|
||||||
: keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw);
|
|
||||||
setKeywords(newKeywords);
|
|
||||||
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function add(value: string) {
|
|
||||||
value = value.trim();
|
|
||||||
if (value === "") return;
|
|
||||||
const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}];
|
|
||||||
setKeywords(newKeywords);
|
|
||||||
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>
|
|
||||||
<span>Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken.</span>
|
|
||||||
{[...keywords].map(({id, keyword}, index) => {
|
|
||||||
return <div key={id} className={"flex-row gap-md"}>
|
|
||||||
<label htmlFor={inputElementId(id)}>Keyword:</label>
|
|
||||||
<TextField
|
|
||||||
id={inputElementId(id)}
|
|
||||||
value={keyword}
|
|
||||||
setValue={(val) => replace(id, val)}
|
|
||||||
placeholder={"..."}
|
|
||||||
className={"flex-1"}
|
|
||||||
invalid={duplicates.includes(index)}
|
|
||||||
/>
|
|
||||||
</div>;
|
|
||||||
})}
|
|
||||||
<KeywordAdder addKeyword={add} />
|
|
||||||
</>;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import {type Edge, type Node } from "@xyflow/react";
|
|
||||||
|
|
||||||
export type SavedProject = {
|
|
||||||
name: string;
|
|
||||||
savedASavedProject: string; // ISO timestamp
|
|
||||||
nodes: Node[];
|
|
||||||
edges: Edge[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Creates a JSON Blob containing the current visual program (nodes + edges)
|
|
||||||
export function makeProjectBlob(name: string, nodes: Node[], edges: Edge[]): Blob {
|
|
||||||
const payload = {
|
|
||||||
name,
|
|
||||||
savedAt: new Date().toISOString(),
|
|
||||||
nodes,
|
|
||||||
edges,
|
|
||||||
};
|
|
||||||
return new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
|
||||||
}
|
|
||||||
@@ -2,56 +2,12 @@ import {useSyncExternalStore} from "react";
|
|||||||
|
|
||||||
type Unsub = () => void;
|
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> = {
|
export type Cell<T> = {
|
||||||
/**
|
|
||||||
* Returns the current value stored in the cell.
|
|
||||||
*/
|
|
||||||
get: () => T;
|
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;
|
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;
|
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> {
|
export function cell<T>(initial: T): Cell<T> {
|
||||||
let value = initial;
|
let value = initial;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
@@ -68,29 +24,6 @@ export function cell<T>(initial: T): Cell<T> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* React hook that subscribes a component to a Cell.
|
|
||||||
*
|
|
||||||
* Automatically re-renders the component whenever the Cell's value changes.
|
|
||||||
* Uses React’s built-in `useSyncExternalStore` for correct subscription behavior.
|
|
||||||
*
|
|
||||||
* @param c - The cell to subscribe to.
|
|
||||||
* @returns The current value of the cell.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const count = cell(0);
|
|
||||||
*
|
|
||||||
* function Counter() {
|
|
||||||
* const value = useCell(count);
|
|
||||||
* return (
|
|
||||||
* <button onClick={() => count.set(v => v + 1)}>
|
|
||||||
* Count: {value}
|
|
||||||
* </button>
|
|
||||||
* );
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useCell<T>(c: Cell<T>) {
|
export function useCell<T>(c: Cell<T>) {
|
||||||
return useSyncExternalStore(c.subscribe, c.get, c.get);
|
return useSyncExternalStore(c.subscribe, c.get, c.get);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { render, screen, act, cleanup, waitFor } from '@testing-library/react';
|
|
||||||
import ConnectedRobots from '../../../src/pages/ConnectedRobots/ConnectedRobots';
|
|
||||||
|
|
||||||
// Mock event source
|
|
||||||
const mockInstances: MockEventSource[] = [];
|
|
||||||
class MockEventSource {
|
|
||||||
url: string;
|
|
||||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
||||||
closed = false;
|
|
||||||
|
|
||||||
constructor(url: string) {
|
|
||||||
this.url = url;
|
|
||||||
mockInstances.push(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendMessage(data: string) {
|
|
||||||
// Trigger whatever the component listens to
|
|
||||||
this.onmessage?.({ data } as MessageEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.closed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mock event source generation with fake function that returns our fake mock source
|
|
||||||
beforeAll(() => {
|
|
||||||
// Cast globalThis to a type exposing EventSource and assign a mocked constructor.
|
|
||||||
(globalThis as unknown as { EventSource?: typeof EventSource }).EventSource =
|
|
||||||
jest.fn((url: string) => new MockEventSource(url)) as unknown as typeof EventSource;
|
|
||||||
});
|
|
||||||
|
|
||||||
// clean after tests
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
mockInstances.length = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ConnectedRobots', () => {
|
|
||||||
test('renders initial state correctly', () => {
|
|
||||||
render(<ConnectedRobots />);
|
|
||||||
|
|
||||||
// Check initial texts (before connection)
|
|
||||||
expect(screen.getByText('Is robot currently connected?')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Robot is currently:\s*checking/i)).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText(/If checking continues, make sure CB is properly loaded/i)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updates to connected when message data is true', async () => {
|
|
||||||
render(<ConnectedRobots />);
|
|
||||||
const eventSource = mockInstances[0];
|
|
||||||
expect(eventSource).toBeDefined();
|
|
||||||
|
|
||||||
// Check state after getting 'true' message
|
|
||||||
await act(async () => {
|
|
||||||
eventSource.sendMessage('true');
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/connected! 🟢/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updates to not connected when message data is false', async () => {
|
|
||||||
render(<ConnectedRobots />);
|
|
||||||
const eventSource = mockInstances[0];
|
|
||||||
|
|
||||||
// Check statew after getting 'false' message
|
|
||||||
await act(async () => {
|
|
||||||
eventSource.sendMessage('false');
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/not connected.*🔴/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles invalid JSON gracefully', async () => {
|
|
||||||
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
render(<ConnectedRobots />);
|
|
||||||
const eventSource = mockInstances[0];
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
eventSource.sendMessage('not-json');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(logSpy).toHaveBeenCalledWith(
|
|
||||||
'Ping message not in correct format:',
|
|
||||||
'not-json'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('closes EventSource on unmount', () => {
|
|
||||||
render(<ConnectedRobots />);
|
|
||||||
const eventSource = mockInstances[0];
|
|
||||||
const closeSpy = jest.spyOn(eventSource, 'close');
|
|
||||||
cleanup();
|
|
||||||
expect(closeSpy).toHaveBeenCalled();
|
|
||||||
expect(eventSource.closed).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,33 @@
|
|||||||
describe('Not implemented', () => {
|
import { mockReactFlow } from '../../../../setupFlowTests.ts';
|
||||||
test('nothing yet', () => {
|
import {act} from "@testing-library/react";
|
||||||
expect(true)
|
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
||||||
});
|
import {addNode} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
mockReactFlow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Drag-and-Drop sidebar', () => {
|
||||||
|
test.each(['phase', 'phase'])('new nodes get added correctly', (nodeType: string) => {
|
||||||
|
act(()=> {
|
||||||
|
addNode(nodeType, {x:100, y:100});
|
||||||
|
})
|
||||||
|
const updatedState = useFlowStore.getState();
|
||||||
|
expect(updatedState.nodes.length).toBe(1);
|
||||||
|
expect(updatedState.nodes[0].type).toBe(nodeType);
|
||||||
|
});
|
||||||
|
test.each(['phase', 'norm'])('new nodes get correct Id', (nodeType) => {
|
||||||
|
act(()=> {
|
||||||
|
addNode(nodeType, {x:100, y:100});
|
||||||
|
addNode(nodeType, {x:100, y:100});
|
||||||
|
})
|
||||||
|
const updatedState = useFlowStore.getState();
|
||||||
|
expect(updatedState.nodes.length).toBe(2);
|
||||||
|
expect(updatedState.nodes[0].id).toBe(`${nodeType}-1`);
|
||||||
|
expect(updatedState.nodes[1].id).toBe(`${nodeType}-2`);
|
||||||
|
});
|
||||||
|
test('throws error on unexpected node type', () => {
|
||||||
|
expect(() => addNode('I do not Exist', {x:100, y:100})).toThrow("Node I do not Exist not found");
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
// 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("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user