diff --git a/package-lock.json b/package-lock.json index a52343e..cb39172 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@neodrag/react": "^2.3.1", "@xyflow/react": "^12.8.6", + "clsx": "^2.1.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router": "^7.9.3", @@ -3971,6 +3972,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -6945,9 +6955,9 @@ } }, "node_modules/react-router": { - "version": "7.9.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz", - "integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", + "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", diff --git a/package.json b/package.json index 45f66df..ea362b7 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@neodrag/react": "^2.3.1", "@xyflow/react": "^12.8.6", + "clsx": "^2.1.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router": "^7.9.3", diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 753dcbe..3df70fb 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -4,7 +4,7 @@ import { Panel, ReactFlow, ReactFlowProvider, - MarkerType, + MarkerType, getOutgoers } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import {type CSSProperties, useEffect, useState} from "react"; @@ -12,6 +12,8 @@ import {useShallow} from 'zustand/react/shallow'; import orderPhaseNodeArray from "../../utils/orderPhaseNodes.ts"; import useProgramStore from "../../utils/programStore.ts"; import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx'; +import {type EditorWarning, globalWarning} from "./visualProgrammingUI/components/EditorWarnings.tsx"; +import {WarningsSidebar} from "./visualProgrammingUI/components/WarningSidebar.tsx"; import type {PhaseNode} from "./visualProgrammingUI/nodes/PhaseNode.tsx"; import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; @@ -89,9 +91,31 @@ const VisProgUI = () => { window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }); + const {unregisterWarning, registerWarning} = useFlowStore(); + useEffect(() => { + + if (checkPhaseChain()) { + unregisterWarning(globalWarning,'INCOMPLETE_PROGRAM'); + } else { + // create global warning for incomplete program chain + const incompleteProgramWarning : EditorWarning = { + scope: { + id: globalWarning, + handleId: undefined + }, + type: 'INCOMPLETE_PROGRAM', + severity: "ERROR", + description: "there is no complete phase chain from the startNode to the EndNode" + } + + registerWarning(incompleteProgramWarning); + } + },[edges, registerWarning, unregisterWarning]) + + return ( -
+
{ snapToGrid fitView proOptions={{hideAttribution: true}} + style={{flexGrow: 3}} > {/* contains the drag and drop panel for nodes */} @@ -120,11 +145,13 @@ const VisProgUI = () => { + +
); }; @@ -179,6 +206,24 @@ function graphReducer() { }); } +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); + console.log(next); + return !!next; + } + + return checkForCompleteChain('start'); +}; /** @@ -187,10 +232,19 @@ function graphReducer() { * @constructor */ function VisProgPage() { + const [programValidity, setProgramValidity] = useState(true); + const {isProgramValid, severityIndex} = useFlowStore(); + + useEffect(() => { + setProgramValidity(isProgramValid); + // 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]); return ( <> - + ) } diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 2831748..20306d9 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -10,6 +10,7 @@ import { } 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, @@ -50,7 +51,7 @@ function createNode(id: string, type: string, position: XYPosition, data: Record 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 @@ -341,8 +342,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..afb1024 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -9,6 +9,7 @@ import type { OnEdgesDelete, OnNodesDelete } 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"; @@ -94,7 +95,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 +130,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..eb5a0f6 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx @@ -0,0 +1,217 @@ +/* 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' + | 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 + +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 = { + editorWarningRegistry: WarningRegistry; + severityIndex: SeverityIndex; + + getWarnings: () => 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 {EditorWarning} warning + */ + unregisterWarningsForId: (id: WarningId) => void; +} + +// --| implemented logic |-- + +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 = get().editorWarningRegistry; + const sIndex = 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 = structuredClone(get().editorWarningRegistry); + const sIndex = structuredClone(get().severityIndex); + console.log("register") + // 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 = structuredClone(get().editorWarningRegistry); + const sIndex = structuredClone(get().severityIndex); + console.log("unregister") + // 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 = structuredClone(get().editorWarningRegistry); + const sIndex = structuredClone(get().severityIndex); + + const nodeWarnings = wRegistry.get(id); + + // remove from severity index + if (nodeWarnings) { + nodeWarnings.forEach((warning, warningKey) => { + 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 +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/WarningSidebar.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css new file mode 100644 index 0000000..2a8241e --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css @@ -0,0 +1,78 @@ +.warnings-sidebar { + width: 320px; + height: 100%; + background: canvas; + border-left: 2px solid black; + display: flex; + flex-direction: column; +} + +.warnings-header { + padding: 12px; + border-bottom: 1px solid #2a2a2e; +} + +.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; +} + +.warnings-list { + flex: 1; + overflow-y: auto; +} + +.warnings-empty { + margin: auto; +} + +.warning-item { + display: flex; + margin: 5px; + gap: 8px; + padding: 8px 12px; + border-radius: 5px; + cursor: pointer; +} + +.warning-item:hover { + background: ButtonFace; +} + +.warning-item--error { + border: 2px solid red; +} + +.warning-item--warning { + border: 3px solid orange; +} + +.warning-item--info { + border: 3px solid steelblue; +} + +.warning-item .meta { + font-size: 11px; + opacity: 0.6; +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx new file mode 100644 index 0000000..fc3b347 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx @@ -0,0 +1,131 @@ +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"; + +export function WarningsSidebar() { + const warnings = useFlowStore.getState().getWarnings(); + + const [severityFilter, setSeverityFilter] = useState('ALL'); + + useEffect(() => {}, [warnings]); + const filtered = severityFilter === 'ALL' + ? warnings + : warnings.filter(w => w.severity === severityFilter); + + return ( + + ); +} + +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 => ( + + ))} +
+
+ ); +} + + + +function WarningsList(props: { warnings: EditorWarning[] }) { + if (props.warnings.length === 0) { + return ( +
+ No warnings! +
+ ) + } + return ( +
+ {props.warnings.map((warning) => ( + + ))} +
+ ); +} + +function WarningListItem(props: { warning: EditorWarning }) { + const jumpToNode = useJumpToNode(); + + return ( +
jumpToNode(props.warning.scope.id)} + > +
+ {props.warning.description} +
+ +
+ {props.warning.scope.id} + {props.warning.scope.handleId && ( + @{props.warning.scope.handleId} + )} +
+
+ ); +} + +function useJumpToNode() { + const { getNode, setCenter } = useReactFlow(); + const { addSelectedNodes } = useStoreApi().getState(); + + return (nodeId: string) => { + // user can't jump to global warning, so prevent further logic from running + if (nodeId === globalWarning) return; + const node = getNode(nodeId); + if (!node) return; + + const { position, width = 0, height = 0} = node; + + // move to node + setCenter( + position!.x + width / 2, + position!.y + height / 2, + { zoom: 2, duration: 300 } + ).then(() => { + // select the node + addSelectedNodes([nodeId]); + }); + + }; +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx index 5c456b5..5bed205 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -1,12 +1,15 @@ import { type NodeProps, Position, - type Node, + type Node, useNodeConnections } 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 {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; import {allowOnlyConnectionsFromType} from "../HandleRules.ts"; +import useFlowStore from "../VisProgStores.tsx"; @@ -27,6 +30,27 @@ export type EndNode = Node * @returns React.JSX.Element */ export default function EndNode(props: NodeProps) { + const {registerWarning, unregisterWarning} = useFlowStore.getState(); + const connections = useNodeConnections({ + id: props.id, + handleId: 'target' + }) + + useEffect(() => { + const noConnectionWarning : EditorWarning = { + scope: { + id: props.id, + handleId: 'target' + }, + type: 'MISSING_INPUT', + severity: "ERROR", + description: "the endNode does not have an incoming connection from a phaseNode" + } + + if (connections.length === 0) { registerWarning(noConnectionWarning); } + else { unregisterWarning(props.id, `${noConnectionWarning.type}:target`); } + }, [connections.length, props.id, registerWarning, unregisterWarning]); + return ( <> diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 50e81b6..c5a283a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -1,8 +1,10 @@ import { type NodeProps, Position, - type Node + type Node, useNodeConnections } 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 {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx"; @@ -41,6 +43,28 @@ export default function PhaseNode(props: NodeProps) { const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value}); const label_input_id = `phase_${props.id}_label_input`; + const {registerWarning, unregisterWarning} = useFlowStore.getState(); + const connections = useNodeConnections({ + id: props.id, + handleType: "target", + handleId: 'data' + }) + + useEffect(() => { + const noConnectionWarning : EditorWarning = { + scope: { + id: props.id, + handleId: 'data' + }, + type: 'MISSING_INPUT', + severity: "WARNING", + description: "the phaseNode has no incoming goals, norms, and/or triggers" + } + + if (connections.length === 0) { registerWarning(noConnectionWarning); } + else { unregisterWarning(props.id, `${noConnectionWarning.type}:data`); } + }, [connections.length, props.id, registerWarning, unregisterWarning]); + return ( <> diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index 741b190..fab9b93 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -1,12 +1,15 @@ import { type NodeProps, Position, - type Node, + type Node, useNodeConnections } from '@xyflow/react'; +import {useEffect} from "react"; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; +import {type EditorWarning} from "../components/EditorWarnings.tsx"; import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts"; +import useFlowStore from "../VisProgStores.tsx"; export type StartNodeData = { @@ -25,6 +28,27 @@ export type StartNode = Node * @returns React.JSX.Element */ export default function StartNode(props: NodeProps) { + const {registerWarning, unregisterWarning} = useFlowStore.getState(); + const connections = useNodeConnections({ + id: props.id, + handleId: 'source' + }) + + useEffect(() => { + const noConnectionWarning : EditorWarning = { + scope: { + id: props.id, + handleId: 'source' + }, + type: 'MISSING_OUTPUT', + severity: "ERROR", + description: "the startNode does not have an outgoing connection to a phaseNode" + } + + if (connections.length === 0) { registerWarning(noConnectionWarning); } + else { unregisterWarning(props.id, `${noConnectionWarning.type}:source`); } + }, [connections.length, props.id, registerWarning, unregisterWarning]); + return ( <>