diff --git a/package-lock.json b/package-lock.json index b225239..a52343e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.3", + "baseline-browser-mapping": "^2.9.11", "eslint": "^9.36.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", @@ -3698,9 +3699,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz", - "integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4869,9 +4870,9 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index cd08dca..45f66df 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.3", + "baseline-browser-mapping": "^2.9.11", "eslint": "^9.36.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", diff --git a/src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts b/src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts new file mode 100644 index 0000000..427542a --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts @@ -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); + }; +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts b/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts new file mode 100644 index 0000000..a04282c --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts @@ -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"); + } + + diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index ca746e5..a70e54d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -8,6 +8,7 @@ import { type Edge, type XYPosition, } from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; import type { FlowState } from './VisProgTypes'; import { NodeDefaults, @@ -50,8 +51,9 @@ function createNode(id: string, type: string, position: XYPosition, data: Record const initialNodes : Node[] = [startNode, endNode, initialPhaseNode,]; -// * Initial edges * / -const initialEdges: Edge[] = []; // no initial edges as edge connect events don't fire when using initial edges +// Initial edges, leave empty as setting initial edges... +// ...breaks logic that is dependent on connection events +const initialEdges: Edge[] = []; /** @@ -84,8 +86,9 @@ const useFlowStore = create(UndoRedo((set, get) => ({ */ 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 edges.forEach((edge) => { const nodes = get().nodes; @@ -231,6 +234,79 @@ const useFlowStore = create(UndoRedo((set, get) => ({ past: [], future: [], isBatchAction: false, + + // handleRuleRegistry definitions + /** + * stores registered rules for handle connection validation + */ + ruleRegistry: new Map(), + + /** + * gets the rules registered by that handle described by the given node and handle ids + * + * @param {string} targetNodeId + * @param {string} targetHandleId + * @returns {HandleRule[]} + */ + getTargetRules: (targetNodeId, targetHandleId) => { + const key = `${targetNodeId}:${targetHandleId}`; + const rules = get().ruleRegistry.get(key); + + // helper function that handles a situation where no rules were registered + const missingRulesResponse = () => { + console.warn( + `No rules were registered for the following handle "${key}"! + returning and empty handleRule[] to avoid crashing`); + return [] + } + + return rules + ? rules + : missingRulesResponse() + }, + + /** + * registers a handle's connection rules + * + * @param {string} nodeId + * @param {string} handleId + * @param {HandleRule[]} rules + */ + registerRules: (nodeId, handleId, rules) => { + const registry = get().ruleRegistry; + registry.set(`${nodeId}:${handleId}`, rules); + set({ ruleRegistry: registry }) ; + }, + + /** + * unregisters a handles connection rules + * + * @param {string} nodeId + * @param {string} handleId + */ + unregisterHandleRules: (nodeId, handleId) => { + set( () => { + const registry = get().ruleRegistry; + registry.delete(`${nodeId}:${handleId}`); + return { ruleRegistry: registry }; + }) + }, + + /** + * unregisters connection rules for all handles on the given node + * used for cleaning up rules on node deletion + * + * @param {string} nodeId + */ + unregisterNodeRules: (nodeId) => { + set(() => { + const registry = get().ruleRegistry; + registry.forEach((_,key) => { + if (key.startsWith(`${nodeId}:`)) registry.delete(key) + }) + return { ruleRegistry: registry }; + }) + } })) ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index ab6674b..8ae3cad 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -1,5 +1,15 @@ // 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 {FlowSnapshot} from "./EditorUndoRedo.ts"; @@ -31,6 +41,8 @@ export type FlowState = { /** Handler for changes to nodes triggered by ReactFlow */ onNodesChange: OnNodesChange; + onNodesDelete: OnNodesDelete; + onEdgesDelete: OnEdgesDelete; /** Handler for changes to edges triggered by ReactFlow */ @@ -82,7 +94,9 @@ export type FlowState = { * @param node - the Node object to add */ addNode: (node: Node) => void; +} & UndoRedoState & HandleRuleRegistry; +export type UndoRedoState = { // UndoRedo Types past: FlowSnapshot[]; future: FlowSnapshot[]; @@ -92,4 +106,27 @@ export type FlowState = { endBatchAction: () => void; undo: () => void; redo: () => void; -}; +} + +export type HandleRuleRegistry = { + ruleRegistry: Map; + + 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 +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/CustomNodeHandles.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/CustomNodeHandles.tsx deleted file mode 100644 index 853c488..0000000 --- a/src/pages/VisProgPage/visualProgrammingUI/components/CustomNodeHandles.tsx +++ /dev/null @@ -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 ( - - ); -}; - -export default LimitedConnectionCountHandle; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css new file mode 100644 index 0000000..48d2351 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css @@ -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); +} + + diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx new file mode 100644 index 0000000..2d3299d --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx @@ -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 ( + { + 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 ( + { + const result = validate(connection as Connection); + setHandleState(result); + return result.isSatisfied; + }} + title={handleState.message} + /> + ); +} + diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index e469c28..06df1e8 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -1,11 +1,12 @@ import { - Handle, type NodeProps, Position, type Node, } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; +import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx"; +import {allowOnlyConnectionsFromType} from "../HandleRules.ts"; import useFlowStore from '../VisProgStores'; import { TextField } from '../../../../components/TextField'; import { MultilineTextField } from '../../../../components/MultilineTextField'; @@ -183,7 +184,9 @@ export default function BasicBeliefNode(props: NodeProps) { /> )} - + ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx index 116dc01..10fb038 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -3,9 +3,11 @@ import { Position, type Node, } from '@xyflow/react'; -import LimitedConnectionCountHandle from "../components/CustomNodeHandles.tsx"; import { Toolbar } from '../components/NodeComponents'; 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) {
End
- + ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 1ab883f..47d7266 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -1,5 +1,4 @@ import { - Handle, type NodeProps, Position, type Node, @@ -7,6 +6,8 @@ import { import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import { TextField } from '../../../../components/TextField'; +import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx"; +import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts"; import useFlowStore from '../VisProgStores'; import { DoesPlanIterate, PlanReduce, type Plan } from '../components/Plan'; import PlanEditorDialog from '../components/PlanEditor'; @@ -111,7 +112,9 @@ export default function GoalNode({id, data}: NodeProps) { description={data.name} /> - + ; } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index ac0a815..d819861 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -1,5 +1,4 @@ import { - Handle, type NodeProps, Position, type Node, @@ -7,6 +6,8 @@ import { import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import { TextField } from '../../../../components/TextField'; +import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx"; +import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts"; import useFlowStore from '../VisProgStores'; import { BasicBeliefReduce } from './BasicBeliefNode'; @@ -75,9 +76,13 @@ export default function NormNode(props: NodeProps) { )} - - - + + + ; }; @@ -86,7 +91,7 @@ export default function NormNode(props: NodeProps) { /** * Reduces each Norm, including its children down into its relevant data. * @param node The Node Properties of this node. - * @param _nodes all the nodes in the graph + * @param nodes all the nodes in the graph */ export function NormReduce(node: Node, nodes: Node[]) { const data = node.data as NormNodeData; diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 441e84f..1c1f849 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -1,15 +1,15 @@ import { - Handle, type NodeProps, Position, type Node } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; +import {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx"; +import {allowOnlyConnectionsFromType, noSelfConnections} from "../HandleRules.ts"; import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry'; import useFlowStore from '../VisProgStores'; import { TextField } from '../../../../components/TextField'; -import LimitedConnectionCountHandle from "../components/CustomNodeHandles.tsx"; /** * The default data dot a phase node @@ -54,21 +54,17 @@ export default function PhaseNode(props: NodeProps) { placeholder={"Phase ..."} /> - - - + + + ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index 13f3fc8..a148587 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -3,9 +3,10 @@ import { Position, type Node, } from '@xyflow/react'; -import LimitedConnectionCountHandle from "../components/CustomNodeHandles.tsx"; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; +import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; +import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts"; export type StartNodeData = { @@ -31,13 +32,9 @@ export default function StartNode(props: NodeProps) {
Start
- + ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 1778d32..39eace8 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -1,5 +1,4 @@ import { - Handle, type NodeProps, Position, type Connection, @@ -8,6 +7,8 @@ import { } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; +import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx"; +import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts"; import useFlowStore from '../VisProgStores'; import { PlanReduce, type Plan } from '../components/Plan'; import PlanEditorDialog from '../components/PlanEditor'; @@ -61,8 +62,9 @@ export default function TriggerNode(props: NodeProps) {
Triggers when the condition is met.
Condition/ Belief is currently {data.condition ? "" : "not"} set. {data.condition ? "🟢" : "🔴"}
Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}
- - + { diff --git a/test/pages/visProgPage/visualProgrammingUI/HandleRuleLogic.test.ts b/test/pages/visProgPage/visualProgrammingUI/HandleRuleLogic.test.ts new file mode 100644 index 0000000..fbeddb1 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/HandleRuleLogic.test.ts @@ -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')); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/HandleRules.test.ts b/test/pages/visProgPage/visualProgrammingUI/HandleRules.test.ts new file mode 100644 index 0000000..65abe19 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/HandleRules.test.ts @@ -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")); + }); + +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx index 8ce8e18..fa98048 100644 --- a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -1,5 +1,6 @@ import {act} from '@testing-library/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 type {PhaseNodeData} from "../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.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[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); + }); + }); +}) }); diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts index aaffff0..e3382c6 100644 --- a/test/setupFlowTests.ts +++ b/test/setupFlowTests.ts @@ -78,7 +78,8 @@ beforeAll(() => { past: [], future: [], isBatchAction: false, - edgeReconnectSuccessful: true + edgeReconnectSuccessful: true, + ruleRegistry: new Map() }); }); @@ -90,7 +91,8 @@ afterEach(() => { past: [], future: [], isBatchAction: false, - edgeReconnectSuccessful: true + edgeReconnectSuccessful: true, + ruleRegistry: new Map() }); });