Merge branch 'feat/editor-user-feedback' into demo
# Conflicts: # src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx
This commit is contained in:
16
package-lock.json
generated
16
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neodrag/react": "^2.3.1",
|
"@neodrag/react": "^2.3.1",
|
||||||
"@xyflow/react": "^12.8.6",
|
"@xyflow/react": "^12.8.6",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router": "^7.9.3",
|
"react-router": "^7.9.3",
|
||||||
@@ -3971,6 +3972,15 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"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": {
|
"node_modules/co": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||||
@@ -6945,9 +6955,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.9.3",
|
"version": "7.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
|
||||||
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
|
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neodrag/react": "^2.3.1",
|
"@neodrag/react": "^2.3.1",
|
||||||
"@xyflow/react": "^12.8.6",
|
"@xyflow/react": "^12.8.6",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router": "^7.9.3",
|
"react-router": "^7.9.3",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Panel,
|
Panel,
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
MarkerType,
|
MarkerType, getOutgoers
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import {type CSSProperties, useEffect, useState} from "react";
|
import {type CSSProperties, useEffect, useState} from "react";
|
||||||
@@ -12,6 +12,8 @@ import {useShallow} from 'zustand/react/shallow';
|
|||||||
import orderPhaseNodeArray from "../../utils/orderPhaseNodes.ts";
|
import orderPhaseNodeArray from "../../utils/orderPhaseNodes.ts";
|
||||||
import useProgramStore from "../../utils/programStore.ts";
|
import useProgramStore from "../../utils/programStore.ts";
|
||||||
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
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 type {PhaseNode} from "./visualProgrammingUI/nodes/PhaseNode.tsx";
|
||||||
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
||||||
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
||||||
@@ -89,9 +91,31 @@ const VisProgUI = () => {
|
|||||||
window.addEventListener('keydown', handler);
|
window.addEventListener('keydown', handler);
|
||||||
return () => window.removeEventListener('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 (
|
return (
|
||||||
<div className={`${styles.innerEditorContainer} round-lg border-lg`} style={({'--flow-zoom': zoom} as CSSProperties)}>
|
<div className={`${styles.innerEditorContainer} round-lg border-lg flex-row`} style={({'--flow-zoom': zoom} as CSSProperties)}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
@@ -111,6 +135,7 @@ const VisProgUI = () => {
|
|||||||
snapToGrid
|
snapToGrid
|
||||||
fitView
|
fitView
|
||||||
proOptions={{hideAttribution: true}}
|
proOptions={{hideAttribution: true}}
|
||||||
|
style={{flexGrow: 3}}
|
||||||
>
|
>
|
||||||
<Panel position="top-center" className={styles.dndPanel}>
|
<Panel position="top-center" className={styles.dndPanel}>
|
||||||
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
|
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
|
||||||
@@ -120,11 +145,13 @@ const VisProgUI = () => {
|
|||||||
</Panel>
|
</Panel>
|
||||||
<Panel position="bottom-center">
|
<Panel position="bottom-center">
|
||||||
<button onClick={() => undo()}>undo</button>
|
<button onClick={() => undo()}>undo</button>
|
||||||
|
|
||||||
<button onClick={() => redo()}>Redo</button>
|
<button onClick={() => redo()}>Redo</button>
|
||||||
</Panel>
|
</Panel>
|
||||||
<Controls/>
|
<Controls/>
|
||||||
<Background/>
|
<Background/>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
<WarningsSidebar/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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
|
* @constructor
|
||||||
*/
|
*/
|
||||||
function VisProgPage() {
|
function VisProgPage() {
|
||||||
|
const [programValidity, setProgramValidity] = useState<boolean>(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<VisualProgrammingUI/>
|
<VisualProgrammingUI/>
|
||||||
<button onClick={runProgram}>run program</button>
|
<button onClick={runProgram} disabled={!programValidity}>run program</button>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import {type ConnectionContext, validateConnectionWithRules} from "./HandleRuleLogic.ts";
|
import {type ConnectionContext, validateConnectionWithRules} from "./HandleRuleLogic.ts";
|
||||||
|
import {editorWarningRegistry} from "./components/EditorWarnings.tsx";
|
||||||
import type { FlowState } from './VisProgTypes';
|
import type { FlowState } from './VisProgTypes';
|
||||||
import {
|
import {
|
||||||
NodeDefaults,
|
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 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 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...
|
// Initial edges, leave empty as setting initial edges...
|
||||||
// ...breaks logic that is dependent on connection events
|
// ...breaks logic that is dependent on connection events
|
||||||
@@ -341,8 +342,12 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
|
|||||||
})
|
})
|
||||||
return { ruleRegistry: registry };
|
return { ruleRegistry: registry };
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
|
...editorWarningRegistry(get, set),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default useFlowStore;
|
export default useFlowStore;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
OnEdgesDelete,
|
OnEdgesDelete,
|
||||||
OnNodesDelete
|
OnNodesDelete
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
|
import type {EditorWarningRegistry} from "./components/EditorWarnings.tsx";
|
||||||
import type {HandleRule} from "./HandleRuleLogic.ts";
|
import type {HandleRule} from "./HandleRuleLogic.ts";
|
||||||
import type { NodeTypes } from './NodeRegistry';
|
import type { NodeTypes } from './NodeRegistry';
|
||||||
import type {FlowSnapshot} from "./EditorUndoRedo.ts";
|
import type {FlowSnapshot} from "./EditorUndoRedo.ts";
|
||||||
@@ -94,7 +95,7 @@ export type FlowState = {
|
|||||||
* @param node - the Node object to add
|
* @param node - the Node object to add
|
||||||
*/
|
*/
|
||||||
addNode: (node: Node) => void;
|
addNode: (node: Node) => void;
|
||||||
} & UndoRedoState & HandleRuleRegistry;
|
} & UndoRedoState & HandleRuleRegistry & EditorWarningRegistry;
|
||||||
|
|
||||||
export type UndoRedoState = {
|
export type UndoRedoState = {
|
||||||
// UndoRedo Types
|
// UndoRedo Types
|
||||||
@@ -130,3 +131,6 @@ export type HandleRuleRegistry = {
|
|||||||
// cleans up all registered rules of all handles of the provided node
|
// cleans up all registered rules of all handles of the provided node
|
||||||
unregisterNodeRules: (nodeId: string) => void
|
unregisterNodeRules: (nodeId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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<WarningId , Map<WarningKey, EditorWarning>>;
|
||||||
|
export type SeverityIndex = Map<WarningSeverity, Set<CompositeWarningKey>>;
|
||||||
|
|
||||||
|
type ZustandSet = (partial: Partial<FlowState> | ((state: FlowState) => Partial<FlowState>)) => 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<NodeId, Map<WarningKey, EditorWarning>>(),
|
||||||
|
severityIndex: new Map([
|
||||||
|
['INFO', new Set<CompositeWarningKey>()],
|
||||||
|
['WARNING', new Set<CompositeWarningKey>()],
|
||||||
|
['ERROR', new Set<CompositeWarningKey>()],
|
||||||
|
]),
|
||||||
|
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<WarningSeverity | 'ALL'>('ALL');
|
||||||
|
|
||||||
|
useEffect(() => {}, [warnings]);
|
||||||
|
const filtered = severityFilter === 'ALL'
|
||||||
|
? warnings
|
||||||
|
: warnings.filter(w => w.severity === severityFilter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={styles.warningsSidebar}>
|
||||||
|
<WarningsHeader
|
||||||
|
severityFilter={severityFilter}
|
||||||
|
onChange={setSeverityFilter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WarningsList warnings={filtered} />
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WarningsHeader({
|
||||||
|
severityFilter,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
severityFilter: WarningSeverity | 'ALL';
|
||||||
|
onChange: (severity: WarningSeverity | 'ALL') => void;
|
||||||
|
}) {
|
||||||
|
const summary = warningSummary();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.warningsHeader}>
|
||||||
|
<h3>Warnings</h3>
|
||||||
|
|
||||||
|
<div className={styles.severityTabs}>
|
||||||
|
{(['ALL', 'ERROR', 'WARNING', 'INFO'] as const).map(severity => (
|
||||||
|
<button
|
||||||
|
key={severity}
|
||||||
|
className={clsx(styles.severityTab, severityFilter === severity && styles.active)}
|
||||||
|
onClick={() => onChange(severity)}
|
||||||
|
>
|
||||||
|
{severity}
|
||||||
|
{severity !== 'ALL' && (
|
||||||
|
<span className={styles.count}>
|
||||||
|
{summary[severity.toLowerCase() as keyof typeof summary]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function WarningsList(props: { warnings: EditorWarning[] }) {
|
||||||
|
if (props.warnings.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={styles.warningsEmpty}>
|
||||||
|
No warnings!
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.warningsList}>
|
||||||
|
{props.warnings.map((warning) => (
|
||||||
|
<WarningListItem warning={warning} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WarningListItem(props: { warning: EditorWarning }) {
|
||||||
|
const jumpToNode = useJumpToNode();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(styles.warningItem, styles[`warning-item--${props.warning.severity.toLowerCase()}`],)}
|
||||||
|
onClick={() => jumpToNode(props.warning.scope.id)}
|
||||||
|
>
|
||||||
|
<div className={styles.description}>
|
||||||
|
{props.warning.description}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.meta}>
|
||||||
|
{props.warning.scope.id}
|
||||||
|
{props.warning.scope.handleId && (
|
||||||
|
<span className={styles.handle}>@{props.warning.scope.handleId}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
Position,
|
Position,
|
||||||
type Node,
|
type Node, useNodeConnections
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
|
import {useEffect} from "react";
|
||||||
|
import type {EditorWarning} from "../components/EditorWarnings.tsx";
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
||||||
|
import useFlowStore from "../VisProgStores.tsx";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -27,6 +30,27 @@ export type EndNode = Node<EndNodeData>
|
|||||||
* @returns React.JSX.Element
|
* @returns React.JSX.Element
|
||||||
*/
|
*/
|
||||||
export default function EndNode(props: NodeProps<EndNode>) {
|
export default function EndNode(props: NodeProps<EndNode>) {
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toolbar nodeId={props.id} allowDelete={false}/>
|
<Toolbar nodeId={props.id} allowDelete={false}/>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
Position,
|
Position,
|
||||||
type Node
|
type Node, useNodeConnections
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
|
import {useEffect} from "react";
|
||||||
|
import type {EditorWarning} from "../components/EditorWarnings.tsx";
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
import {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
import {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
@@ -41,6 +43,28 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
|||||||
const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value});
|
const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value});
|
||||||
const label_input_id = `phase_${props.id}_label_input`;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
Position,
|
Position,
|
||||||
type Node,
|
type Node, useNodeConnections
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
|
import {useEffect} from "react";
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
|
import {type EditorWarning} from "../components/EditorWarnings.tsx";
|
||||||
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
|
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
|
||||||
|
import useFlowStore from "../VisProgStores.tsx";
|
||||||
|
|
||||||
|
|
||||||
export type StartNodeData = {
|
export type StartNodeData = {
|
||||||
@@ -25,6 +28,27 @@ export type StartNode = Node<StartNodeData>
|
|||||||
* @returns React.JSX.Element
|
* @returns React.JSX.Element
|
||||||
*/
|
*/
|
||||||
export default function StartNode(props: NodeProps<StartNode>) {
|
export default function StartNode(props: NodeProps<StartNode>) {
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toolbar nodeId={props.id} allowDelete={false}/>
|
<Toolbar nodeId={props.id} allowDelete={false}/>
|
||||||
|
|||||||
Reference in New Issue
Block a user