No program loaded.
;
- }
-
- const phaseId = phaseIds[phaseIndex];
-
- return (
-
+
{
onNodeDragStop={endBatchAction}
preventScrolling={scrollable}
onMove={(_, viewport) => setZoom(viewport.zoom)}
+ reconnectRadius={15}
snapToGrid
fitView
proOptions={{hideAttribution: true}}
+ style={{flexGrow: 3}}
>
{/* contains the drag and drop panel for nodes */}
@@ -119,12 +147,16 @@ const VisProgUI = () => {
- undo()}>undo
+ undo()}>Undo
redo()}>Redo
+
+
+
+
);
};
@@ -143,7 +175,24 @@ function VisualProgrammingUI() {
);
}
-
+
+const checkPhaseChain = (): boolean => {
+ const {nodes, edges} = useFlowStore.getState();
+
+ function checkForCompleteChain(currentNodeId: string): boolean {
+ const outgoingPhases = getOutgoers({id: currentNodeId}, nodes, edges)
+ .filter(node => ["end", "phase"].includes(node.type!));
+
+ if (outgoingPhases.length === 0) return false;
+ if (outgoingPhases.some(node => node.type === "end" )) return true;
+
+ const next = outgoingPhases.map(node => checkForCompleteChain(node.id))
+ .find(result => result);
+ return !!next;
+ }
+
+ return checkForCompleteChain('start');
+};
/**
* houses the entire page, so also UI elements
@@ -152,9 +201,20 @@ function VisualProgrammingUI() {
*/
function VisProgPage() {
const [showSimpleProgram, setShowSimpleProgram] = useState(false);
+ const [programValidity, setProgramValidity] = useState
(true);
+ const {isProgramValid, severityIndex} = useFlowStore();
const setProgramState = useProgramStore((state) => state.setProgramState);
- const runProgram = () => {
+ const validity = () => {return isProgramValid();}
+
+ useEffect(() => {
+ setProgramValidity(validity);
+ // the following eslint disable is required as it wants us to use all possible dependencies for the useEffect statement,
+ // however this would cause unneeded updates
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [severityIndex]);
+
+ const processProgram = () => {
const phases = graphReducer(); // reduce graph
setProgramState({ phases }); // <-- save to store
setShowSimpleProgram(true); // show SimpleProgram
@@ -165,17 +225,19 @@ function VisProgPage() {
return (
setShowSimpleProgram(false)}>
- Back to Editor ◀
+ Back to Editor ◀
);
}
+
+
return (
<>
- run program
+ Run Program
>
)
}
diff --git a/src/pages/VisProgPage/VisProgLogic.tsx b/src/pages/VisProgPage/VisProgLogic.tsx
new file mode 100644
index 0000000..3753a3f
--- /dev/null
+++ b/src/pages/VisProgPage/VisProgLogic.tsx
@@ -0,0 +1,43 @@
+import useProgramStore from "../../utils/programStore";
+import orderPhaseNodeArray from "../../utils/orderPhaseNodes";
+import useFlowStore from './visualProgrammingUI/VisProgStores';
+import { NodeReduces } from './visualProgrammingUI/NodeRegistry';
+import type { PhaseNode } from "./visualProgrammingUI/nodes/PhaseNode";
+
+/**
+ * Reduces the graph into its phases' information and recursively calls their reducing function
+ */
+export function graphReducer() {
+ const { nodes } = useFlowStore.getState();
+ return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode [])
+ .map((n) => {
+ const reducer = NodeReduces['phase'];
+ return reducer(n, nodes)
+ });
+}
+
+
+/**
+ * Outputs the prepared program to the console and sends it to the backend
+ */
+export function runProgram() {
+ const phases = graphReducer();
+ const program = {phases}
+ console.log(JSON.stringify(program, null, 2));
+ fetch(
+ "http://localhost:8000/program",
+ {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify(program),
+ }
+ ).then((res) => {
+ if (!res.ok) throw new Error("Failed communicating with the backend.")
+ console.log("Successfully sent the program to the backend.");
+
+ // store reduced program in global program store for further use in the UI
+ // when the program was sent to the backend successfully:
+ useProgramStore.getState().setProgramState(structuredClone(program));
+ }).catch(() => console.log("Failed to send program to the backend."));
+ console.log(program);
+}
\ No newline at end of file
diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts
index 6ad705d..4e45148 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts
+++ b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts
@@ -1,10 +1,18 @@
import type {Edge, Node} from "@xyflow/react";
import type {StateCreator, StoreApi } from 'zustand/vanilla';
+import type {
+ SeverityIndex,
+ WarningRegistry
+} from "./components/EditorWarnings.tsx";
import type {FlowState} from "./VisProgTypes.tsx";
export type FlowSnapshot = {
nodes: Node[];
edges: Edge[];
+ warnings: {
+ warningRegistry: WarningRegistry;
+ severityIndex: SeverityIndex;
+ }
}
/**
@@ -41,7 +49,11 @@ export const UndoRedo = (
*/
const getSnapshot = (state : BaseFlowState) : FlowSnapshot => (structuredClone({
nodes: state.nodes,
- edges: state.edges
+ edges: state.edges,
+ warnings: {
+ warningRegistry: state.editorWarningRegistry,
+ severityIndex: state.severityIndex,
+ }
}));
const initialState = config(set, get, api);
@@ -78,6 +90,8 @@ export const UndoRedo = (
set({
nodes: snapshot.nodes,
edges: snapshot.edges,
+ editorWarningRegistry: snapshot.warnings.warningRegistry,
+ severityIndex: snapshot.warnings.severityIndex,
});
state.future.push(currentSnapshot); // push current to redo
@@ -97,6 +111,8 @@ export const UndoRedo = (
set({
nodes: snapshot.nodes,
edges: snapshot.edges,
+ editorWarningRegistry: snapshot.warnings.warningRegistry,
+ severityIndex: snapshot.warnings.severityIndex,
});
state.past.push(currentSnapshot); // push current to undo
diff --git a/src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts b/src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts
index 427542a..e212ed2 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts
+++ b/src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts
@@ -107,4 +107,16 @@ export function useHandleRules(
// finally we return a function that evaluates all rules using the created context
return evaluateRules(targetRules, connection, context);
};
+}
+
+export function validateConnectionWithRules(
+ connection: Connection,
+ context: ConnectionContext
+): RuleResult {
+ const rules = useFlowStore.getState().getTargetRules(
+ connection.target!,
+ connection.targetHandle!
+ );
+
+ return evaluateRules(rules,connection, context);
}
\ No newline at end of file
diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx
index defa934..65df21f 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx
@@ -9,6 +9,8 @@ import {
type XYPosition,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
+import {type ConnectionContext, validateConnectionWithRules} from "./HandleRuleLogic.ts";
+import {editorWarningRegistry} from "./components/EditorWarnings.tsx";
import type { FlowState } from './VisProgTypes';
import {
NodeDefaults,
@@ -43,19 +45,18 @@ function createNode(id: string, type: string, position: XYPosition, data: Record
}
}
- //* Initial nodes, created by using createNode. */
- // Start and End don't need to apply the UUID, since they are technically never compiled into a program.
- const startNode = createNode('start', 'start', {x: 110, y: 100}, {label: "Start"}, false)
- const endNode = createNode('end', 'end', {x: 590, y: 100}, {label: "End"}, false)
- const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:235, y:100}, {label: "Phase 1", children : [], isFirstPhase: false, nextPhaseId: null})
+//* Initial nodes, created by using createNode. */
+// Start and End don't need to apply the UUID, since they are technically never compiled into a program.
+const startNode = createNode('start', 'start', {x: 110, y: 100}, {label: "Start"}, false)
+const endNode = createNode('end', 'end', {x: 590, y: 100}, {label: "End"}, false)
+const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:235, y:100}, {label: "Phase 1", children : [], isFirstPhase: false, nextPhaseId: null})
- const initialNodes : Node[] = [startNode, endNode, initialPhaseNode,];
+const initialNodes : Node[] = [startNode, endNode, initialPhaseNode];
// Initial edges, leave empty as setting initial edges...
// ...breaks logic that is dependent on connection events
const initialEdges: Edge[] = [];
-
/**
* useFlowStore contains the implementation for all editor functionality
* and stores the current state of the visual programming editor
@@ -86,7 +87,9 @@ const useFlowStore = create(UndoRedo((set, get) => ({
*/
onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}),
- onNodesDelete: (nodes) => nodes.forEach(node => get().unregisterNodeRules(node.id)),
+ onNodesDelete: (nodes) => nodes.forEach((_node) => {
+ return;
+ }),
onEdgesDelete: (edges) => {
// we make sure any affected nodes get updated to reflect removal of edges
@@ -129,7 +132,41 @@ const useFlowStore = create(UndoRedo((set, get) => ({
* Handles reconnecting an edge between nodes.
*/
onReconnect: (oldEdge, newConnection) => {
- get().edgeReconnectSuccessful = true;
+
+ function createContext(
+ source: {id: string, handleId: string},
+ target: {id: string, handleId: string}
+ ) : ConnectionContext {
+ const edges = get().edges;
+ const targetConnections = edges.filter(edge => edge.target === target.id && edge.targetHandle === target.handleId).length
+ return {
+ connectionCount: targetConnections,
+ source: source,
+ target: target
+ }
+ }
+
+ // connection validation
+ const context: ConnectionContext = oldEdge.source === newConnection.source
+ ? createContext({id: newConnection.source, handleId: newConnection.sourceHandle!}, {id: newConnection.target, handleId: newConnection.targetHandle!})
+ : createContext({id: newConnection.target, handleId: newConnection.targetHandle!}, {id: newConnection.source, handleId: newConnection.sourceHandle!});
+
+ const result = validateConnectionWithRules(
+ newConnection,
+ context
+ );
+
+ if (!result.isSatisfied) {
+ set({
+ edges: get().edges.map(e =>
+ e.id === oldEdge.id ? oldEdge : e
+ ),
+ });
+ return;
+ }
+
+ // further reconnect logic
+ set({ edgeReconnectSuccessful: true });
set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) });
// We make sure to perform any required data updates on the newly reconnected nodes
@@ -182,19 +219,32 @@ const useFlowStore = create(UndoRedo((set, get) => ({
* Deletes a node by ID, respecting NodeDeletes rules.
* Also removes all edges connected to that node.
*/
- deleteNode: (nodeId) => {
+ deleteNode: (nodeId, deleteElements) => {
get().pushSnapshot();
// Let's find our node to check if they have a special deletion function
const ourNode = get().nodes.find((n)=>n.id==nodeId);
const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1]
-
+
+
+
// If there's no function, OR, our function tells us we can delete it, let's do so...
if (ourFunction == undefined || ourFunction()) {
- set({
- nodes: get().nodes.filter((n) => n.id !== nodeId),
- edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
- })}
+ if (deleteElements){
+ deleteElements({
+ nodes: get().nodes.filter((n) => n.id === nodeId),
+ edges: get().edges.filter((e) => e.source !== nodeId && e.target === nodeId)}
+ ).then(() => {
+ get().unregisterNodeRules(nodeId);
+ get().unregisterWarningsForId(nodeId);
+ });
+ } else {
+ set({
+ nodes: get().nodes.filter((n) => n.id !== nodeId),
+ edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
+ })
+ }
+ }
},
/**
@@ -306,8 +356,12 @@ const useFlowStore = create(UndoRedo((set, get) => ({
})
return { ruleRegistry: registry };
})
- }
+ },
+ ...editorWarningRegistry(get, set),
}))
);
+
+
export default useFlowStore;
+
diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx
index 8ae3cad..a34a3e7 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx
@@ -7,8 +7,9 @@ import type {
OnReconnect,
Node,
OnEdgesDelete,
- OnNodesDelete
+ OnNodesDelete, DeleteElementsOptions
} from '@xyflow/react';
+import type {EditorWarningRegistry} from "./components/EditorWarnings.tsx";
import type {HandleRule} from "./HandleRuleLogic.ts";
import type { NodeTypes } from './NodeRegistry';
import type {FlowSnapshot} from "./EditorUndoRedo.ts";
@@ -68,7 +69,10 @@ export type FlowState = {
* Deletes a node and any connected edges.
* @param nodeId - the ID of the node to delete
*/
- deleteNode: (nodeId: string) => void;
+ deleteNode: (nodeId: string, deleteElements?: (params: DeleteElementsOptions) => Promise<{
+ deletedNodes: Node[]
+ deletedEdges: Edge[]
+ }>) => void;
/**
* Replaces the current nodes array in the store.
@@ -94,7 +98,7 @@ export type FlowState = {
* @param node - the Node object to add
*/
addNode: (node: Node) => void;
-} & UndoRedoState & HandleRuleRegistry;
+} & UndoRedoState & HandleRuleRegistry & EditorWarningRegistry;
export type UndoRedoState = {
// UndoRedo Types
@@ -129,4 +133,7 @@ export type HandleRuleRegistry = {
// 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/EditorWarnings.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx
new file mode 100644
index 0000000..497aac6
--- /dev/null
+++ b/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx
@@ -0,0 +1,245 @@
+/* contains all logic for the VisProgEditor warning system
+*
+* Missing but desirable features:
+* - Warning filtering:
+* - if there is no completely connected chain of startNode-[PhaseNodes]-EndNode
+* then hide any startNode, phaseNode, or endNode specific warnings
+*/
+import useFlowStore from "../VisProgStores.tsx";
+import type {FlowState} from "../VisProgTypes.tsx";
+
+// --| Type definitions |--
+
+export type WarningId = NodeId | "GLOBAL_WARNINGS";
+export type NodeId = string;
+
+
+export type WarningType =
+ | 'MISSING_INPUT'
+ | 'MISSING_OUTPUT'
+ | 'PLAN_IS_UNDEFINED'
+ | 'INCOMPLETE_PROGRAM'
+ | 'NOT_CONNECTED_TO_PROGRAM'
+ | string
+
+export type WarningSeverity =
+ | 'INFO' // Acceptable, but important to be aware of
+ | 'WARNING' // Acceptable, but probably undesirable behavior
+ | 'ERROR' // Prevents running program, should be fixed before running program is allowed
+
+/**
+ * warning scope, include a handleId if the warning is handle specific
+ */
+export type WarningScope = {
+ id: string;
+ handleId?: string;
+}
+
+export type EditorWarning = {
+ scope: WarningScope;
+ type: WarningType;
+ severity: WarningSeverity;
+ description: string;
+};
+
+/**
+ * a scoped WarningKey,
+ * the handleId scoping is only needed for handle specific errors
+ *
+ * "`WarningType`:`handleId`"
+ */
+export type WarningKey = string; // for warnings that can occur on a per-handle basis
+
+/**
+ * a composite key used in the severityIndex
+ *
+ * "`WarningId`|`WarningKey`"
+ */
+export type CompositeWarningKey = string;
+
+export type WarningRegistry = Map>;
+export type SeverityIndex = Map>;
+
+type ZustandSet = (partial: Partial | ((state: FlowState) => Partial)) => void;
+type ZustandGet = () => FlowState;
+
+export type EditorWarningRegistry = {
+ /**
+ * stores all editor warnings
+ */
+ editorWarningRegistry: WarningRegistry;
+ /**
+ * index of warnings by severity
+ */
+ severityIndex: SeverityIndex;
+
+ /**
+ * gets all warnings and returns them as a list of warnings
+ * @returns {EditorWarning[]}
+ */
+ getWarnings: () => EditorWarning[];
+
+ /**
+ * gets all warnings with the current severity
+ * @param {WarningSeverity} warningSeverity
+ * @returns {EditorWarning[]}
+ */
+ getWarningsBySeverity: (warningSeverity: WarningSeverity) => EditorWarning[];
+
+ /**
+ * checks if there are no warnings of breaking severity
+ * @returns {boolean}
+ */
+ isProgramValid: () => boolean;
+
+ /**
+ * registers a warning to the warningRegistry and the SeverityIndex
+ * @param {EditorWarning} warning
+ */
+ registerWarning: (warning: EditorWarning) => void;
+
+ /**
+ * unregisters a warning from the warningRegistry and the SeverityIndex
+ * @param {EditorWarning} warning
+ */
+ unregisterWarning: (id: WarningId, warningKey: WarningKey) => void
+
+ /**
+ * unregisters warnings from the warningRegistry and the SeverityIndex
+ * @param {WarningId} warning
+ */
+ unregisterWarningsForId: (id: WarningId) => void;
+}
+
+// --| implemented logic |--
+
+/**
+ * the id to use for global editor warnings
+ * @type {string}
+ */
+export const globalWarning = "GLOBAL_WARNINGS";
+
+export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : EditorWarningRegistry { return {
+ editorWarningRegistry: new Map>(),
+ severityIndex: new Map([
+ ['INFO', new Set()],
+ ['WARNING', new Set()],
+ ['ERROR', new Set()],
+ ]),
+
+ getWarningsBySeverity: (warningSeverity) => {
+ const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
+ const sIndex = new Map(get().severityIndex);
+ const warningKeys = sIndex.get(warningSeverity);
+ const warnings: EditorWarning[] = [];
+
+ warningKeys?.forEach(
+ (compositeKey) => {
+ const [id, warningKey] = compositeKey.split('|');
+ const warning = wRegistry.get(id)?.get(warningKey);
+
+ if (warning) {
+ warnings.push(warning);
+ }
+ }
+ )
+
+ return warnings;
+ },
+
+ isProgramValid: () => {
+ const sIndex = get().severityIndex;
+ return (sIndex.get("ERROR")!.size === 0);
+ },
+
+ getWarnings: () => Array.from(get().editorWarningRegistry.values())
+ .flatMap(innerMap => Array.from(innerMap.values())),
+
+
+ registerWarning: (warning) => {
+ const { scope: {id, handleId}, type, severity } = warning;
+ const warningKey = handleId ? `${type}:${handleId}` : type;
+ const compositeKey = `${id}|${warningKey}`;
+ const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
+ const sIndex = new Map(get().severityIndex);
+ // add to warning registry
+ if (!wRegistry.has(id)) {
+ wRegistry.set(id, new Map());
+ }
+ wRegistry.get(id)!.set(warningKey, warning);
+
+
+ // add to severityIndex
+ if (!sIndex.get(severity)!.has(compositeKey)) {
+ sIndex.get(severity)!.add(compositeKey);
+ }
+
+ set({
+ editorWarningRegistry: wRegistry,
+ severityIndex: sIndex
+ })
+ },
+
+ unregisterWarning: (id, warningKey) => {
+ const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
+ const sIndex = new Map(get().severityIndex);
+ // verify if the warning was created already
+ const warning = wRegistry.get(id)?.get(warningKey);
+ if (!warning) return;
+
+ // remove from warning registry
+ wRegistry.get(id)!.delete(warningKey);
+
+
+ // remove from severityIndex
+ sIndex.get(warning.severity)!.delete(`${id}|${warningKey}`);
+
+ set({
+ editorWarningRegistry: wRegistry,
+ severityIndex: sIndex
+ })
+ },
+
+ unregisterWarningsForId: (id) => {
+ const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
+ const sIndex = new Map(get().severityIndex);
+
+ const nodeWarnings = wRegistry.get(id);
+
+ // remove from severity index
+ if (nodeWarnings) {
+ nodeWarnings.forEach((warning) => {
+ const warningKey = warning.scope.handleId
+ ? `${warning.type}:${warning.scope.handleId}`
+ : warning.type;
+ sIndex.get(warning.severity)?.delete(`${id}|${warningKey}`);
+ });
+ }
+
+ // remove from warning registry
+ wRegistry.delete(id);
+
+ set({
+ editorWarningRegistry: wRegistry,
+ severityIndex: sIndex
+ })
+ },
+}}
+
+
+
+/**
+ * returns a summary of the warningRegistry
+ * @returns {{info: number, warning: number, error: number, isValid: boolean}}
+ */
+export function warningSummary() {
+ const {severityIndex, isProgramValid} = useFlowStore.getState();
+ return {
+ info: severityIndex.get('INFO')!.size,
+ warning: severityIndex.get('WARNING')!.size,
+ error: severityIndex.get('ERROR')!.size,
+ isValid: isProgramValid(),
+ };
+}
+
+
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx
index 38f03a1..2d9bbd8 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx
@@ -1,4 +1,4 @@
-import {NodeToolbar} from '@xyflow/react';
+import {NodeToolbar, useReactFlow} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {type JSX, useState} from "react";
import {createPortal} from "react-dom";
@@ -30,10 +30,11 @@ type ToolbarProps = {
*/
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
const {nodes, deleteNode} = useFlowStore();
-
+ const { deleteElements } = useReactFlow();
const deleteParentNode = () => {
- deleteNode(nodeId);
+
+ deleteNode(nodeId, deleteElements);
};
const nodeType = nodes.find((node) => node.id === nodeId)?.type as keyof typeof NodeTooltips;
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css
index e0aa5de..582ec2d 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css
+++ b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css
@@ -1,7 +1,16 @@
+:global(.react-flow__handle.source){
+ border-radius: 100%;
+}
+:global(.react-flow__handle.target){
+ border-radius: 15%;
+}
+
+
+
:global(.react-flow__handle.connected) {
background: lightgray;
border-color: green;
- filter: drop-shadow(0 0 0.25rem green);
+ filter: drop-shadow(0 0 0.15rem green);
}
:global(.singleConnectionHandle.connected) {
@@ -16,19 +25,19 @@
:global(.singleConnectionHandle.unconnected){
background: lightsalmon;
border-color: #ff6060;
- filter: drop-shadow(0 0 0.25rem #ff6060);
+ filter: drop-shadow(0 0 0.15rem #ff6060);
}
:global(.react-flow__handle.connectingto) {
background: #ff6060;
border-color: coral;
- filter: drop-shadow(0 0 0.25rem coral);
+ filter: drop-shadow(0 0 0.15rem coral);
}
:global(.react-flow__handle.valid) {
background: #55dd99;
border-color: green;
- filter: drop-shadow(0 0 0.25rem green);
+ filter: drop-shadow(0 0 0.15rem green);
}
:global(.react-flow__handle) {
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx
index 2d3299d..2026b00 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx
@@ -4,7 +4,6 @@ import {
type Connection,
useNodeId, useNodeConnections
} from '@xyflow/react';
-import {useState} from 'react';
import { type HandleRule, useHandleRules} from "../HandleRuleLogic.ts";
import "./RuleBasedHandle.module.css";
@@ -29,21 +28,16 @@ export function MultiConnectionHandle({
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}
/>
);
}
@@ -66,22 +60,18 @@ export function SingleConnectionHandle({
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/components/SaveLoadPanel.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx
index baac724..8cf4146 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx
@@ -29,6 +29,8 @@ export default function SaveLoadPanel() {
const text = await file.text();
const parsed = JSON.parse(text) as SavedProject;
if (!parsed.nodes || !parsed.edges) throw new Error("Invalid file format");
+ const {nodes, unregisterWarningsForId} = useFlowStore.getState();
+ nodes.forEach((node) => {unregisterWarningsForId(node.id);});
setNodes(parsed.nodes);
setEdges(parsed.edges);
} catch (e) {
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css
new file mode 100644
index 0000000..82168dc
--- /dev/null
+++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css
@@ -0,0 +1,203 @@
+.warnings-sidebar {
+ min-width: auto;
+ max-width: 340px;
+ margin-right: 0;
+ height: 100%;
+ background: canvas;
+ display: flex;
+ flex-direction: row;
+}
+
+.warnings-toggle-bar {
+ background-color: ButtonFace;
+ justify-items: center;
+ align-content: center;
+ width: 1rem;
+ cursor: pointer;
+}
+
+.warnings-toggle-bar.error:first-child:has(.arrow-right){
+ background-color: hsl(from red h s 75%);
+}
+.warnings-toggle-bar.warning:first-child:has(.arrow-right) {
+ background-color: hsl(from orange h s 75%);
+}
+.warnings-toggle-bar.info:first-child:has(.arrow-right) {
+ background-color: hsl(from steelblue h s 75%);
+}
+
+.warnings-toggle-bar:hover {
+ background-color: GrayText !important ;
+ .arrow-left {
+ border-right-color: ButtonFace;
+ transition: transform 0.15s ease-in-out;
+ transform: rotateY(180deg);
+ }
+ .arrow-right {
+ border-left-color: ButtonFace;
+ transition: transform 0.15s ease-in-out;
+ transform: rotateY(180deg);
+ }
+}
+
+
+.warnings-content {
+ width: 320px;
+ flex: 1;
+ flex-direction: column;
+ border-left: 2px solid CanvasText;
+}
+
+.warnings-header {
+ padding: 12px;
+ border-bottom: 2px solid CanvasText;
+}
+
+.severity-tabs {
+ display: flex;
+ gap: 4px;
+}
+
+.severity-tab {
+ flex: 1;
+ padding: 4px;
+ background: ButtonFace;
+ color: GrayText;
+ border: none;
+ cursor: pointer;
+}
+
+.count {
+ padding: 4px;
+ color: GrayText;
+ border: none;
+ cursor: pointer;
+}
+
+.severity-tab.active {
+ color: ButtonText;
+ border: 2px solid currentColor;
+ .count {
+ color: ButtonText;
+ }
+}
+
+.warning-group-header {
+ background: ButtonFace;
+ padding: 6px;
+ font-weight: bold;
+}
+
+.warnings-list {
+ flex: 1;
+ min-height: 0;
+ overflow-y: scroll;
+}
+
+.warnings-empty {
+ margin: auto;
+}
+
+.warning-item {
+ display: flex;
+ flex-direction: column;
+ margin: 5px;
+ gap: 2px;
+ padding: 0;
+ border-radius: 5px;
+ cursor: pointer;
+ color: GrayText;
+}
+
+.warning-item:hover {
+ background: ButtonFace;
+}
+
+.warning-item--error {
+ border: 2px solid red;
+ background-color: hsl(from red h s 96%);
+ .item-header{
+ background-color: red;
+ .type{
+ color: hsl(from red h s 96%);
+ }
+ }
+
+}
+
+.warning-item--error:hover {
+ background-color: hsl(from red h s 75%);
+}
+
+.warning-item--warning {
+ border: 2px solid orange;
+ background-color: hsl(from orange h s 96%);
+ .item-header{
+ background-color: orange;
+ .type{
+ color: hsl(from orange h s 96%);
+ }
+ }
+}
+
+.warning-item--warning:hover {
+ background-color: hsl(from orange h s 75%);
+}
+
+.warning-item--info {
+ border: 2px solid steelblue;
+ background-color: hsl(from steelblue h s 96%);
+ .item-header{
+ background-color: steelblue;
+ .type{
+ color: hsl(from steelblue h s 96%);
+ }
+ }
+}
+
+.warning-item--info:hover {
+ background-color: hsl(from steelblue h s 75%);
+}
+
+.warning-item .item-header {
+ padding: 8px 8px;
+ opacity: 1;
+ font-weight: bolder;
+}
+.warning-item .item-header .type{
+ padding: 2px 8px;
+ font-size: 0.9rem;
+}
+
+.warning-item .description {
+ padding: 5px 10px;
+ font-size: 0.8rem;
+}
+
+.auto-hide {
+ background-color: Canvas;
+ border-top: 2px solid CanvasText;
+ margin-top: auto;
+ width: 100%;
+ height: 2.5rem;
+ display: flex;
+ align-items: center;
+ padding: 0 12px;
+}
+
+/* arrows for toggleBar */
+.arrow-right {
+ width: 0;
+ height: 0;
+ border-top: 0.5rem solid transparent;
+ border-bottom: 0.5rem solid transparent;
+ border-left: 0.6rem solid GrayText;
+}
+
+.arrow-left {
+ width: 0;
+ height: 0;
+ border-top: 0.5rem solid transparent;
+ border-bottom: 0.5rem solid transparent;
+ border-right: 0.6rem solid GrayText;
+}
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx
new file mode 100644
index 0000000..27a4684
--- /dev/null
+++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx
@@ -0,0 +1,225 @@
+import {useReactFlow, useStoreApi} from "@xyflow/react";
+import clsx from "clsx";
+import {useEffect, useState} from "react";
+import useFlowStore from "../VisProgStores.tsx";
+import {
+ warningSummary,
+ type WarningSeverity,
+ type EditorWarning, globalWarning
+} from "./EditorWarnings.tsx";
+import styles from "./WarningSidebar.module.css";
+
+/**
+ * the warning sidebar, shows all warnings
+ *
+ * @returns {React.JSX.Element}
+ * @constructor
+ */
+export function WarningsSidebar() {
+ const warnings = useFlowStore.getState().getWarnings();
+ const [hide, setHide] = useState(false);
+ const [severityFilter, setSeverityFilter] = useState('ALL');
+ const [autoHide, setAutoHide] = useState(false);
+
+ // let autohide change hide status only when autohide is toggled
+ // and allow for user to change the hide state even if autohide is enabled
+ const hasWarnings = warnings.length > 0;
+ useEffect(() => {
+ if (autoHide) {
+ setHide(!hasWarnings);
+ }
+ }, [autoHide, hasWarnings]);
+
+ const filtered = severityFilter === 'ALL'
+ ? warnings
+ : warnings.filter(w => w.severity === severityFilter);
+
+
+ const summary = warningSummary();
+ // Finds the first key where the count > 0
+ const getHighestSeverity = () => {
+ if (summary.error > 0) return styles.error;
+ if (summary.warning > 0) return styles.warning;
+ if (summary.info > 0) return styles.info;
+ return '';
+ };
+
+ return (
+
+
+ );
+}
+
+/**
+ * the header of the warning sidebar, contains severity filtering buttons
+ *
+ * @param {WarningSeverity | "ALL"} severityFilter
+ * @param {(severity: (WarningSeverity | "ALL")) => void} onChange
+ * @returns {React.JSX.Element}
+ * @constructor
+ */
+function WarningsHeader({
+ severityFilter,
+ onChange,
+}: {
+ severityFilter: WarningSeverity | 'ALL';
+ onChange: (severity: WarningSeverity | 'ALL') => void;
+}) {
+ const summary = warningSummary();
+
+ return (
+
+
Warnings
+
+ {(['ALL', 'ERROR', 'WARNING', 'INFO'] as const).map(severity => (
+ onChange(severity)}
+ >
+ {severity}
+ {severity !== 'ALL' && (
+
+ {summary[severity.toLowerCase() as keyof typeof summary]}
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+
+/**
+ * the list of warnings in the warning sidebar
+ *
+ * @param {{warnings: EditorWarning[]}} props
+ * @returns {React.JSX.Element}
+ * @constructor
+ */
+function WarningsList(props: { warnings: EditorWarning[] }) {
+ const splitWarnings = {
+ global: props.warnings.filter(w => w.scope.id === globalWarning),
+ other: props.warnings.filter(w => w.scope.id !== globalWarning),
+ }
+ if (props.warnings.length === 0) {
+ return (
+
+ No warnings!
+
+ )
+ }
+ return (
+
+
global:
+
+ {splitWarnings.global.map((warning) => (
+
+ ))}
+ {splitWarnings.global.length === 0 && "No global warnings!"}
+
+
other:
+
+ {splitWarnings.other.map((warning) => (
+
+ ))}
+ {splitWarnings.other.length === 0 && "No other warnings!"}
+
+
+ );
+}
+
+/**
+ * a single warning in the warning sidebar
+ *
+ * @param {{warning: EditorWarning, key: string}} props
+ * @returns {React.JSX.Element}
+ * @constructor
+ */
+function WarningListItem(props: { warning: EditorWarning, key: string}) {
+ const jumpToNode = useJumpToNode();
+
+ return (
+ jumpToNode(props.warning.scope.id)}
+ >
+
+ {props.warning.type}
+
+
+
+ {props.warning.description}
+
+
+ );
+}
+
+/**
+ * moves the editor to the provided node
+ * @returns {(nodeId: string) => void}
+ */
+function useJumpToNode() {
+ const { getNode, setCenter, getViewport } = useReactFlow();
+ const { addSelectedNodes } = useStoreApi().getState();
+
+
+ return (nodeId: string) => {
+ // user can't jump to global warning, so prevent further logic from running if the warning is a globalWarning
+ if (nodeId === globalWarning) return;
+ const node = getNode(nodeId);
+ if (!node) return;
+
+ const nodeElement = document.querySelector(`.react-flow__node[data-id="${nodeId}"]`) as HTMLElement;
+ const { position } = node;
+ const viewport = getViewport();
+ const { width, height } = nodeElement.getBoundingClientRect();
+
+ //move to node
+ setCenter(
+ position!.x + ((width / viewport.zoom) / 2),
+ position!.y + ((height / viewport.zoom) / 2),
+ {duration: 300, interpolate: "smooth" }
+ ).then(() => {
+ addSelectedNodes([nodeId]);
+ });
+
+
+
+ };
+}
\ No newline at end of file
diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx
index 467187d..4495745 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx
@@ -10,6 +10,7 @@ import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores.tsx';
import { TextField } from '../../../../components/TextField.tsx';
import { MultilineTextField } from '../../../../components/MultilineTextField.tsx';
+import {noMatchingLeftRightBelief} from "./BeliefGlobals.ts";
/**
* The default data structure for a BasicBelief node
@@ -112,9 +113,7 @@ export default function BasicBeliefNode(props: NodeProps) {
updateNodeData(props.id, {...data, belief: {...data.belief, description: value}});
}
- // Use this
- const emotionOptions = ["Happy", "Angry", "Sad", "Cheerful"]
-
+ const emotionOptions = ["sad", "angry", "surprise", "fear", "happy", "disgust", "neutral"];
let placeholder = ""
let wrapping = ""
@@ -189,8 +188,9 @@ export default function BasicBeliefNode(props: NodeProps) {
)}