feat: made warnings undo redo safe and added warnings to phase nodes

ref: N25B-450
This commit is contained in:
JGerla
2026-01-22 11:26:48 +01:00
parent e86c06c3e5
commit d6d74d4c6b
5 changed files with 128 additions and 20 deletions

View File

@@ -1,10 +1,18 @@
import type {Edge, Node} from "@xyflow/react"; import type {Edge, Node} from "@xyflow/react";
import type {StateCreator, StoreApi } from 'zustand/vanilla'; import type {StateCreator, StoreApi } from 'zustand/vanilla';
import type {
SeverityIndex,
WarningRegistry
} from "./components/EditorWarnings.tsx";
import type {FlowState} from "./VisProgTypes.tsx"; import type {FlowState} from "./VisProgTypes.tsx";
export type FlowSnapshot = { export type FlowSnapshot = {
nodes: Node[]; nodes: Node[];
edges: Edge[]; edges: Edge[];
warnings: {
warningRegistry: WarningRegistry;
severityIndex: SeverityIndex;
}
} }
/** /**
@@ -41,7 +49,11 @@ export const UndoRedo = (
*/ */
const getSnapshot = (state : BaseFlowState) : FlowSnapshot => (structuredClone({ const getSnapshot = (state : BaseFlowState) : FlowSnapshot => (structuredClone({
nodes: state.nodes, nodes: state.nodes,
edges: state.edges edges: state.edges,
warnings: {
warningRegistry: state.editorWarningRegistry,
severityIndex: state.severityIndex,
}
})); }));
const initialState = config(set, get, api); const initialState = config(set, get, api);
@@ -78,6 +90,8 @@ export const UndoRedo = (
set({ set({
nodes: snapshot.nodes, nodes: snapshot.nodes,
edges: snapshot.edges, edges: snapshot.edges,
editorWarningRegistry: snapshot.warnings.warningRegistry,
severityIndex: snapshot.warnings.severityIndex,
}); });
state.future.push(currentSnapshot); // push current to redo state.future.push(currentSnapshot); // push current to redo
@@ -97,6 +111,8 @@ export const UndoRedo = (
set({ set({
nodes: snapshot.nodes, nodes: snapshot.nodes,
edges: snapshot.edges, edges: snapshot.edges,
editorWarningRegistry: snapshot.warnings.warningRegistry,
severityIndex: snapshot.warnings.severityIndex,
}); });
state.past.push(currentSnapshot); // push current to undo state.past.push(currentSnapshot); // push current to undo

View File

@@ -19,6 +19,7 @@ export type WarningType =
| 'MISSING_OUTPUT' | 'MISSING_OUTPUT'
| 'PLAN_IS_UNDEFINED' | 'PLAN_IS_UNDEFINED'
| 'INCOMPLETE_PROGRAM' | 'INCOMPLETE_PROGRAM'
| 'NOT_CONNECTED_TO_PROGRAM'
| string | string
export type WarningSeverity = export type WarningSeverity =
@@ -87,7 +88,7 @@ export type EditorWarningRegistry = {
/** /**
* unregisters warnings from the warningRegistry and the SeverityIndex * unregisters warnings from the warningRegistry and the SeverityIndex
* @param {EditorWarning} warning * @param {WarningId} warning
*/ */
unregisterWarningsForId: (id: WarningId) => void; unregisterWarningsForId: (id: WarningId) => void;
} }
@@ -187,13 +188,15 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor
// remove from severity index // remove from severity index
if (nodeWarnings) { if (nodeWarnings) {
nodeWarnings.forEach((warning, warningKey) => { nodeWarnings.forEach((warning) => {
const warningKey = `${warning.type}:${warning.scope.handleId}`;
sIndex.get(warning.severity)?.delete(`${id}|${warningKey}`); sIndex.get(warning.severity)?.delete(`${id}|${warningKey}`);
}); });
} }
// remove from warning registry // remove from warning registry
wRegistry.delete(id); wRegistry.delete(id);
console.log(wRegistry.get(id));
set({ set({
editorWarningRegistry: wRegistry, editorWarningRegistry: wRegistry,

View File

@@ -19,9 +19,6 @@ export function WarningsSidebar() {
? warnings ? warnings
: warnings.filter(w => w.severity === severityFilter); : warnings.filter(w => w.severity === severityFilter);
return ( return (
<aside className={styles.warningsSidebar}> <aside className={styles.warningsSidebar}>
<WarningsHeader <WarningsHeader
@@ -83,7 +80,7 @@ function WarningsList(props: { warnings: EditorWarning[] }) {
) )
} }
return ( return (
<div> <div style={{overflowY: 'auto'}}>
<div className={"warningGroup"}> <div className={"warningGroup"}>
<div className={styles.warningGroupHeader}>global:</div> <div className={styles.warningGroupHeader}>global:</div>
<div className={styles.warningsList}> <div className={styles.warningsList}>

View File

@@ -4,7 +4,7 @@ import {
type Node, useNodeConnections type Node, useNodeConnections
} from '@xyflow/react'; } from '@xyflow/react';
import {useEffect} from "react"; import {useEffect} from "react";
import type {EditorWarning} from "../components/EditorWarnings.tsx"; 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";
@@ -39,16 +39,29 @@ export type PhaseNode = Node<PhaseNodeData>
*/ */
export default function PhaseNode(props: NodeProps<PhaseNode>) { export default function PhaseNode(props: NodeProps<PhaseNode>) {
const data = props.data; const data = props.data;
const {updateNodeData} = useFlowStore(); const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
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({ const connections = useNodeConnections({
id: props.id, id: props.id,
handleType: "target", handleType: "target",
handleId: 'data' handleId: 'data'
}) })
const phaseOutCons = useNodeConnections({
id: props.id,
handleType: "source",
handleId: 'source',
})
const phaseInCons = useNodeConnections({
id: props.id,
handleType: "target",
handleId: 'target',
})
useEffect(() => { useEffect(() => {
const noConnectionWarning : EditorWarning = { const noConnectionWarning : EditorWarning = {
@@ -61,10 +74,64 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
description: "the phaseNode has no incoming goals, norms, and/or triggers" description: "the phaseNode has no incoming goals, norms, and/or triggers"
} }
if (connections.length === 0) { registerWarning(noConnectionWarning); } if (connections.length === 0) { registerWarning(noConnectionWarning); return; }
else { unregisterWarning(props.id, `${noConnectionWarning.type}:data`); } unregisterWarning(props.id, `${noConnectionWarning.type}:data`);
}, [connections.length, props.id, registerWarning, unregisterWarning]); }, [connections.length, props.id, registerWarning, unregisterWarning]);
useEffect(() => {
const notConnectedInfo : EditorWarning = {
scope: {
id: props.id,
handleId: undefined,
},
type: 'NOT_CONNECTED_TO_PROGRAM',
severity: "INFO",
description: "The PhaseNode is not connected to other nodes"
};
const noIncomingPhaseWarning : EditorWarning = {
scope: {
id: props.id,
handleId: 'target'
},
type: 'MISSING_INPUT',
severity: "WARNING",
description: "the phaseNode has no incoming connection from a phase or the startNode"
}
const noOutgoingPhaseWarning : EditorWarning = {
scope: {
id: props.id,
handleId: 'source'
},
type: 'MISSING_OUTPUT',
severity: "WARNING",
description: "the phaseNode has no outgoing connection to a phase or the endNode"
}
// register relevant warning and unregister others
if (phaseInCons.length === 0 && phaseOutCons.length === 0) {
registerWarning(notConnectedInfo);
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
return;
}
if (phaseOutCons.length === 0) {
registerWarning(noOutgoingPhaseWarning);
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
return;
}
if (phaseInCons.length === 0) {
registerWarning(noIncomingPhaseWarning);
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
return;
}
// unregister all warnings if none should be present
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
}, [phaseInCons.length, phaseOutCons.length, props.id, registerWarning, unregisterWarning]);
return ( return (
<> <>
<Toolbar nodeId={props.id} allowDelete={true}/> <Toolbar nodeId={props.id} allowDelete={true}/>

View File

@@ -34,10 +34,17 @@ describe("UndoRedo Middleware", () => {
type: 'default', type: 'default',
position: {x: 0, y: 0}, position: {x: 0, y: 0},
data: {label: 'A'} data: {label: 'A'}
}, }
], ],
edges: [] edges: [],
warnings: {
warningRegistry: new Map(),
severityIndex: new Map()
}
}], }],
ruleRegistry: new Map(),
editorWarningRegistry: new Map(),
severityIndex: new Map()
}); });
act(() => { act(() => {
@@ -53,7 +60,11 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0}, position: {x: 0, y: 0},
data: {label: 'A'} data: {label: 'A'}
}], }],
edges: [] edges: [],
warnings: {
warningRegistry: {},
severityIndex: {}
}
}); });
expect(state.future).toEqual([]); expect(state.future).toEqual([]);
}); });
@@ -80,7 +91,9 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0}, position: {x: 0, y: 0},
data: {label: 'A'} data: {label: 'A'}
}], }],
edges: [] edges: [],
editorWarningRegistry: new Map(),
severityIndex: new Map()
}); });
act(() => { act(() => {
@@ -114,7 +127,11 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0}, position: {x: 0, y: 0},
data: {label: 'B'} data: {label: 'B'}
}], }],
edges: [] edges: [],
warnings: {
warningRegistry: {},
severityIndex: {}
}
}); });
}); });
@@ -140,7 +157,9 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0}, position: {x: 0, y: 0},
data: {label: 'A'} data: {label: 'A'}
}], }],
edges: [] edges: [],
editorWarningRegistry: new Map(),
severityIndex: new Map()
}); });
act(() => { act(() => {
@@ -176,7 +195,11 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0}, position: {x: 0, y: 0},
data: {label: 'A'} data: {label: 'A'}
}], }],
edges: [] edges: [],
warnings: {
warningRegistry: {},
severityIndex: {}
}
}); });
}); });
@@ -199,7 +222,9 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0}, position: {x: 0, y: 0},
data: {label: 'A'} data: {label: 'A'}
}], }],
edges: [] edges: [],
editorWarningRegistry: new Map(),
severityIndex: new Map()
}); });
act(() => { store.getState().beginBatchAction(); }); act(() => { store.getState().beginBatchAction(); });