Warning 1: if elements have the same name, show a warning.
Warning 2: if a goal/triggerNode has no/empty plan, show a warning.
Warning 3: if (non-phase) elements start with or are a number,
show a warning.
247 lines
9.2 KiB
TypeScript
247 lines
9.2 KiB
TypeScript
// This program has been developed by students from the bachelor Computer Science at Utrecht
|
|
// University within the Software Project course.
|
|
// © Copyright Utrecht University (Department of Information and Computing Sciences)
|
|
import {
|
|
type NodeProps,
|
|
Position,
|
|
type Node
|
|
} from '@xyflow/react';
|
|
import {useEffect} from "react";
|
|
import type {EditorWarning} from "../components/EditorWarnings.tsx";
|
|
import { Toolbar } from '../components/NodeComponents';
|
|
import styles from '../../VisProg.module.css';
|
|
import { TextField } from '../../../../components/TextField';
|
|
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
|
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
|
import useFlowStore from '../VisProgStores';
|
|
import {DoesPlanIterate, HasCheckingSubGoal, PlanReduce, type Plan } from '../components/Plan';
|
|
import PlanEditorDialog from '../components/PlanEditor';
|
|
import { MultilineTextField } from '../../../../components/MultilineTextField';
|
|
import { defaultPlan } from '../components/Plan.default.ts';
|
|
import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
|
|
|
|
/**
|
|
* The default data dot a phase node
|
|
* @param label: the label of this phase
|
|
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
|
* @param desciption: description of the goal - this will be checked for completion
|
|
* @param hasReduce: whether this node has reducing functionality (true by default)
|
|
* @param can_fail: whether this plan should be checked- this plan could possible fail
|
|
* @param plan: The (possible) attached plan to this goal
|
|
*/
|
|
export type GoalNodeData = {
|
|
label: string;
|
|
name: string;
|
|
description: string;
|
|
droppable: boolean;
|
|
achieved: boolean;
|
|
hasReduce: boolean;
|
|
can_fail: boolean;
|
|
plan?: Plan;
|
|
};
|
|
|
|
export type GoalNode = Node<GoalNodeData>
|
|
|
|
|
|
/**
|
|
* Defines how a Goal node should be rendered
|
|
* @param props NodeProps, like id, label, children
|
|
* @returns React.JSX.Element
|
|
*/
|
|
export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
|
const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
|
|
const _nodes = useFlowStore().nodes;
|
|
|
|
const text_input_id = `goal_${id}_text_input`;
|
|
const checkbox_id = `goal_${id}_checkbox`;
|
|
const planIterate = DoesPlanIterate(_nodes, data.plan);
|
|
const hasCheckSubGoal = data.plan !== undefined && HasCheckingSubGoal(data.plan, _nodes)
|
|
|
|
const setDescription = (value: string) => {
|
|
updateNodeData(id, {...data, description: value});
|
|
}
|
|
|
|
const setName= (value: string) => {
|
|
updateNodeData(id, {...data, name: value})
|
|
}
|
|
|
|
const setFailable = (value: boolean) => {
|
|
updateNodeData(id, {...data, can_fail: value});
|
|
}
|
|
|
|
//undefined plan warning
|
|
useEffect(() => {
|
|
const noPlanWarning : EditorWarning = {
|
|
scope: {
|
|
id: id,
|
|
handleId: undefined
|
|
},
|
|
type: 'PLAN_IS_UNDEFINED',
|
|
severity: 'ERROR',
|
|
description: "This goalNode is missing a plan, please make sure to create a plan by using the create plan button"
|
|
};
|
|
|
|
if (!data.plan || data.plan.steps?.length === 0){
|
|
registerWarning(noPlanWarning);
|
|
return;
|
|
}
|
|
unregisterWarning(id, noPlanWarning.type);
|
|
},[data.plan, id, registerWarning, unregisterWarning])
|
|
|
|
//starts with number warning
|
|
useEffect(() => {
|
|
const name = data.name || "";
|
|
|
|
const startsWithNumberWarning: EditorWarning = {
|
|
scope: { id: id },
|
|
type: 'ELEMENT_STARTS_WITH_NUMBER',
|
|
severity: 'ERROR',
|
|
description: "Norms are not allowed to start with a number."
|
|
};
|
|
|
|
if (/^\d/.test(name)) {
|
|
registerWarning(startsWithNumberWarning);
|
|
} else {
|
|
unregisterWarning(id, 'ELEMENT_STARTS_WITH_NUMBER');
|
|
}
|
|
}, [data.name, id, registerWarning, unregisterWarning]);
|
|
|
|
return <>
|
|
<Toolbar nodeId={id} allowDelete={true}/>
|
|
<div className={`${styles.defaultNode} ${styles.nodeGoal} flex-col gap-sm`}>
|
|
<div className={"flex-row gap-md"}>
|
|
<label htmlFor={text_input_id}>Goal:</label>
|
|
<TextField
|
|
id={text_input_id}
|
|
value={data.name}
|
|
setValue={(val) => setName(val)}
|
|
placeholder={"To ..."}
|
|
/>
|
|
</div>
|
|
|
|
{(data.can_fail || hasCheckSubGoal) && (<div>
|
|
<label htmlFor={text_input_id}>Description/ Condition of goal:</label>
|
|
<div className={"flex-wrap"}>
|
|
<MultilineTextField
|
|
id={text_input_id}
|
|
value={data.description}
|
|
setValue={setDescription}
|
|
placeholder={"Describe the condition of this goal..."}
|
|
/>
|
|
</div>
|
|
</div>)}
|
|
<div>
|
|
<label> {!data.plan ? "No plan set to execute while goal is not reached. 🔴" : "Will follow plan '" + data.plan.name + "' until all steps complete. 🟢"} </label>
|
|
</div>
|
|
{data.plan && (<div className={"flex-row gap-md align-center " + (planIterate ? "" : styles.planNoIterate)}>
|
|
{planIterate ? "" : <s></s>}
|
|
<label htmlFor={checkbox_id}>{!planIterate ? "This plan always succeeds!" : "Check if this plan fails"}:</label>
|
|
<input
|
|
id={checkbox_id}
|
|
type={"checkbox"}
|
|
disabled={!planIterate || (data.plan && HasCheckingSubGoal(data.plan, _nodes))}
|
|
checked={!planIterate || data.can_fail || (data.plan && HasCheckingSubGoal(data.plan, _nodes))}
|
|
onChange={(e) => planIterate ? setFailable(e.target.checked) : setFailable(false)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<PlanEditorDialog
|
|
plan={data.plan}
|
|
onSave={(plan) => {
|
|
updateNodeData(id, {
|
|
...data,
|
|
plan,
|
|
});
|
|
}}
|
|
description={data.name}
|
|
/>
|
|
</div>
|
|
<MultiConnectionHandle type="source" position={Position.Right} id="GoalSource" rules={[
|
|
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
|
|
]} title="Connect to any number of phase and/or goalNode(-s)"/>
|
|
|
|
<MultiConnectionHandle type="target" position={Position.Bottom} id="GoalTarget" rules={[
|
|
allowOnlyConnectionsFromType(["goal"])]
|
|
} title="Connect to any number of goalNode(-s)"/>
|
|
|
|
|
|
</div>
|
|
</>;
|
|
}
|
|
|
|
|
|
/**
|
|
* Reduces each Goal, including its children down into its relevant data.
|
|
* @param node The Node Properties of this node.
|
|
* @param _nodes all the nodes in the graph
|
|
*/
|
|
export function GoalReduce(node: Node, _nodes: Node[]) {
|
|
const data = node.data as GoalNodeData;
|
|
return {
|
|
id: node.id,
|
|
name: data.name,
|
|
description: data.description,
|
|
can_fail: data.can_fail || (data.plan && HasCheckingSubGoal(data.plan, _nodes)),
|
|
plan: data.plan ? PlanReduce(_nodes, data.plan) : "",
|
|
}
|
|
}
|
|
|
|
|
|
export const GoalTooltip = `
|
|
The goal node allows you to set goals that Pepper has to achieve
|
|
before moving to the next phase of your program`;
|
|
|
|
/**
|
|
* This function is called whenever a connection is made with this node type as the target
|
|
* @param _thisNode the node of this node type which function is called
|
|
* @param _sourceNodeId the source of the received connection
|
|
*/
|
|
export function GoalConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
|
// Goals should only be targeted by other goals, for them to be part of our plan.
|
|
const nodes = useFlowStore.getState().nodes;
|
|
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
|
|
if (!otherNode || otherNode.type !== "goal") return;
|
|
|
|
const data = _thisNode.data as GoalNodeData
|
|
|
|
// First, let's see if we have a plan currently. If not, let's create a default plan with this goal inside.:)
|
|
if (!data.plan) {
|
|
data.plan = insertGoalInPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()} as Plan, otherNode as GoalNode)
|
|
}
|
|
|
|
// Else, lets just insert this goal into our current plan.
|
|
else {
|
|
data.plan = insertGoalInPlan(structuredClone(data.plan), otherNode as GoalNode)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This function is called whenever a connection is made with this node type as the source
|
|
* @param _thisNode the node of this node type which function is called
|
|
* @param _targetNodeId the target of the created connection
|
|
*/
|
|
export function GoalConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
|
// no additional connection logic exists yet
|
|
}
|
|
|
|
/**
|
|
* This function is called whenever a connection is disconnected with this node type as the target
|
|
* @param _thisNode the node of this node type which function is called
|
|
* @param _sourceNodeId the source of the disconnected connection
|
|
*/
|
|
export function GoalDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
|
// We should probably check if our disconnection was by a goal, since it would mean we have to remove it from our plan list.
|
|
const data = _thisNode.data as GoalNodeData
|
|
data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId)
|
|
}
|
|
|
|
/**
|
|
* This function is called whenever a connection is disconnected with this node type as the source
|
|
* @param _thisNode the node of this node type which function is called
|
|
* @param _targetNodeId the target of the diconnected connection
|
|
*/
|
|
export function GoalDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
|
// no additional connection logic exists yet
|
|
} |