diff --git a/.githooks/check-branch-name.sh b/.githooks/check-branch-name.sh new file mode 100755 index 0000000..6a6669a --- /dev/null +++ b/.githooks/check-branch-name.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# This script checks if the current branch name follows the specified format. +# It's designed to be used as a 'pre-commit' git hook. + +# Format: / +# 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: / +if ! [[ "$BRANCH_NAME" =~ ^[a-z]+/.+$ ]]; then + error_exit "Branch name must be in the format: /\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_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 +# 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 diff --git a/.githooks/check-commit-msg.sh b/.githooks/check-commit-msg.sh new file mode 100755 index 0000000..2dd592c --- /dev/null +++ b/.githooks/check-commit-msg.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +# This script checks if a commit message follows the specified format. +# It's designed to be used as a 'commit-msg' git hook. + +# Format: +# : +# +# [optional] +# +# [ref/close]: + +# --- 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: : +# 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: : \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]: \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 diff --git a/.githooks/commit-msg b/.githooks/commit-msg deleted file mode 100755 index 41992ad..0000000 --- a/.githooks/commit-msg +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh - -commit_msg_file=$1 -commit_msg=$(cat "$commit_msg_file") - -if echo "$commit_msg" | grep -Eq "^(feat|fix|refactor|perf|style|test|docs|build|chore|revert): .+"; then - if echo "$commit_msg" | grep -Eq "^(ref|close):\sN25B-.+"; then - exit 0 - else - echo "❌ Commit message invalid! Must end with [ref/close]: N25B-000" - exit 1 - fi -else - echo "❌ Commit message invalid! Must start with : " - exit 1 -fi \ No newline at end of file diff --git a/.githooks/pre-commit b/.githooks/pre-commit deleted file mode 100755 index 7e94937..0000000 --- a/.githooks/pre-commit +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh - -# Get current branch -branch=$(git rev-parse --abbrev-ref HEAD) - -if echo "$branch" | grep -Eq "(dev|main)"; then - echo 0 -fi - -# allowed pattern -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 / (must have one to six words separated by a dash)" - exit 1 -fi \ No newline at end of file diff --git a/.githooks/prepare-commit-msg b/.githooks/prepare-commit-msg deleted file mode 100755 index 5b706c1..0000000 --- a/.githooks/prepare-commit-msg +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh - -echo "#: - -#[optional body] - -#[optional footer(s)] - -#[ref/close]: " > $1 \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..371c0a9 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,53 @@ +# ---------- GLOBAL SETUP ---------- # +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + +stages: + - install + - lint + - test + +variables: + NODE_VERSION: "24.11.1" + BASE_LAYER: trixie-slim + +default: + image: docker.io/library/node:${NODE_VERSION}-${BASE_LAYER} + cache: + key: "${CI_COMMIT_REF_SLUG}" + paths: + - node_modules/ + policy: pull-push + +# --------- INSTALLING --------- # +install: + stage: install + tags: + - install + script: + - npm ci + artifacts: + paths: + - node_modules/ + expire_in: 1h + +# ---------- LINTING ---------- # +lint: + stage: lint + needs: + - install + tags: + - lint + script: + - npm run lint + +# ---------- TESTING ---------- # +test: + stage: test + needs: + - install + tags: + - test + script: + - npm run test diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..297870d --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +sh .githooks/check-commit-msg.sh $1 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..822552c --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,3 @@ +sh .githooks/check-branch-name.sh + +npm run lint diff --git a/README.md b/README.md index 9d8fee2..47e2054 100644 --- a/README.md +++ b/README.md @@ -28,18 +28,21 @@ npm run dev It should automatically reload when you save changes. -## GitHooks +## Git Hooks -To activate automatic commits/branch name checks run: +To activate automatic linting, branch name checks and commit message checks, run: -```shell -git config --local core.hooksPath .githooks +```bash +npm run prepare ``` -If your commit fails its either: -branch name != /description-of-branch , -commit name != : description of the commit. - : N25B-Num's +You might get an error along the lines of `Can't install pre-commit with core.hooksPath` set. To fix this, simply unset the hooksPath by running: + +```bash +git config --local --unset core.hooksPath +``` + +Then run the pre-commit install commands again. ## Documentation diff --git a/package-lock.json b/package-lock.json index 395326d..b225239 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.4.0", + "husky": "^9.1.7", "identity-obj-proxy": "^3.0.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", @@ -5051,6 +5052,22 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", diff --git a/package.json b/package.json index a493ed2..cd08dca 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,10 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview" + "lint": "eslint src test", + "preview": "vite preview", + "test": "jest", + "prepare": "husky" }, "dependencies": { "@neodrag/react": "^2.3.1", @@ -30,6 +32,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.4.0", + "husky": "^9.1.7", "identity-obj-proxy": "^3.0.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index f9527c8..6dbc47b 100644 --- a/src/components/TextField.tsx +++ b/src/components/TextField.tsx @@ -1,4 +1,4 @@ -import {useState} from "react"; +import {useEffect, useState} from "react"; import styles from "./TextField.module.css"; /** @@ -105,6 +105,10 @@ export function TextField({ }) { const [inputValue, setInputValue] = useState(value); + useEffect(() => { + setInputValue(value); + }, [value]); + const onCommit = () => setValue(inputValue); return ({ onConnect: state.onConnect, onReconnectStart: state.onReconnectStart, onReconnectEnd: state.onReconnectEnd, - onReconnect: state.onReconnect + onReconnect: state.onReconnect, + undo: state.undo, + redo: state.redo, + beginBatchAction: state.beginBatchAction, + endBatchAction: state.endBatchAction }); // --| define ReactFlow editor |-- @@ -60,9 +65,23 @@ const VisProgUI = () => { onConnect, onReconnect, onReconnectStart, - onReconnectEnd + onReconnectEnd, + undo, + redo, + beginBatchAction, + endBatchAction } = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore + // adds ctrl+z and ctrl+y support to respectively undo and redo actions + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.ctrlKey && e.key === 'z') undo(); + if (e.ctrlKey && e.key === 'y') redo(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }); + return (
{ onReconnectStart={onReconnectStart} onReconnectEnd={onReconnectEnd} onConnect={onConnect} + onNodeDragStart={beginBatchAction} + onNodeDragStop={endBatchAction} snapToGrid fitView proOptions={{hideAttribution: true}} @@ -83,6 +104,10 @@ const VisProgUI = () => { {/* contains the drag and drop panel for nodes */} + + + + @@ -90,8 +115,6 @@ const VisProgUI = () => { ); }; - - /** * Places the VisProgUI component inside a ReactFlowProvider * diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts new file mode 100644 index 0000000..70c4c01 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts @@ -0,0 +1,129 @@ +import type {Edge, Node} from "@xyflow/react"; +import type {StateCreator, StoreApi } from 'zustand/vanilla'; +import type {FlowState} from "./VisProgTypes.tsx"; + +export type FlowSnapshot = { + nodes: Node[]; + edges: Edge[]; +} + +/** + * A reduced version of the flowState type, + * This removes the functions that are provided by UndoRedo from the expected input type + */ +type BaseFlowState = Omit; + + +/** + * UndoRedo is implemented as a middleware for the FlowState store, + * this allows us to keep the undo redo logic separate from the flowState, + * and thus from the internal editor logic + * + * Allows users to undo and redo actions in the visual programming editor + * + * @param {(set: StoreApi["setState"], get: () => FlowState, api: StoreApi) => BaseFlowState} config + * @returns {StateCreator} + * @constructor + */ +export const UndoRedo = ( + config: ( + set: StoreApi['setState'], + get: () => FlowState, + api: StoreApi + ) => BaseFlowState ) : StateCreator => (set, get, api) => { + let batchTimeout: number | null = null; + + /** + * Captures the current state for + * + * @param {BaseFlowState} state - the current state of the editor + * @returns {FlowSnapshot} - returns a snapshot of the current editor state + */ + const getSnapshot = (state : BaseFlowState) : FlowSnapshot => ({ + nodes: state.nodes, + edges: state.edges + }); + + const initialState = config(set, get, api); + + return { + ...initialState, + + /** + * Adds a snapshot of the current state to the undo history + */ + pushSnapshot: () => { + const state = get(); + // we don't add new snapshots during an ongoing batch action + if (!state.isBatchAction) { + set({ + past: [...state.past, getSnapshot(state)], + future: [] + }); + } + + }, + + /** + * Undoes the last action from the editor, + * The state before undoing is added to the future for potential redoing + */ + undo: () => { + const state = get(); + if (!state.past.length) return; + + const snapshot = state.past.pop()!; // pop last snapshot + const currentSnapshot: FlowSnapshot = getSnapshot(state); + + set({ + nodes: snapshot.nodes, + edges: snapshot.edges, + }); + + state.future.push(currentSnapshot); // push current to redo + }, + + /** + * redoes the last undone action, + * The state before redoing is added to the past for potential undoing + */ + redo: () => { + const state = get(); + if (!state.future.length) return; + + const snapshot = state.future.pop()!; // pop last redo + const currentSnapshot: FlowSnapshot = getSnapshot(state); + + set({ + nodes: snapshot.nodes, + edges: snapshot.edges, + }); + + state.past.push(currentSnapshot); // push current to undo + }, + + /** + * Begins a batched action + * + * An example of a batched action is dragging a node in the editor, + * where we want the entire action of moving a node to a different position + * to be covered by one undoable snapshot + */ + beginBatchAction: () => { + get().pushSnapshot(); + set({ isBatchAction: true }); + if (batchTimeout) clearTimeout(batchTimeout); + }, + + /** + * Ends a batched action, + * a very short timeout is used to prevent new snapshots from being added + * until we are certain that the batch event is finished + */ + endBatchAction: () => { + batchTimeout = window.setTimeout(() => { + set({ isBatchAction: false }); + }, 10); + } + } +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 84b0ec5..8812434 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -79,6 +79,7 @@ export const NodeConnects = { export const NodeDeletes = { start: () => false, end: () => false, + test: () => false, // Used for coverage of universal/ undefined nodes } /** @@ -91,4 +92,5 @@ export const NodesInPhase = { start: () => false, end: () => false, phase: () => false, + test: () => false, // Used for coverage of universal/ undefined nodes } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index e79715f..5bcd855 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -10,6 +10,7 @@ import { } from '@xyflow/react'; import type { FlowState } from './VisProgTypes'; import { NodeDefaults, NodeConnects, NodeDeletes } from './NodeRegistry'; +import { UndoRedo } from "./EditorUndoRedo.ts"; /** @@ -34,7 +35,7 @@ function createNode(id: string, type: string, position: XYPosition, data: Record return {...defaultData, ...newData} } -//* Initial nodes to populate the flow at startup. +//* Initial nodes, created by using createNode. */ const initialNodes : Node[] = [ createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false), createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false), @@ -42,7 +43,7 @@ const initialNodes : Node[] = [ createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}), ]; -//* Initial edges to connect the startup nodes. +// * Initial edges * / const initialEdges: Edge[] = [ { id: 'start-phase-1', source: 'start', target: 'phase-1' }, { id: 'phase-1-end', source: 'phase-1', target: 'end' }, @@ -50,17 +51,17 @@ const initialEdges: Edge[] = [ /** - * How we have defined the functions for our FlowState. - * We have the normal functionality of a default FlowState with some exceptions to account for extra functionality. - * The biggest changes are in onConnect and onDelete, which we have given extra functionality based on the nodes defined functions. - * + * useFlowStore contains the implementation for all editor functionality + * and stores the current state of the visual programming editor + * * * Provides: * - Node and edge state management * - Node creation, deletion, and updates * - Custom connection handling via NodeConnects * - Edge reconnection handling + * - Undo Redo functionality through custom middleware */ -const useFlowStore = create((set, get) => ({ +const useFlowStore = create(UndoRedo((set, get) => ({ nodes: initialNodes, edges: initialEdges, edgeReconnectSuccessful: true, @@ -68,8 +69,7 @@ const useFlowStore = create((set, get) => ({ /** * Handles changes to nodes triggered by ReactFlow. */ - onNodesChange: (changes) => - set({nodes: applyNodeChanges(changes, get().nodes)}), + onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}), /** * Handles changes to edges triggered by ReactFlow. @@ -81,28 +81,34 @@ const useFlowStore = create((set, get) => ({ * 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; - } + get().pushSnapshot(); - // 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 }); -}, + 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. @@ -112,19 +118,32 @@ const useFlowStore = create((set, get) => ({ set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) }); }, - onReconnectStart: () => set({ edgeReconnectSuccessful: false }), + onReconnectStart: () => { + get().pushSnapshot(); + set({ edgeReconnectSuccessful: false }) + }, + + /** + * handles potential dropping (deleting) of an edge + * if it is not reconnected to a node after detaching it + * + * @param _evt - the event + * @param {{id: string}} edge - the described edge + */ 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) => { + get().pushSnapshot(); + // Let's find our node to check if they have a special deletion function const ourNode = get().nodes.find((n)=>n.id==nodeId); const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1] @@ -135,7 +154,7 @@ const useFlowStore = create((set, get) => ({ 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. @@ -151,6 +170,7 @@ const useFlowStore = create((set, get) => ({ * Updates the data of a node by merging new data with existing data. */ updateNodeData: (nodeId, data) => { + get().pushSnapshot(); set({ nodes: get().nodes.map((node) => { if (node.id === nodeId) { @@ -165,8 +185,15 @@ const useFlowStore = create((set, get) => ({ * Adds a new node to the flow store. */ addNode: (node: Node) => { + get().pushSnapshot(); set({ nodes: [...get().nodes, node] }); }, -})); + + // undo redo default values + past: [], + future: [], + isBatchAction: false, + })) +); export default useFlowStore; diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index e466bed..b35bbf2 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -1,6 +1,8 @@ // VisProgTypes.ts import type { Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node } from '@xyflow/react'; import type { NodeTypes } from './NodeRegistry'; +import type {FlowSnapshot} from "./EditorUndoRedo.ts"; + /** * Type representing all registered node types. @@ -74,4 +76,14 @@ export type FlowState = { * @param node - the Node object to add */ addNode: (node: Node) => void; + + // UndoRedo Types + past: FlowSnapshot[]; + future: FlowSnapshot[]; + pushSnapshot: () => void; + isBatchAction: boolean; + beginBatchAction: () => void; + endBatchAction: () => void; + undo: () => void; + redo: () => void; }; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 8440552..0401da9 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -68,8 +68,8 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP * @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(); +function addNodeToFlow(nodeType: keyof typeof NodeTypes, position: XYPosition) { + const { nodes, addNode } = useFlowStore.getState(); // Load any predefined data for this node type. const defaultData = NodeDefaults[nodeType] ?? {} @@ -94,7 +94,7 @@ function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { position, data: JSON.parse(JSON.stringify(defaultData)) } - setNodes([...nodes, newNode]); + addNode(newNode); } /** @@ -129,7 +129,7 @@ export function DndToolbar() { if (isInFlow) { const position = screenToFlowPosition(screenPosition); - addNode(nodeType, position); + addNodeToFlow(nodeType, position); } }, [screenToFlowPosition], diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 1149496..bbacdf0 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -32,23 +32,22 @@ export type GoalNode = Node * @param props NodeProps, like id, label, children * @returns React.JSX.Element */ -export default function GoalNode(props: NodeProps) { - const data = props.data +export default function GoalNode({id, data}: NodeProps) { const {updateNodeData} = useFlowStore(); - const text_input_id = `goal_${props.id}_text_input`; - const checkbox_id = `goal_${props.id}_checkbox`; + const text_input_id = `goal_${id}_text_input`; + const checkbox_id = `goal_${id}_checkbox`; const setDescription = (value: string) => { - updateNodeData(props.id, {...data, description: value}); + updateNodeData(id, {...data, description: value}); } const setAchieved = (value: boolean) => { - updateNodeData(props.id, {...data, achieved: value}); + updateNodeData(id, {...data, achieved: value}); } return <> - +
@@ -64,7 +63,7 @@ export default function GoalNode(props: NodeProps) { setAchieved(e.target.checked)} />
@@ -89,6 +88,12 @@ export function GoalReduce(node: Node, _nodes: Node[]) { } } +/** + * 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 } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 35f44c1..2e7b732 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -78,16 +78,12 @@ export default function TriggerNode(props: NodeProps) { } /** - * Reduces each Trigger, including its children down into its relevant data. - * @param node: The Node Properties of this node. - * @param _nodes: all the nodes in the graph. - * @returns A simplified object containing the node label and its list of triggers. + * 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") - } +export function TriggerReduce(node: Node, _nodes: Node[]) { const data = node.data; switch (data.triggerType) { case "keywords": diff --git a/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts b/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts new file mode 100644 index 0000000..76e7e96 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts @@ -0,0 +1,239 @@ +import {act} from '@testing-library/react'; +import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; +import { mockReactFlow } from '../../../setupFlowTests.ts'; + + +beforeAll(() => { + mockReactFlow(); +}); + +describe("UndoRedo Middleware", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + test("pushSnapshot adds a snapshot to past and clears future", () => { + const store = useFlowStore; + + store.setState({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [], + past: [], + future: [{ + nodes: [ + { + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }, + ], + edges: [] + }], + }); + + act(() => { + store.getState().pushSnapshot(); + }) + + const state = store.getState(); + expect(state.past.length).toBe(1); + expect(state.past[0]).toEqual({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + expect(state.future).toEqual([]); + }); + + test("pushSnapshot does nothing during batch action", () => { + const store = useFlowStore; + + act(() => { + store.setState({ isBatchAction: true }); + store.getState().pushSnapshot(); + }) + + expect(store.getState().past.length).toBe(0); + }); + + test("undo restores last snapshot and pushes current snapshot to future", () => { + const store = useFlowStore; + + // initial state + store.setState({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + + act(() => { + store.getState().pushSnapshot(); + + // modified state + store.setState({ + nodes: [{ + id: 'B', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'B'} + }], + edges: [] + }); + + store.getState().undo(); + }) + + expect(store.getState().nodes).toEqual([{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }]); + expect(store.getState().future.length).toBe(1); + expect(store.getState().future[0]).toEqual({ + nodes: [{ + id: 'B', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'B'} + }], + edges: [] + }); + }); + + test("undo does nothing when past is empty", () => { + const store = useFlowStore; + + store.setState({past: []}); + + act(() => { store.getState().undo(); }); + + expect(store.getState().nodes).toEqual([]); + expect(store.getState().future).toEqual([]); + }); + + test("redo restores last future snapshot and pushes current to past", () => { + const store = useFlowStore; + + // initial + store.setState({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + + act(() => { + store.getState().pushSnapshot(); + store.setState({ + nodes: [{ + id: 'B', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'B'} + }], + edges: [] + }); + + + store.getState().undo(); + + // redo should restore node with id 'B' + store.getState().redo(); + }) + + expect(store.getState().nodes).toEqual([{ + id: 'B', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'B'} + }]); + expect(store.getState().past.length).toBe(1); // snapshot A stored + expect(store.getState().past[0]).toEqual({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + }); + + test("redo does nothing when future is empty", () => { + const store = useFlowStore; + + store.setState({past: []}); + act(() => { store.getState().redo(); }); + + expect(store.getState().nodes).toEqual([]); + }); + + test("beginBatchAction pushes snapshot and sets isBatchAction=true", () => { + const store = useFlowStore; + + store.setState({ + nodes: [{ + id: 'A', + type: 'default', + position: {x: 0, y: 0}, + data: {label: 'A'} + }], + edges: [] + }); + + act(() => { store.getState().beginBatchAction(); }); + + expect(store.getState().isBatchAction).toBe(true); + expect(store.getState().past.length).toBe(1); + }); + + test("endBatchAction sets isBatchAction=false after timeout", () => { + const store = useFlowStore; + + store.setState({ isBatchAction: true }); + act(() => { store.getState().endBatchAction(); }); + + // isBatchAction should remain true before the timer has advanced + expect(store.getState().isBatchAction).toBe(true); + + jest.advanceTimersByTime(10); + + // it should now be set to false as the timer has advanced enough + expect(store.getState().isBatchAction).toBe(false); + }); + + test("multiple beginBatchAction calls clear the timeout", () => { + const store = useFlowStore; + + act(() => { + store.getState().beginBatchAction(); + store.getState().endBatchAction(); // starts timeout + store.getState().beginBatchAction(); // should clear previous timeout + }); + + + jest.advanceTimersByTime(10); + + // After advancing the timers, isBatchAction should still be true, + // as the timeout should have been cleared + expect(store.getState().isBatchAction).toBe(true); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx index a17fde8..486d41f 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx @@ -1,7 +1,7 @@ import { getByTestId, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg'; -import userEvent from '@testing-library/user-event'; @@ -52,13 +52,7 @@ jest.mock('@xyflow/react', () => { }; }); -// Reset Zustand state helper -function resetStore() { - useFlowStore.setState({ nodes: [], edges: [] }); -} - describe("Drag & drop node creation", () => { - beforeEach(() => resetStore()); test("drops a phase node inside the canvas and adds it with transformed position", async () => { const user = userEvent.setup(); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index 598d687..25c9947 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -1,7 +1,7 @@ import { describe, it, beforeEach } from '@jest/globals'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils'; +import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; import NormNode, { NormReduce, NormConnects, type NormNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode' import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; @@ -13,7 +13,6 @@ describe('NormNode', () => { let user: ReturnType; beforeEach(() => { - resetFlowStore(); user = userEvent.setup(); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx index 3754202..c5ec43a 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx @@ -1,15 +1,13 @@ -import { describe, it, beforeEach } from '@jest/globals'; -import { screen } from '@testing-library/react'; -import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils'; -import StartNode, { StartReduce, StartConnects } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode'; -import type { Node } from '@xyflow/react'; +import { describe, it } from '@jest/globals'; import '@testing-library/jest-dom'; +import { screen } from '@testing-library/react'; +import type { Node } from '@xyflow/react'; +import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; +import StartNode, { StartReduce, StartConnects } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode'; + describe('StartNode', () => { - beforeEach(() => { - resetFlowStore(); - }); describe('Rendering', () => { it('renders the StartNode correctly', () => { diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx index 9b1ff49..55a46e3 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -1,7 +1,7 @@ import { describe, it, beforeEach } from '@jest/globals'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils'; +import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; import TriggerNode, { TriggerReduce, TriggerConnects, TriggerNodeCanConnect, type TriggerNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; @@ -11,7 +11,6 @@ describe('TriggerNode', () => { let user: ReturnType; beforeEach(() => { - resetFlowStore(); user = userEvent.setup(); }); @@ -193,22 +192,19 @@ describe('TriggerNode', () => { }, }; - const allNodes: Node[] = [triggerNode]; - const result = TriggerReduce(triggerNode, allNodes); + const allNodes: Node[] = [triggerNode]; + const result = TriggerReduce(triggerNode, allNodes); - expect(result).toEqual({ - id: "trigger-1", - type: "keywords", - label: 'Keyword Trigger', - keywords: [ - { - "id": "kw1", - "keyword": "hello", - },], - }); + expect(result).toEqual({ + id: 'trigger-1', + type: 'keywords', + label: 'Keyword Trigger', + keywords: [{ id: 'kw1', keyword: 'hello' }], + }); }); }); + describe('TriggerConnects Function', () => { it('should handle connection without errors', () => { const node1: Node = { diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 80e52b4..7fb0709 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -1,6 +1,6 @@ import { describe, beforeEach } from '@jest/globals'; import { screen } from '@testing-library/react'; -import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils'; +import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; import type { XYPosition } from '@xyflow/react'; import { NodeTypes, NodeDefaults, NodeConnects, NodeReduces, NodesInPhase } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry'; import '@testing-library/jest-dom' @@ -10,12 +10,11 @@ import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgramming describe('NormNode', () => { beforeEach(() => { - resetFlowStore(); jest.clearAllMocks(); }); function createNode(id: string, type: string, position: XYPosition, data: Record, deletable?: boolean) { - const defaultData = JSON.parse(JSON.stringify(NodeDefaults[type as keyof typeof NodeDefaults])) + const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] const newData = { id: id, type: type, @@ -46,30 +45,35 @@ describe('NormNode', () => { describe('Rendering', () => { test.each(getAllTypes())('it should render %s node with the default data', (nodeType) => { - const lengthBefore = screen.getAllByText(/.*/).length; + const lengthBefore = screen.getAllByText(/.*/).length; - const newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {}) - const uiElement = Object.entries(NodeTypes).find(([t])=>t==nodeType)?.[1]; - expect(uiElement).toBeDefined(); - const props = { - id:newNode.id, - type:newNode.type as string, - data:newNode.data as any, - selected:false, - isConnectable:true, - zIndex:0, - dragging:false, - selectable:true, - deletable:true, - draggable:true, - positionAbsoluteX:0, - positionAbsoluteY:0,} + const newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {}); - renderWithProviders(createElement(uiElement as React.ComponentType, props)); - const lengthAfter = screen.getAllByText(/.*/).length; - - expect(lengthBefore + 1 == lengthAfter) - }); + const found = Object.entries(NodeTypes).find(([t]) => t === nodeType); + const uiElement = found ? found[1] : null; + + expect(uiElement).not.toBeNull(); + const props = { + id: newNode.id, + type: newNode.type as string, + data: newNode.data as any, + selected: false, + isConnectable: true, + zIndex: 0, + dragging: false, + selectable: true, + deletable: true, + draggable: true, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + }; + + renderWithProviders(createElement(uiElement as React.ComponentType, props)); + const lengthAfter = screen.getAllByText(/.*/).length; + + expect(lengthBefore + 1 === lengthAfter); + }); + }); diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts index 21a4945..3ce8c3a 100644 --- a/test/setupFlowTests.ts +++ b/test/setupFlowTests.ts @@ -69,6 +69,9 @@ beforeAll(() => { useFlowStore.setState({ nodes: [], edges: [], + past: [], + future: [], + isBatchAction: false, edgeReconnectSuccessful: true }); }); @@ -78,6 +81,9 @@ afterEach(() => { useFlowStore.setState({ nodes: [], edges: [], + past: [], + future: [], + isBatchAction: false, edgeReconnectSuccessful: true }); }); diff --git a/test/test-utils/test-utils.tsx b/test/test-utils/test-utils.tsx index 1ad371a..2379d9c 100644 --- a/test/test-utils/test-utils.tsx +++ b/test/test-utils/test-utils.tsx @@ -2,7 +2,6 @@ import { render, type RenderOptions } from '@testing-library/react'; import { type ReactElement, type ReactNode } from 'react'; import { ReactFlowProvider } from '@xyflow/react'; -import useFlowStore from '../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; /** * Custom render function that wraps components with necessary providers @@ -19,15 +18,7 @@ export function renderWithProviders( return render(ui, { wrapper: Wrapper, ...options }); } -/** - * Helper to reset the Zustand store between tests - * This ensures test isolation - */ -export function resetFlowStore() { - useFlowStore.setState({ - nodes: [], - edges: [], - edgeReconnectSuccessful: true, - }); -} +// Re-export everything from testing library +//eslint-disable-next-line react-refresh/only-export-components +export * from '@testing-library/react';