Merging dev into main #49
13
package-lock.json
generated
13
package-lock.json
generated
@@ -24,6 +24,7 @@
|
|||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.1.13",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@vitejs/plugin-react": "^5.0.3",
|
"@vitejs/plugin-react": "^5.0.3",
|
||||||
|
"baseline-browser-mapping": "^2.9.11",
|
||||||
"eslint": "^9.36.0",
|
"eslint": "^9.36.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
@@ -3698,9 +3699,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.8.6",
|
"version": "2.9.11",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
||||||
"integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==",
|
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -4869,9 +4870,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "10.4.5",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.1.13",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@vitejs/plugin-react": "^5.0.3",
|
"@vitejs/plugin-react": "^5.0.3",
|
||||||
|
"baseline-browser-mapping": "^2.9.11",
|
||||||
"eslint": "^9.36.0",
|
"eslint": "^9.36.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
|||||||
110
src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts
Normal file
110
src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import {type Connection} from "@xyflow/react";
|
||||||
|
import {useEffect} from "react";
|
||||||
|
import useFlowStore from "./VisProgStores.tsx";
|
||||||
|
|
||||||
|
export type ConnectionContext = {
|
||||||
|
connectionCount: number;
|
||||||
|
source: {
|
||||||
|
id: string;
|
||||||
|
handleId: string;
|
||||||
|
}
|
||||||
|
target: {
|
||||||
|
id: string;
|
||||||
|
handleId: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HandleRule = (
|
||||||
|
connection: Connection,
|
||||||
|
context: ConnectionContext
|
||||||
|
) => RuleResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A RuleResult describes the outcome of validating a HandleRule
|
||||||
|
*
|
||||||
|
* if a rule is not satisfied, the RuleResult includes a message that is used inside a tooltip
|
||||||
|
* that tells the user why their attempted connection is not possible
|
||||||
|
*/
|
||||||
|
export type RuleResult =
|
||||||
|
| { isSatisfied: true }
|
||||||
|
| { isSatisfied: false, message: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* default RuleResults, can be used to create more readable handleRule definitions
|
||||||
|
*/
|
||||||
|
export const ruleResult = {
|
||||||
|
satisfied: { isSatisfied: true } as RuleResult,
|
||||||
|
unknownError: {isSatisfied: false, message: "Unknown Error" } as RuleResult,
|
||||||
|
notSatisfied: (message: string) : RuleResult => { return {isSatisfied: false, message: message } }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const evaluateRules = (
|
||||||
|
rules: HandleRule[],
|
||||||
|
connection: Connection,
|
||||||
|
context: ConnectionContext
|
||||||
|
) : RuleResult => {
|
||||||
|
// evaluate the rules and check if there is at least one unsatisfied rule
|
||||||
|
const failedRule = rules
|
||||||
|
.map(rule => rule(connection, context))
|
||||||
|
.find(result => !result.isSatisfied);
|
||||||
|
|
||||||
|
return failedRule ? ruleResult.notSatisfied(failedRule.message) : ruleResult.satisfied;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* !DOCUMENTATION NOT FINISHED!
|
||||||
|
*
|
||||||
|
* - The output is a single RuleResult, meaning we only show one error message.
|
||||||
|
* Error messages are prioritised by listOrder; Thus, if multiple HandleRules evaluate to false,
|
||||||
|
* we only send the error message of the first failed rule in the target's registered list of rules.
|
||||||
|
*
|
||||||
|
* @param {string} nodeId
|
||||||
|
* @param {string} handleId
|
||||||
|
* @param type
|
||||||
|
* @param {HandleRule[]} rules
|
||||||
|
* @returns {(c: Connection) => RuleResult} a function that validates an attempted connection
|
||||||
|
*/
|
||||||
|
export function useHandleRules(
|
||||||
|
nodeId: string,
|
||||||
|
handleId: string,
|
||||||
|
type: "source" | "target",
|
||||||
|
rules: HandleRule[],
|
||||||
|
) : (c: Connection) => RuleResult {
|
||||||
|
const edges = useFlowStore.getState().edges;
|
||||||
|
const registerRules = useFlowStore((state) => state.registerRules);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerRules(nodeId, handleId, rules);
|
||||||
|
// the following eslint disable is required as it wants us to use all possible dependencies for the useEffect statement,
|
||||||
|
// however this would result in an infinite loop because it would change one of its own dependencies
|
||||||
|
// so we only use those dependencies that we don't change ourselves
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [handleId, nodeId, registerRules]);
|
||||||
|
|
||||||
|
return (connection: Connection) => {
|
||||||
|
// inside this function we consider the target to be the target of the isValidConnection event
|
||||||
|
// and not the target in the actual connection
|
||||||
|
const { target, targetHandle } = type === "source"
|
||||||
|
? connection
|
||||||
|
: { target: connection.source, targetHandle: connection.sourceHandle };
|
||||||
|
|
||||||
|
if (!targetHandle) {throw new Error("No target handle was provided");}
|
||||||
|
|
||||||
|
const targetConnections = edges.filter(edge => edge.target === target && edge.targetHandle === targetHandle);
|
||||||
|
|
||||||
|
|
||||||
|
// we construct the connectionContext
|
||||||
|
const context: ConnectionContext = {
|
||||||
|
connectionCount: targetConnections.length,
|
||||||
|
source: {id: nodeId, handleId: handleId},
|
||||||
|
target: {id: target, handleId: targetHandle},
|
||||||
|
};
|
||||||
|
const targetRules = useFlowStore.getState().getTargetRules(target, targetHandle);
|
||||||
|
|
||||||
|
// finally we return a function that evaluates all rules using the created context
|
||||||
|
return evaluateRules(targetRules, connection, context);
|
||||||
|
};
|
||||||
|
}
|
||||||
45
src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts
Normal file
45
src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
type HandleRule,
|
||||||
|
ruleResult
|
||||||
|
} from "./HandleRuleLogic.ts";
|
||||||
|
import useFlowStore from "./VisProgStores.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* this specifies what types of nodes can make a connection to a handle that uses this rule
|
||||||
|
*/
|
||||||
|
export function allowOnlyConnectionsFromType(nodeTypes: string[]) : HandleRule {
|
||||||
|
return ((_, {source}) => {
|
||||||
|
const sourceType = useFlowStore.getState().nodes.find(node => node.id === source.id)!.type!;
|
||||||
|
return nodeTypes.find(type => sourceType === type)
|
||||||
|
? ruleResult.satisfied
|
||||||
|
: ruleResult.notSatisfied(`the target doesn't allow connections from nodes with type: ${sourceType}`);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* similar to allowOnlyConnectionsFromType,
|
||||||
|
* this is a more specific variant that allows you to restrict connections to specific handles on each nodeType
|
||||||
|
*/
|
||||||
|
//
|
||||||
|
export function allowOnlyConnectionsFromHandle(handles: {nodeType: string, handleId: string}[]) : HandleRule {
|
||||||
|
return ((_, {source}) => {
|
||||||
|
const sourceNode = useFlowStore.getState().nodes.find(node => node.id === source.id)!;
|
||||||
|
return handles.find(handle => sourceNode.type === handle.nodeType && source.handleId === handle.handleId)
|
||||||
|
? ruleResult.satisfied
|
||||||
|
: ruleResult.notSatisfied(`the target doesn't allow connections from nodes with type: ${sourceNode.type}`);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This rule prevents a node from making a connection between its own handles
|
||||||
|
*/
|
||||||
|
export const noSelfConnections : HandleRule =
|
||||||
|
(connection, _) => {
|
||||||
|
return connection.source !== connection.target
|
||||||
|
? ruleResult.satisfied
|
||||||
|
: ruleResult.notSatisfied("nodes are not allowed to connect to themselves");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type Edge,
|
type Edge,
|
||||||
type XYPosition,
|
type XYPosition,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
import type { FlowState } from './VisProgTypes';
|
import type { FlowState } from './VisProgTypes';
|
||||||
import {
|
import {
|
||||||
NodeDefaults,
|
NodeDefaults,
|
||||||
@@ -50,8 +51,9 @@ function createNode(id: string, type: string, position: XYPosition, data: Record
|
|||||||
|
|
||||||
const initialNodes : Node[] = [startNode, endNode, initialPhaseNode,];
|
const initialNodes : Node[] = [startNode, endNode, initialPhaseNode,];
|
||||||
|
|
||||||
// * Initial edges * /
|
// Initial edges, leave empty as setting initial edges...
|
||||||
const initialEdges: Edge[] = []; // no initial edges as edge connect events don't fire when using initial edges
|
// ...breaks logic that is dependent on connection events
|
||||||
|
const initialEdges: Edge[] = [];
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,8 +86,9 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
|
|||||||
*/
|
*/
|
||||||
onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}),
|
onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}),
|
||||||
|
|
||||||
onEdgesDelete: (edges) => {
|
onNodesDelete: (nodes) => nodes.forEach(node => get().unregisterNodeRules(node.id)),
|
||||||
|
|
||||||
|
onEdgesDelete: (edges) => {
|
||||||
// we make sure any affected nodes get updated to reflect removal of edges
|
// we make sure any affected nodes get updated to reflect removal of edges
|
||||||
edges.forEach((edge) => {
|
edges.forEach((edge) => {
|
||||||
const nodes = get().nodes;
|
const nodes = get().nodes;
|
||||||
@@ -231,6 +234,79 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
|
|||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
isBatchAction: false,
|
isBatchAction: false,
|
||||||
|
|
||||||
|
// handleRuleRegistry definitions
|
||||||
|
/**
|
||||||
|
* stores registered rules for handle connection validation
|
||||||
|
*/
|
||||||
|
ruleRegistry: new Map(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gets the rules registered by that handle described by the given node and handle ids
|
||||||
|
*
|
||||||
|
* @param {string} targetNodeId
|
||||||
|
* @param {string} targetHandleId
|
||||||
|
* @returns {HandleRule[]}
|
||||||
|
*/
|
||||||
|
getTargetRules: (targetNodeId, targetHandleId) => {
|
||||||
|
const key = `${targetNodeId}:${targetHandleId}`;
|
||||||
|
const rules = get().ruleRegistry.get(key);
|
||||||
|
|
||||||
|
// helper function that handles a situation where no rules were registered
|
||||||
|
const missingRulesResponse = () => {
|
||||||
|
console.warn(
|
||||||
|
`No rules were registered for the following handle "${key}"!
|
||||||
|
returning and empty handleRule[] to avoid crashing`);
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules
|
||||||
|
? rules
|
||||||
|
: missingRulesResponse()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* registers a handle's connection rules
|
||||||
|
*
|
||||||
|
* @param {string} nodeId
|
||||||
|
* @param {string} handleId
|
||||||
|
* @param {HandleRule[]} rules
|
||||||
|
*/
|
||||||
|
registerRules: (nodeId, handleId, rules) => {
|
||||||
|
const registry = get().ruleRegistry;
|
||||||
|
registry.set(`${nodeId}:${handleId}`, rules);
|
||||||
|
set({ ruleRegistry: registry }) ;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* unregisters a handles connection rules
|
||||||
|
*
|
||||||
|
* @param {string} nodeId
|
||||||
|
* @param {string} handleId
|
||||||
|
*/
|
||||||
|
unregisterHandleRules: (nodeId, handleId) => {
|
||||||
|
set( () => {
|
||||||
|
const registry = get().ruleRegistry;
|
||||||
|
registry.delete(`${nodeId}:${handleId}`);
|
||||||
|
return { ruleRegistry: registry };
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* unregisters connection rules for all handles on the given node
|
||||||
|
* used for cleaning up rules on node deletion
|
||||||
|
*
|
||||||
|
* @param {string} nodeId
|
||||||
|
*/
|
||||||
|
unregisterNodeRules: (nodeId) => {
|
||||||
|
set(() => {
|
||||||
|
const registry = get().ruleRegistry;
|
||||||
|
registry.forEach((_,key) => {
|
||||||
|
if (key.startsWith(`${nodeId}:`)) registry.delete(key)
|
||||||
|
})
|
||||||
|
return { ruleRegistry: registry };
|
||||||
|
})
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
// VisProgTypes.ts
|
// VisProgTypes.ts
|
||||||
import type {Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node, OnEdgesDelete} from '@xyflow/react';
|
import type {
|
||||||
|
Edge,
|
||||||
|
OnNodesChange,
|
||||||
|
OnEdgesChange,
|
||||||
|
OnConnect,
|
||||||
|
OnReconnect,
|
||||||
|
Node,
|
||||||
|
OnEdgesDelete,
|
||||||
|
OnNodesDelete
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import type {HandleRule} from "./HandleRuleLogic.ts";
|
||||||
import type { NodeTypes } from './NodeRegistry';
|
import type { NodeTypes } from './NodeRegistry';
|
||||||
import type {FlowSnapshot} from "./EditorUndoRedo.ts";
|
import type {FlowSnapshot} from "./EditorUndoRedo.ts";
|
||||||
|
|
||||||
@@ -31,6 +41,8 @@ export type FlowState = {
|
|||||||
/** Handler for changes to nodes triggered by ReactFlow */
|
/** Handler for changes to nodes triggered by ReactFlow */
|
||||||
onNodesChange: OnNodesChange;
|
onNodesChange: OnNodesChange;
|
||||||
|
|
||||||
|
onNodesDelete: OnNodesDelete;
|
||||||
|
|
||||||
onEdgesDelete: OnEdgesDelete;
|
onEdgesDelete: OnEdgesDelete;
|
||||||
|
|
||||||
/** Handler for changes to edges triggered by ReactFlow */
|
/** Handler for changes to edges triggered by ReactFlow */
|
||||||
@@ -82,7 +94,9 @@ export type FlowState = {
|
|||||||
* @param node - the Node object to add
|
* @param node - the Node object to add
|
||||||
*/
|
*/
|
||||||
addNode: (node: Node) => void;
|
addNode: (node: Node) => void;
|
||||||
|
} & UndoRedoState & HandleRuleRegistry;
|
||||||
|
|
||||||
|
export type UndoRedoState = {
|
||||||
// UndoRedo Types
|
// UndoRedo Types
|
||||||
past: FlowSnapshot[];
|
past: FlowSnapshot[];
|
||||||
future: FlowSnapshot[];
|
future: FlowSnapshot[];
|
||||||
@@ -92,4 +106,27 @@ export type FlowState = {
|
|||||||
endBatchAction: () => void;
|
endBatchAction: () => void;
|
||||||
undo: () => void;
|
undo: () => void;
|
||||||
redo: () => void;
|
redo: () => void;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
export type HandleRuleRegistry = {
|
||||||
|
ruleRegistry: Map<string, HandleRule[]>;
|
||||||
|
|
||||||
|
getTargetRules: (
|
||||||
|
targetNodeId: string,
|
||||||
|
targetHandleId: string
|
||||||
|
) => HandleRule[];
|
||||||
|
|
||||||
|
registerRules: (
|
||||||
|
nodeId: string,
|
||||||
|
handleId: string,
|
||||||
|
rules: HandleRule[]
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
unregisterHandleRules: (
|
||||||
|
nodeId: string,
|
||||||
|
handleId: string
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
// cleans up all registered rules of all handles of the provided node
|
||||||
|
unregisterNodeRules: (nodeId: string) => void
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import {
|
|
||||||
Handle,
|
|
||||||
useNodeConnections,
|
|
||||||
type HandleType,
|
|
||||||
type Position
|
|
||||||
} from '@xyflow/react';
|
|
||||||
|
|
||||||
|
|
||||||
const LimitedConnectionCountHandle = (props: {
|
|
||||||
node_id: string,
|
|
||||||
type: HandleType,
|
|
||||||
position: Position,
|
|
||||||
connection_count: number,
|
|
||||||
id?: string
|
|
||||||
}) => {
|
|
||||||
const connections = useNodeConnections({
|
|
||||||
id: props.node_id,
|
|
||||||
handleType: props.type,
|
|
||||||
handleId: props.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Handle
|
|
||||||
{...props}
|
|
||||||
isConnectable={connections.length < props.connection_count}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LimitedConnectionCountHandle;
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
:global(.react-flow__handle.connected) {
|
||||||
|
background: lightgray;
|
||||||
|
border-color: green;
|
||||||
|
filter: drop-shadow(0 0 0.25rem green);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.singleConnectionHandle.connected) {
|
||||||
|
background: #55dd99;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.react-flow__handle.unconnected){
|
||||||
|
background: lightgray;
|
||||||
|
border-color: gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.singleConnectionHandle.unconnected){
|
||||||
|
background: lightsalmon;
|
||||||
|
border-color: #ff6060;
|
||||||
|
filter: drop-shadow(0 0 0.25rem #ff6060);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.react-flow__handle.connectingto) {
|
||||||
|
background: #ff6060;
|
||||||
|
border-color: coral;
|
||||||
|
filter: drop-shadow(0 0 0.25rem coral);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.react-flow__handle.valid) {
|
||||||
|
background: #55dd99;
|
||||||
|
border-color: green;
|
||||||
|
filter: drop-shadow(0 0 0.25rem green);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
Handle,
|
||||||
|
type HandleProps,
|
||||||
|
type Connection,
|
||||||
|
useNodeId, useNodeConnections
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import {useState} from 'react';
|
||||||
|
import { type HandleRule, useHandleRules} from "../HandleRuleLogic.ts";
|
||||||
|
import "./RuleBasedHandle.module.css";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function MultiConnectionHandle({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
rules = [],
|
||||||
|
...otherProps
|
||||||
|
} : HandleProps & { rules?: HandleRule[]}) {
|
||||||
|
let nodeId = useNodeId();
|
||||||
|
// this check is used to make sure that the handle code doesn't break when used inside a test,
|
||||||
|
// since useNodeId would be undefined if the handle is not used inside a node
|
||||||
|
nodeId = nodeId ? nodeId : "mockId";
|
||||||
|
const validate = useHandleRules(nodeId, id!, type!, rules);
|
||||||
|
|
||||||
|
|
||||||
|
const connections = useNodeConnections({
|
||||||
|
id: nodeId,
|
||||||
|
handleType: type,
|
||||||
|
handleId: id!
|
||||||
|
})
|
||||||
|
|
||||||
|
// initialise the handles state with { isValid: true } to show that connections are possible
|
||||||
|
const [handleState, setHandleState] = useState<{ isSatisfied: boolean, message?: string }>({ isSatisfied: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Handle
|
||||||
|
{...otherProps}
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
className={"multiConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected")}
|
||||||
|
isValidConnection={(connection) => {
|
||||||
|
const result = validate(connection as Connection);
|
||||||
|
setHandleState(result);
|
||||||
|
return result.isSatisfied;
|
||||||
|
}}
|
||||||
|
title={handleState.message}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SingleConnectionHandle({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
rules = [],
|
||||||
|
...otherProps
|
||||||
|
} : HandleProps & { rules?: HandleRule[]}) {
|
||||||
|
let nodeId = useNodeId();
|
||||||
|
// this check is used to make sure that the handle code doesn't break when used inside a test,
|
||||||
|
// since useNodeId would be undefined if the handle is not used inside a node
|
||||||
|
nodeId = nodeId ? nodeId : "mockId";
|
||||||
|
const validate = useHandleRules(nodeId, id!, type!, rules);
|
||||||
|
|
||||||
|
const connections = useNodeConnections({
|
||||||
|
id: nodeId,
|
||||||
|
handleType: type,
|
||||||
|
handleId: id!
|
||||||
|
})
|
||||||
|
|
||||||
|
// initialise the handles state with { isValid: true } to show that connections are possible
|
||||||
|
const [handleState, setHandleState] = useState<{ isSatisfied: boolean, message?: string }>({ isSatisfied: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Handle
|
||||||
|
{...otherProps}
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
className={"singleConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected")}
|
||||||
|
isConnectable={connections.length === 0}
|
||||||
|
isValidConnection={(connection) => {
|
||||||
|
const result = validate(connection as Connection);
|
||||||
|
setHandleState(result);
|
||||||
|
return result.isSatisfied;
|
||||||
|
}}
|
||||||
|
title={handleState.message}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
Handle,
|
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
Position,
|
Position,
|
||||||
type Node,
|
type Node,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
|
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
|
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
||||||
import useFlowStore from '../VisProgStores';
|
import useFlowStore from '../VisProgStores';
|
||||||
import { TextField } from '../../../../components/TextField';
|
import { TextField } from '../../../../components/TextField';
|
||||||
import { MultilineTextField } from '../../../../components/MultilineTextField';
|
import { MultilineTextField } from '../../../../components/MultilineTextField';
|
||||||
@@ -183,7 +184,9 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Handle type="source" position={Position.Right} id="source"/>
|
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||||
|
allowOnlyConnectionsFromType(["norm", "trigger"]),
|
||||||
|
]}/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import {
|
|||||||
Position,
|
Position,
|
||||||
type Node,
|
type Node,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import LimitedConnectionCountHandle from "../components/CustomNodeHandles.tsx";
|
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
|
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
|
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,13 +34,9 @@ export default function EndNode(props: NodeProps<EndNode>) {
|
|||||||
<div className={"flex-row gap-sm"}>
|
<div className={"flex-row gap-sm"}>
|
||||||
End
|
End
|
||||||
</div>
|
</div>
|
||||||
<LimitedConnectionCountHandle
|
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
|
||||||
node_id={props.id}
|
allowOnlyConnectionsFromType(["phase"])
|
||||||
type="target"
|
]}/>
|
||||||
position={Position.Left}
|
|
||||||
connection_count={1}
|
|
||||||
id="target"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Handle,
|
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
Position,
|
Position,
|
||||||
type Node,
|
type Node,
|
||||||
@@ -7,6 +6,8 @@ import {
|
|||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
import { TextField } from '../../../../components/TextField';
|
import { TextField } from '../../../../components/TextField';
|
||||||
|
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
|
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
|
||||||
import useFlowStore from '../VisProgStores';
|
import useFlowStore from '../VisProgStores';
|
||||||
import { DoesPlanIterate, PlanReduce, type Plan } from '../components/Plan';
|
import { DoesPlanIterate, PlanReduce, type Plan } from '../components/Plan';
|
||||||
import PlanEditorDialog from '../components/PlanEditor';
|
import PlanEditorDialog from '../components/PlanEditor';
|
||||||
@@ -111,7 +112,9 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
|||||||
description={data.name}
|
description={data.name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Handle type="source" position={Position.Right} id="GoalSource"/>
|
<MultiConnectionHandle type="source" position={Position.Right} id="GoalSource" rules={[
|
||||||
|
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
|
||||||
|
]}/>
|
||||||
</div>
|
</div>
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Handle,
|
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
Position,
|
Position,
|
||||||
type Node,
|
type Node,
|
||||||
@@ -7,6 +6,8 @@ import {
|
|||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
import { TextField } from '../../../../components/TextField';
|
import { TextField } from '../../../../components/TextField';
|
||||||
|
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
|
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
||||||
import useFlowStore from '../VisProgStores';
|
import useFlowStore from '../VisProgStores';
|
||||||
import { BasicBeliefReduce } from './BasicBeliefNode';
|
import { BasicBeliefReduce } from './BasicBeliefNode';
|
||||||
|
|
||||||
@@ -76,8 +77,12 @@ export default function NormNode(props: NodeProps<NormNode>) {
|
|||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
|
|
||||||
<Handle type="source" position={Position.Right} id="norms"/>
|
<MultiConnectionHandle type="source" position={Position.Right} id="norms" rules={[
|
||||||
<Handle type="target" position={Position.Bottom} id="norms"/>
|
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}])
|
||||||
|
]}/>
|
||||||
|
<MultiConnectionHandle type="target" position={Position.Bottom} id="beliefs" rules={[
|
||||||
|
allowOnlyConnectionsFromType(["basic_belief"])
|
||||||
|
]}/>
|
||||||
</div>
|
</div>
|
||||||
</>;
|
</>;
|
||||||
};
|
};
|
||||||
@@ -86,7 +91,7 @@ export default function NormNode(props: NodeProps<NormNode>) {
|
|||||||
/**
|
/**
|
||||||
* Reduces each Norm, including its children down into its relevant data.
|
* Reduces each Norm, including its children down into its relevant data.
|
||||||
* @param node The Node Properties of this node.
|
* @param node The Node Properties of this node.
|
||||||
* @param _nodes all the nodes in the graph
|
* @param nodes all the nodes in the graph
|
||||||
*/
|
*/
|
||||||
export function NormReduce(node: Node, nodes: Node[]) {
|
export function NormReduce(node: Node, nodes: Node[]) {
|
||||||
const data = node.data as NormNodeData;
|
const data = node.data as NormNodeData;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
Handle,
|
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
Position,
|
Position,
|
||||||
type Node
|
type Node
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
|
import {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
|
import {allowOnlyConnectionsFromType, noSelfConnections} from "../HandleRules.ts";
|
||||||
import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry';
|
import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry';
|
||||||
import useFlowStore from '../VisProgStores';
|
import useFlowStore from '../VisProgStores';
|
||||||
import { TextField } from '../../../../components/TextField';
|
import { TextField } from '../../../../components/TextField';
|
||||||
import LimitedConnectionCountHandle from "../components/CustomNodeHandles.tsx";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default data dot a phase node
|
* The default data dot a phase node
|
||||||
@@ -54,21 +54,17 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
|||||||
placeholder={"Phase ..."}
|
placeholder={"Phase ..."}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<LimitedConnectionCountHandle
|
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
|
||||||
node_id={props.id}
|
noSelfConnections,
|
||||||
type="target"
|
allowOnlyConnectionsFromType(["phase", "start"]),
|
||||||
position={Position.Left}
|
]}/>
|
||||||
connection_count={1}
|
<MultiConnectionHandle type="target" position={Position.Bottom} id="data" rules={[
|
||||||
id="target"
|
allowOnlyConnectionsFromType(["norm", "goal", "trigger"])
|
||||||
/>
|
]}/>
|
||||||
<Handle type="target" position={Position.Bottom} id="norms"/>
|
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||||
<LimitedConnectionCountHandle
|
noSelfConnections,
|
||||||
node_id={props.id}
|
allowOnlyConnectionsFromType(["phase", "end"]),
|
||||||
type="source"
|
]}/>
|
||||||
position={Position.Right}
|
|
||||||
connection_count={1}
|
|
||||||
id="source"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import {
|
|||||||
Position,
|
Position,
|
||||||
type Node,
|
type Node,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import LimitedConnectionCountHandle from "../components/CustomNodeHandles.tsx";
|
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
|
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
|
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
|
||||||
|
|
||||||
|
|
||||||
export type StartNodeData = {
|
export type StartNodeData = {
|
||||||
@@ -31,13 +32,9 @@ export default function StartNode(props: NodeProps<StartNode>) {
|
|||||||
<div className={"flex-row gap-sm"}>
|
<div className={"flex-row gap-sm"}>
|
||||||
Start
|
Start
|
||||||
</div>
|
</div>
|
||||||
<LimitedConnectionCountHandle
|
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||||
node_id={props.id}
|
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"target"}])
|
||||||
type="source"
|
]}/>
|
||||||
position={Position.Right}
|
|
||||||
connection_count={1}
|
|
||||||
id="source"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Handle,
|
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
Position,
|
Position,
|
||||||
type Connection,
|
type Connection,
|
||||||
@@ -8,6 +7,8 @@ import {
|
|||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
|
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
|
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
|
||||||
import useFlowStore from '../VisProgStores';
|
import useFlowStore from '../VisProgStores';
|
||||||
import { PlanReduce, type Plan } from '../components/Plan';
|
import { PlanReduce, type Plan } from '../components/Plan';
|
||||||
import PlanEditorDialog from '../components/PlanEditor';
|
import PlanEditorDialog from '../components/PlanEditor';
|
||||||
@@ -61,8 +62,9 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
|||||||
<div className={"flex-row gap-md"}>Triggers when the condition is met.</div>
|
<div className={"flex-row gap-md"}>Triggers when the condition is met.</div>
|
||||||
<div className={"flex-row gap-md"}>Condition/ Belief is currently {data.condition ? "" : "not"} set. {data.condition ? "🟢" : "🔴"}</div>
|
<div className={"flex-row gap-md"}>Condition/ Belief is currently {data.condition ? "" : "not"} set. {data.condition ? "🟢" : "🔴"}</div>
|
||||||
<div className={"flex-row gap-md"}>Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}</div>
|
<div className={"flex-row gap-md"}>Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}</div>
|
||||||
<Handle type="source" position={Position.Right} id="TriggerSource"/>
|
<MultiConnectionHandle type="source" position={Position.Right} id="TriggerSource" rules={[
|
||||||
<Handle type="target" position={Position.Bottom} id="ConditionTarget"/>
|
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
|
||||||
|
]}/>
|
||||||
<PlanEditorDialog
|
<PlanEditorDialog
|
||||||
plan={data.plan}
|
plan={data.plan}
|
||||||
onSave={(plan) => {
|
onSave={(plan) => {
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {renderHook} from "@testing-library/react";
|
||||||
|
import type {Connection} from "@xyflow/react";
|
||||||
|
import {
|
||||||
|
ruleResult,
|
||||||
|
type RuleResult,
|
||||||
|
useHandleRules
|
||||||
|
} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
|
||||||
|
import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
||||||
|
|
||||||
|
describe('useHandleRules', () => {
|
||||||
|
it('should register rules on mount and validate connection', () => {
|
||||||
|
const rules = [() => ({ isSatisfied: true } as RuleResult)];
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useHandleRules('node1', 'h1', 'target', rules));
|
||||||
|
|
||||||
|
// Confirm rules registered
|
||||||
|
const storedRules = useFlowStore.getState().getTargetRules('node1', 'h1');
|
||||||
|
expect(storedRules).toEqual(rules);
|
||||||
|
|
||||||
|
// Validate a connection
|
||||||
|
const connection = { source: 'node2', sourceHandle: 'h2', target: 'node1', targetHandle: 'h1' };
|
||||||
|
const validation = result.current(connection);
|
||||||
|
expect(validation).toEqual(ruleResult.satisfied);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if targetHandle missing', () => {
|
||||||
|
const rules: any[] = [];
|
||||||
|
const { result } = renderHook(() => useHandleRules('node1', 'h1', 'target', rules));
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
result.current({ source: 'a', target: 'b', targetHandle: null, sourceHandle: null })
|
||||||
|
).toThrow('No target handle was provided');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useHandleRules with multiple failed rules', () => {
|
||||||
|
it('should return the first failed rule message and consider connectionCount', () => {
|
||||||
|
// Mock rules for the target handle
|
||||||
|
const failingRules = [
|
||||||
|
(_conn: any, ctx: any) => {
|
||||||
|
if (ctx.connectionCount >= 1) {
|
||||||
|
return { isSatisfied: false, message: 'Max connections reached' } as RuleResult;
|
||||||
|
}
|
||||||
|
return { isSatisfied: true } as RuleResult;
|
||||||
|
},
|
||||||
|
() => ({ isSatisfied: false, message: 'Other rule failed' } as RuleResult),
|
||||||
|
() => ({ isSatisfied: true } as RuleResult),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Register rules for the target handle
|
||||||
|
useFlowStore.getState().registerRules('targetNode', 'targetHandle', failingRules);
|
||||||
|
|
||||||
|
// Add one existing edge to simulate connectionCount
|
||||||
|
useFlowStore.setState({
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: 'edge-1',
|
||||||
|
source: 'sourceNode',
|
||||||
|
sourceHandle: 'sourceHandle',
|
||||||
|
target: 'targetNode',
|
||||||
|
targetHandle: 'targetHandle',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create hook for a source node handle
|
||||||
|
const rulesForSource = [
|
||||||
|
(_c: Connection) => ({ isSatisfied: true } as RuleResult)
|
||||||
|
];
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useHandleRules('sourceNode', 'sourceHandle', 'source', rulesForSource)
|
||||||
|
);
|
||||||
|
|
||||||
|
const connection = {
|
||||||
|
source: 'sourceNode',
|
||||||
|
sourceHandle: 'sourceHandle',
|
||||||
|
target: 'targetNode',
|
||||||
|
targetHandle: 'targetHandle',
|
||||||
|
};
|
||||||
|
|
||||||
|
const validation = result.current(connection);
|
||||||
|
|
||||||
|
// Should fail with first failing rule message
|
||||||
|
expect(validation).toEqual(ruleResult.notSatisfied('Max connections reached'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {ruleResult} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
|
||||||
|
import {
|
||||||
|
allowOnlyConnectionsFromType,
|
||||||
|
allowOnlyConnectionsFromHandle, noSelfConnections
|
||||||
|
} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts";
|
||||||
|
import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [
|
||||||
|
{ id: 'nodeA', type: 'typeA', position: { x: 0, y: 0 }, data: {} },
|
||||||
|
{ id: 'nodeB', type: 'typeB', position: { x: 0, y: 0 }, data: {} },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('allowOnlyConnectionsFromType', () => {
|
||||||
|
it('should allow connection from allowed node type', () => {
|
||||||
|
const rule = allowOnlyConnectionsFromType(['typeA']);
|
||||||
|
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeB', targetHandle: 'h2' };
|
||||||
|
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
|
||||||
|
|
||||||
|
const result = rule(connection, context);
|
||||||
|
expect(result).toEqual(ruleResult.satisfied);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow connection from disallowed node type', () => {
|
||||||
|
const rule = allowOnlyConnectionsFromType(['typeA']);
|
||||||
|
const connection = { source: 'nodeB', sourceHandle: 'h1', target: 'nodeA', targetHandle: 'h2' };
|
||||||
|
const context = { connectionCount: 0, source: { id: 'nodeB', handleId: 'h1' }, target: { id: 'nodeA', handleId: 'h2' } };
|
||||||
|
|
||||||
|
const result = rule(connection, context);
|
||||||
|
expect(result).toEqual(ruleResult.notSatisfied("the target doesn't allow connections from nodes with type: typeB"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('allowOnlyConnectionsFromHandle', () => {
|
||||||
|
it('should allow connection from node with correct type and handle', () => {
|
||||||
|
const rule = allowOnlyConnectionsFromHandle([{ nodeType: 'typeA', handleId: 'h1' }]);
|
||||||
|
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeB', targetHandle: 'h2' };
|
||||||
|
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
|
||||||
|
|
||||||
|
const result = rule(connection, context);
|
||||||
|
expect(result).toEqual(ruleResult.satisfied);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow connection from node with wrong handle', () => {
|
||||||
|
const rule = allowOnlyConnectionsFromHandle([{ nodeType: 'typeA', handleId: 'h1' }]);
|
||||||
|
const connection = { source: 'nodeA', sourceHandle: 'wrongHandle', target: 'nodeB', targetHandle: 'h2' };
|
||||||
|
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'wrongHandle' }, target: { id: 'nodeB', handleId: 'h2' } };
|
||||||
|
|
||||||
|
const result = rule(connection, context);
|
||||||
|
expect(result).toEqual(ruleResult.notSatisfied("the target doesn't allow connections from nodes with type: typeA"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow connection from node with wrong type', () => {
|
||||||
|
const rule = allowOnlyConnectionsFromHandle([{ nodeType: 'typeA', handleId: 'h1' }]);
|
||||||
|
const connection = { source: 'nodeB', sourceHandle: 'h1', target: 'nodeA', targetHandle: 'h2' };
|
||||||
|
const context = { connectionCount: 0, source: { id: 'nodeB', handleId: 'h1' }, target: { id: 'nodeA', handleId: 'h2' } };
|
||||||
|
|
||||||
|
const result = rule(connection, context);
|
||||||
|
expect(result).toEqual(ruleResult.notSatisfied("the target doesn't allow connections from nodes with type: typeB"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('noSelfConnections', () => {
|
||||||
|
it('should allow connection from node with other type and handle', () => {
|
||||||
|
const rule = noSelfConnections;
|
||||||
|
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeB', targetHandle: 'h2' };
|
||||||
|
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
|
||||||
|
|
||||||
|
const result = rule(connection, context);
|
||||||
|
expect(result).toEqual(ruleResult.satisfied);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow connection from other handle on same node', () => {
|
||||||
|
const rule = noSelfConnections;
|
||||||
|
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeA', targetHandle: 'h2' };
|
||||||
|
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
|
||||||
|
|
||||||
|
const result = rule(connection, context);
|
||||||
|
expect(result).toEqual(ruleResult.notSatisfied("nodes are not allowed to connect to themselves"));
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {act} from '@testing-library/react';
|
import {act} from '@testing-library/react';
|
||||||
import type {Connection, Edge, Node} from "@xyflow/react";
|
import type {Connection, Edge, Node} from "@xyflow/react";
|
||||||
|
import type {HandleRule, RuleResult} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
|
||||||
import { NodeDisconnections } from "../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts";
|
import { NodeDisconnections } from "../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts";
|
||||||
import type {PhaseNodeData} from "../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
|
import type {PhaseNodeData} from "../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
|
||||||
import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
|
import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
|
||||||
@@ -594,5 +595,48 @@ describe('FlowStore Functionality', () => {
|
|||||||
expect(updatedState.nodes).toHaveLength(1);
|
expect(updatedState.nodes).toHaveLength(1);
|
||||||
expect(updatedState.nodes[0]).toMatchObject(expected.node);
|
expect(updatedState.nodes[0]).toMatchObject(expected.node);
|
||||||
})
|
})
|
||||||
|
describe('Handle Rule Registry', () => {
|
||||||
|
it('should register and retrieve rules', () => {
|
||||||
|
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
|
||||||
|
|
||||||
|
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
|
||||||
|
const rules = useFlowStore.getState().getTargetRules('node1', 'handleA');
|
||||||
|
|
||||||
|
expect(rules).toEqual(mockRules);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn and return empty array if rules are missing', () => {
|
||||||
|
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const rules = useFlowStore.getState().getTargetRules('missingNode', 'missingHandle');
|
||||||
|
expect(rules).toEqual([]);
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No rules were registered'));
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unregister a specific handle rule', () => {
|
||||||
|
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
|
||||||
|
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
|
||||||
|
|
||||||
|
useFlowStore.getState().unregisterHandleRules('node1', 'handleA');
|
||||||
|
const rules = useFlowStore.getState().getTargetRules('node1', 'handleA');
|
||||||
|
|
||||||
|
expect(rules).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unregister all rules for a node', () => {
|
||||||
|
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
|
||||||
|
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
|
||||||
|
useFlowStore.getState().registerRules('node1', 'handleB', mockRules);
|
||||||
|
useFlowStore.getState().registerRules('node2', 'handleC', mockRules);
|
||||||
|
|
||||||
|
useFlowStore.getState().unregisterNodeRules('node1');
|
||||||
|
|
||||||
|
expect(useFlowStore.getState().getTargetRules('node1', 'handleA')).toEqual([]);
|
||||||
|
expect(useFlowStore.getState().getTargetRules('node1', 'handleB')).toEqual([]);
|
||||||
|
expect(useFlowStore.getState().getTargetRules('node2', 'handleC')).toEqual(mockRules);
|
||||||
|
});
|
||||||
|
});
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ beforeAll(() => {
|
|||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
isBatchAction: false,
|
isBatchAction: false,
|
||||||
edgeReconnectSuccessful: true
|
edgeReconnectSuccessful: true,
|
||||||
|
ruleRegistry: new Map()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,7 +91,8 @@ afterEach(() => {
|
|||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
isBatchAction: false,
|
isBatchAction: false,
|
||||||
edgeReconnectSuccessful: true
|
edgeReconnectSuccessful: true,
|
||||||
|
ruleRegistry: new Map()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user