From 67558a7ac748e6c5aa56b2450cdb29218e76cfc0 Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 14 Jan 2026 16:04:44 +0100 Subject: [PATCH 01/52] feat: added full definition of editor warning infrastructure Everything is now defined architecturally, and can be implemented properly. ref: N25B-450 --- .../visualProgrammingUI/EditorWarnings.tsx | 95 +++++++++++++++++++ .../visualProgrammingUI/VisProgStores.tsx | 4 +- .../visualProgrammingUI/VisProgTypes.tsx | 8 +- 3 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx b/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx new file mode 100644 index 0000000..54c5c11 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx @@ -0,0 +1,95 @@ + + +// --| Type definitions |-- + +export type NodeId = string; +export type WarningType = + | 'MISSING_INPUT' + | 'MISSING_OUTPUT' + | string +export type WarningSeverity = + | 'INFO' // Acceptable, but probably not desirable + | 'WARNING' // Prevents running program, should be fixed before running program is allowed + + +export type EditorWarning = { + nodeId: NodeId; + type: WarningType; + severity: WarningSeverity; + description: string; + handleId?: string; +}; + +/** + * either a single warningType, or a scoped warningKey. + * + * supported-scopes: + * - `handle` + */ +export type WarningKey = + | WarningType // for warnings that can only occur once per node + | { type: WarningType, handleId: string }; // for warnings that can occur on a per-handle basis + +export type WarningRegistry = Map>; +export type SeverityIndex = Map>; + +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: ( + nodeId: NodeId, + warningKey: WarningKey, + ) => void + + /** + * unregisters warnings from the warningRegistry and the SeverityIndex + * @param {EditorWarning} warning + */ + unregisterWarningsForNode: (nodeId: string) => void; +} + +// --| implemented logic |-- + +export const editorWarningRegistry : EditorWarningRegistry = { + editorWarningRegistry: new Map>(), + severityIndex: new Map([ + ['INFO', new Set()], + ['WARNING', new Set()] + ]), + + getWarningsBySeverity: (_warningSeverity) => { return []}, + + isProgramValid: () => { return true}, + + getWarnings: () => { return []}, + + registerWarning: () => {}, + + unregisterWarning: () => {}, + + unregisterWarningsForNode: (_nodeId) => {}, +}; + diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index defa934..faa3f3c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -9,6 +9,7 @@ import { type XYPosition, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; +import { editorWarningRegistry } from "./EditorWarnings.tsx"; import type { FlowState } from './VisProgTypes'; import { NodeDefaults, @@ -306,7 +307,8 @@ const useFlowStore = create(UndoRedo((set, get) => ({ }) return { ruleRegistry: registry }; }) - } + }, + ...editorWarningRegistry, })) ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index 8ae3cad..bf1bffd 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 "./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 +} + + + From b3b77b94ad0bb0d68a98900b529c9c806efffe0c Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 14 Jan 2026 16:11:48 +0100 Subject: [PATCH 02/52] feat: slightly modified structure for better global logic ref: N25B-450 --- .../visualProgrammingUI/EditorWarnings.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx b/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx index 54c5c11..65f8e6c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx @@ -21,17 +21,13 @@ export type EditorWarning = { }; /** - * either a single warningType, or a scoped warningKey. - * - * supported-scopes: - * - `handle` + * a scoped WarningKey, + * `handleId` is `null` if the warning is not specific to one handle on the node */ -export type WarningKey = - | WarningType // for warnings that can only occur once per node - | { type: WarningType, handleId: string }; // for warnings that can occur on a per-handle basis +export type WarningKey = { type: WarningType, handleId: string | null }; // for warnings that can occur on a per-handle basis export type WarningRegistry = Map>; -export type SeverityIndex = Map>; +export type SeverityIndex = Map>; export type EditorWarningRegistry = { editorWarningRegistry: WarningRegistry; @@ -76,8 +72,8 @@ export type EditorWarningRegistry = { export const editorWarningRegistry : EditorWarningRegistry = { editorWarningRegistry: new Map>(), severityIndex: new Map([ - ['INFO', new Set()], - ['WARNING', new Set()] + ['INFO', new Set<{ nodeId: NodeId, warningKey: WarningKey}>()], + ['WARNING', new Set<{ nodeId: NodeId, warningKey: WarningKey}>()] ]), getWarningsBySeverity: (_warningSeverity) => { return []}, From f174623a4c67527c17af074110ee66a1f5dd8493 Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 14 Jan 2026 16:46:50 +0100 Subject: [PATCH 03/52] feat: implemented basic add and remove functions ref: N25B-450 --- .../visualProgrammingUI/EditorWarnings.tsx | 58 +++++++++++++++---- .../visualProgrammingUI/VisProgStores.tsx | 2 +- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx b/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx index 65f8e6c..7ecde38 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx @@ -2,6 +2,8 @@ // --| Type definitions |-- +import type {FlowState} from "./VisProgTypes.tsx"; + export type NodeId = string; export type WarningType = | 'MISSING_INPUT' @@ -29,6 +31,9 @@ export type WarningKey = { type: WarningType, handleId: string | null }; // for 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; @@ -47,18 +52,13 @@ export type EditorWarningRegistry = { * registers a warning to the warningRegistry and the SeverityIndex * @param {EditorWarning} warning */ - registerWarning: ( - warning: EditorWarning - ) => void; + registerWarning: (warning: EditorWarning) => void; /** * unregisters a warning from the warningRegistry and the SeverityIndex * @param {EditorWarning} warning */ - unregisterWarning: ( - nodeId: NodeId, - warningKey: WarningKey, - ) => void + unregisterWarning: (nodeId: NodeId, warningKey: WarningKey) => void /** * unregisters warnings from the warningRegistry and the SeverityIndex @@ -69,7 +69,7 @@ export type EditorWarningRegistry = { // --| implemented logic |-- -export const editorWarningRegistry : EditorWarningRegistry = { +export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : EditorWarningRegistry {return { editorWarningRegistry: new Map>(), severityIndex: new Map([ ['INFO', new Set<{ nodeId: NodeId, warningKey: WarningKey}>()], @@ -82,10 +82,46 @@ export const editorWarningRegistry : EditorWarningRegistry = { getWarnings: () => { return []}, - registerWarning: () => {}, + registerWarning: (warning) => { + const { nodeId, type, severity, handleId } = warning; + const warningKey = handleId ? { type, handleId } : { type, handleId: null}; + const wRegistry = get().editorWarningRegistry; + const sIndex = get().severityIndex; - unregisterWarning: () => {}, + // add to registry + if (!wRegistry.has(nodeId)) { + wRegistry.set(nodeId, new Map()); + } + wRegistry.get(nodeId)!.set(warningKey, warning); + + // add to severityIndex + sIndex.get(severity)!.add({nodeId,warningKey}); + + set({ + editorWarningRegistry: wRegistry, + severityIndex: sIndex + }) + }, + + unregisterWarning: (nodeId, warningKey) => { + + const wRegistry = get().editorWarningRegistry; + const sIndex = get().severityIndex; + + const warning = wRegistry.get(nodeId)!.get(warningKey); + // remove from registry + wRegistry.get(nodeId)!.delete(warningKey); + + + // remove from severityIndex + sIndex.get(warning!.severity)!.delete({nodeId,warningKey}); + + set({ + editorWarningRegistry: wRegistry, + severityIndex: sIndex + }) + }, unregisterWarningsForNode: (_nodeId) => {}, -}; +}} diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index faa3f3c..570d0df 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -308,7 +308,7 @@ const useFlowStore = create(UndoRedo((set, get) => ({ return { ruleRegistry: registry }; }) }, - ...editorWarningRegistry, + ...editorWarningRegistry(get, set), })) ); From 1a8670ba13aee5382955f77ed173abdd21aa64e4 Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 14 Jan 2026 16:52:43 +0100 Subject: [PATCH 04/52] feat: implemented delete all warnings for a node ref: N25B-450 --- .../visualProgrammingUI/EditorWarnings.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx b/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx index 7ecde38..66c12b5 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx @@ -122,6 +122,26 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor }) }, - unregisterWarningsForNode: (_nodeId) => {}, + unregisterWarningsForNode: (nodeId) => { + const wRegistry = get().editorWarningRegistry; + const sIndex = get().severityIndex; + + const nodeWarnings = wRegistry.get(nodeId); + + // remove from severity index + if (nodeWarnings) { + nodeWarnings.forEach((warning, warningKey) => { + sIndex.get(warning.severity)?.delete({nodeId, warningKey}); + }); + } + + // remove from registry + wRegistry.delete(nodeId); + + set({ + editorWarningRegistry: wRegistry, + severityIndex: sIndex + }) + }, }} From e9acab456ea84eb72c5cd5818b63baa3ac025d20 Mon Sep 17 00:00:00 2001 From: JGerla Date: Wed, 14 Jan 2026 16:59:28 +0100 Subject: [PATCH 05/52] feat: implemented getWarningsBySeverity ref: N25B-450 --- .../visualProgrammingUI/EditorWarnings.tsx | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx b/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx index 66c12b5..554cc2c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx @@ -76,7 +76,26 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor ['WARNING', new Set<{ nodeId: NodeId, warningKey: WarningKey}>()] ]), - getWarningsBySeverity: (_warningSeverity) => { return []}, + getWarningsBySeverity: (warningSeverity) => { + const wRegistry = get().editorWarningRegistry; + const sIndex = get().severityIndex; + + const warningKeys = sIndex.get(warningSeverity); + + const warnings: EditorWarning[] = []; + + warningKeys?.forEach( + ({nodeId, warningKey}) => { + const warning = wRegistry.get(nodeId)?.get(warningKey); + + if (warning) { + warnings.push(warning); + } + } + ) + + return warnings; + }, isProgramValid: () => { return true}, From 5d650b36ce9c91138425f2e41a608d841842bf23 Mon Sep 17 00:00:00 2001 From: JGerla Date: Thu, 15 Jan 2026 12:15:32 +0100 Subject: [PATCH 06/52] feat: implemented the rest of the warning registry ref: N25B-450 --- .../visualProgrammingUI/EditorWarnings.tsx | 96 ++++++++++++------- 1 file changed, 61 insertions(+), 35 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx b/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx index 554cc2c..2ff7397 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx @@ -1,25 +1,43 @@ - +/* contains all logic for the VisProgEditor warning system +* +* Missing but desirable features: +* - Global Warnings, e.g. not node specific +* - could be done by creating a global scope entry with GLOBAL as nodeId +* - Warning filtering: +* - if there is no completely connected chain of startNode-[PhaseNodes]-EndNode +* then hide any startNode, phaseNode, or endNode specific warnings +* */ // --| Type definitions |-- import type {FlowState} from "./VisProgTypes.tsx"; + +export type WarningId = NodeId | "GLOBAL_WARNINGS"; export type NodeId = string; + + export type WarningType = | 'MISSING_INPUT' | 'MISSING_OUTPUT' + | 'PLAN_IS_UNDEFINED' | string -export type WarningSeverity = - | 'INFO' // Acceptable, but probably not desirable - | 'WARNING' // Prevents running program, should be fixed before running program is allowed +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 = { - nodeId: NodeId; + scope: WarningScope; type: WarningType; severity: WarningSeverity; description: string; - handleId?: string; }; /** @@ -28,8 +46,9 @@ export type EditorWarning = { */ export type WarningKey = { type: WarningType, handleId: string | null }; // for warnings that can occur on a per-handle basis -export type WarningRegistry = Map>; -export type SeverityIndex = Map>; + +export type WarningRegistry = Map>; +export type SeverityIndex = Map>; type ZustandSet = (partial: Partial | ((state: FlowState) => Partial)) => void; type ZustandGet = () => FlowState; @@ -58,35 +77,37 @@ export type EditorWarningRegistry = { * unregisters a warning from the warningRegistry and the SeverityIndex * @param {EditorWarning} warning */ - unregisterWarning: (nodeId: NodeId, warningKey: WarningKey) => void + unregisterWarning: (id: WarningId, warningKey: WarningKey) => void /** * unregisters warnings from the warningRegistry and the SeverityIndex * @param {EditorWarning} warning */ - unregisterWarningsForNode: (nodeId: string) => void; + unregisterWarningsForId: (id: WarningId) => void; } // --| implemented logic |-- -export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : EditorWarningRegistry {return { +export const globalWarningKey = (type: WarningType) : WarningKey => { return {type: type, handleId: null}}; +export const globalWarning = "GLOBAL_WARNINGS"; + + +export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : EditorWarningRegistry { return { editorWarningRegistry: new Map>(), severityIndex: new Map([ - ['INFO', new Set<{ nodeId: NodeId, warningKey: WarningKey}>()], - ['WARNING', new Set<{ nodeId: NodeId, warningKey: WarningKey}>()] + ['INFO', new Set<{ id: WarningId, warningKey: WarningKey}>()], + ['WARNING', new Set<{ id: WarningId, warningKey: WarningKey}>()] ]), getWarningsBySeverity: (warningSeverity) => { const wRegistry = get().editorWarningRegistry; const sIndex = get().severityIndex; - const warningKeys = sIndex.get(warningSeverity); - const warnings: EditorWarning[] = []; warningKeys?.forEach( - ({nodeId, warningKey}) => { - const warning = wRegistry.get(nodeId)?.get(warningKey); + ({id, warningKey}) => { + const warning = wRegistry.get(id)?.get(warningKey); if (warning) { warnings.push(warning); @@ -97,24 +118,29 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor return warnings; }, - isProgramValid: () => { return true}, + isProgramValid: () => { + const sIndex = get().severityIndex; + return sIndex.get("ERROR")!.size === 0; + }, + + getWarnings: () => Array.from(get().editorWarningRegistry.values()) + .flatMap(innerMap => Array.from(innerMap.values())), - getWarnings: () => { return []}, registerWarning: (warning) => { - const { nodeId, type, severity, handleId } = warning; + const { scope: {id, handleId}, type, severity } = warning; const warningKey = handleId ? { type, handleId } : { type, handleId: null}; const wRegistry = get().editorWarningRegistry; const sIndex = get().severityIndex; - // add to registry - if (!wRegistry.has(nodeId)) { - wRegistry.set(nodeId, new Map()); + // add to warning registry + if (!wRegistry.has(id)) { + wRegistry.set(id, new Map()); } - wRegistry.get(nodeId)!.set(warningKey, warning); + wRegistry.get(id)!.set(warningKey, warning); // add to severityIndex - sIndex.get(severity)!.add({nodeId,warningKey}); + sIndex.get(severity)!.add({id,warningKey}); set({ editorWarningRegistry: wRegistry, @@ -122,18 +148,18 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor }) }, - unregisterWarning: (nodeId, warningKey) => { + unregisterWarning: (id, warningKey) => { const wRegistry = get().editorWarningRegistry; const sIndex = get().severityIndex; - const warning = wRegistry.get(nodeId)!.get(warningKey); - // remove from registry - wRegistry.get(nodeId)!.delete(warningKey); + const warning = wRegistry.get(id)!.get(warningKey); + // remove from warning registry + wRegistry.get(id)!.delete(warningKey); // remove from severityIndex - sIndex.get(warning!.severity)!.delete({nodeId,warningKey}); + sIndex.get(warning!.severity)!.delete({id,warningKey}); set({ editorWarningRegistry: wRegistry, @@ -141,21 +167,21 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor }) }, - unregisterWarningsForNode: (nodeId) => { + unregisterWarningsForId: (id) => { const wRegistry = get().editorWarningRegistry; const sIndex = get().severityIndex; - const nodeWarnings = wRegistry.get(nodeId); + const nodeWarnings = wRegistry.get(id); // remove from severity index if (nodeWarnings) { nodeWarnings.forEach((warning, warningKey) => { - sIndex.get(warning.severity)?.delete({nodeId, warningKey}); + sIndex.get(warning.severity)?.delete({id, warningKey}); }); } - // remove from registry - wRegistry.delete(nodeId); + // remove from warning registry + wRegistry.delete(id); set({ editorWarningRegistry: wRegistry, From 66daafe1f0ec0a99a2cbb1b02ce4c16f68f7a864 Mon Sep 17 00:00:00 2001 From: JGerla Date: Thu, 15 Jan 2026 12:21:09 +0100 Subject: [PATCH 07/52] feat: added disabling of runProgram button if program is not valid ref: N25B-450 --- src/pages/VisProgPage/VisProg.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index a083bbf..a31665f 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -186,10 +186,17 @@ function graphReducer() { * @constructor */ function VisProgPage() { + const [programValidity, setProgramValidity] = useState(true); + const sIndex = useFlowStore.getState().severityIndex; + + useEffect(() => { + setProgramValidity(useFlowStore.getState().isProgramValid) + }, [sIndex]); + return ( <> - + ) } From 35bf3ad9e50d8fcedeaf5fcb1f537d2b915c6062 Mon Sep 17 00:00:00 2001 From: JGerla Date: Thu, 15 Jan 2026 14:22:50 +0100 Subject: [PATCH 08/52] feat: finished basic warning system nodes can now register warnings to prevent running the program ref: N25B-450 --- src/pages/VisProgPage/VisProg.tsx | 18 ++++-- .../visualProgrammingUI/EditorWarnings.tsx | 62 +++++++++++-------- .../visualProgrammingUI/nodes/StartNode.tsx | 28 ++++++++- 3 files changed, 77 insertions(+), 31 deletions(-) diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index a31665f..500a6c0 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -90,6 +90,8 @@ const VisProgUI = () => { return () => window.removeEventListener('keydown', handler); }); + + return (
{ + @@ -187,16 +190,21 @@ function graphReducer() { */ function VisProgPage() { const [programValidity, setProgramValidity] = useState(true); - const sIndex = useFlowStore.getState().severityIndex; + + const {isProgramValid, severityIndex} = useFlowStore(); + useEffect(() => { - setProgramValidity(useFlowStore.getState().isProgramValid) - }, [sIndex]); - + setProgramValidity(isProgramValid); + // the following eslint disable is required as it wants us to use all possible dependencies for the useEffect statement, + // however this would result in an infinite loop because it would change one of its own dependencies + // so we only use those dependencies that we don't change ourselves + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [severityIndex]); return ( <> - + ) } diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx b/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx index 2ff7397..436fc21 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx @@ -1,12 +1,10 @@ /* contains all logic for the VisProgEditor warning system * * Missing but desirable features: -* - Global Warnings, e.g. not node specific -* - could be done by creating a global scope entry with GLOBAL as nodeId * - Warning filtering: * - if there is no completely connected chain of startNode-[PhaseNodes]-EndNode * then hide any startNode, phaseNode, or endNode specific warnings -* */ +*/ // --| Type definitions |-- @@ -42,13 +40,21 @@ export type EditorWarning = { /** * a scoped WarningKey, - * `handleId` is `null` if the warning is not specific to one handle on the node + * the handleId scoping is only needed for handle specific errors + * + * "`WarningType`:`handleId`" */ -export type WarningKey = { type: WarningType, handleId: string | null }; // for warnings that can occur on a per-handle basis +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>; +export type SeverityIndex = Map>; type ZustandSet = (partial: Partial | ((state: FlowState) => Partial)) => void; type ZustandGet = () => FlowState; @@ -88,15 +94,14 @@ export type EditorWarningRegistry = { // --| implemented logic |-- -export const globalWarningKey = (type: WarningType) : WarningKey => { return {type: type, handleId: null}}; export const globalWarning = "GLOBAL_WARNINGS"; - export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : EditorWarningRegistry { return { editorWarningRegistry: new Map>(), severityIndex: new Map([ - ['INFO', new Set<{ id: WarningId, warningKey: WarningKey}>()], - ['WARNING', new Set<{ id: WarningId, warningKey: WarningKey}>()] + ['INFO', new Set()], + ['WARNING', new Set()], + ['ERROR', new Set()], ]), getWarningsBySeverity: (warningSeverity) => { @@ -106,7 +111,8 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor const warnings: EditorWarning[] = []; warningKeys?.forEach( - ({id, warningKey}) => { + (compositeKey) => { + const [id, warningKey] = compositeKey.split('|'); const warning = wRegistry.get(id)?.get(warningKey); if (warning) { @@ -120,7 +126,7 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor isProgramValid: () => { const sIndex = get().severityIndex; - return sIndex.get("ERROR")!.size === 0; + return (sIndex.get("ERROR")!.size === 0); }, getWarnings: () => Array.from(get().editorWarningRegistry.values()) @@ -129,18 +135,22 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor registerWarning: (warning) => { const { scope: {id, handleId}, type, severity } = warning; - const warningKey = handleId ? { type, handleId } : { type, handleId: null}; - const wRegistry = get().editorWarningRegistry; - const sIndex = get().severityIndex; - + 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 - sIndex.get(severity)!.add({id,warningKey}); + if (!sIndex.get(severity)!.has(compositeKey)) { + sIndex.get(severity)!.add(compositeKey); + } set({ editorWarningRegistry: wRegistry, @@ -149,17 +159,19 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor }, 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; - const wRegistry = get().editorWarningRegistry; - const sIndex = get().severityIndex; - - const warning = wRegistry.get(id)!.get(warningKey); // remove from warning registry wRegistry.get(id)!.delete(warningKey); // remove from severityIndex - sIndex.get(warning!.severity)!.delete({id,warningKey}); + sIndex.get(warning.severity)!.delete(`${id}|${warningKey}`); set({ editorWarningRegistry: wRegistry, @@ -168,15 +180,15 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor }, unregisterWarningsForId: (id) => { - const wRegistry = get().editorWarningRegistry; - const sIndex = get().severityIndex; + 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}); + sIndex.get(warning.severity)?.delete(`${id}|${warningKey}`); }); } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index 741b190..d4bf0f6 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 "../EditorWarnings.tsx"; import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts"; +import useFlowStore from "../VisProgStores.tsx"; export type StartNodeData = { @@ -25,6 +28,29 @@ 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 ( <> From 385ec250cc300409d1da4773b1ee0e8e67601755 Mon Sep 17 00:00:00 2001 From: JGerla Date: Thu, 15 Jan 2026 14:22:50 +0100 Subject: [PATCH 09/52] feat: finished basic warning system nodes can now register warnings to prevent running the program ref: N25B-450 --- src/pages/VisProgPage/VisProg.tsx | 17 +++-- .../visualProgrammingUI/EditorWarnings.tsx | 62 +++++++++++-------- .../visualProgrammingUI/nodes/StartNode.tsx | 28 ++++++++- 3 files changed, 76 insertions(+), 31 deletions(-) diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index a31665f..ef759c1 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -90,6 +90,8 @@ const VisProgUI = () => { return () => window.removeEventListener('keydown', handler); }); + + return (
{ + @@ -187,16 +190,20 @@ function graphReducer() { */ function VisProgPage() { const [programValidity, setProgramValidity] = useState(true); - const sIndex = useFlowStore.getState().severityIndex; + + const {isProgramValid, severityIndex} = useFlowStore(); + useEffect(() => { - setProgramValidity(useFlowStore.getState().isProgramValid) - }, [sIndex]); - + 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/EditorWarnings.tsx b/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx index 2ff7397..436fc21 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx @@ -1,12 +1,10 @@ /* contains all logic for the VisProgEditor warning system * * Missing but desirable features: -* - Global Warnings, e.g. not node specific -* - could be done by creating a global scope entry with GLOBAL as nodeId * - Warning filtering: * - if there is no completely connected chain of startNode-[PhaseNodes]-EndNode * then hide any startNode, phaseNode, or endNode specific warnings -* */ +*/ // --| Type definitions |-- @@ -42,13 +40,21 @@ export type EditorWarning = { /** * a scoped WarningKey, - * `handleId` is `null` if the warning is not specific to one handle on the node + * the handleId scoping is only needed for handle specific errors + * + * "`WarningType`:`handleId`" */ -export type WarningKey = { type: WarningType, handleId: string | null }; // for warnings that can occur on a per-handle basis +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>; +export type SeverityIndex = Map>; type ZustandSet = (partial: Partial | ((state: FlowState) => Partial)) => void; type ZustandGet = () => FlowState; @@ -88,15 +94,14 @@ export type EditorWarningRegistry = { // --| implemented logic |-- -export const globalWarningKey = (type: WarningType) : WarningKey => { return {type: type, handleId: null}}; export const globalWarning = "GLOBAL_WARNINGS"; - export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : EditorWarningRegistry { return { editorWarningRegistry: new Map>(), severityIndex: new Map([ - ['INFO', new Set<{ id: WarningId, warningKey: WarningKey}>()], - ['WARNING', new Set<{ id: WarningId, warningKey: WarningKey}>()] + ['INFO', new Set()], + ['WARNING', new Set()], + ['ERROR', new Set()], ]), getWarningsBySeverity: (warningSeverity) => { @@ -106,7 +111,8 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor const warnings: EditorWarning[] = []; warningKeys?.forEach( - ({id, warningKey}) => { + (compositeKey) => { + const [id, warningKey] = compositeKey.split('|'); const warning = wRegistry.get(id)?.get(warningKey); if (warning) { @@ -120,7 +126,7 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor isProgramValid: () => { const sIndex = get().severityIndex; - return sIndex.get("ERROR")!.size === 0; + return (sIndex.get("ERROR")!.size === 0); }, getWarnings: () => Array.from(get().editorWarningRegistry.values()) @@ -129,18 +135,22 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor registerWarning: (warning) => { const { scope: {id, handleId}, type, severity } = warning; - const warningKey = handleId ? { type, handleId } : { type, handleId: null}; - const wRegistry = get().editorWarningRegistry; - const sIndex = get().severityIndex; - + 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 - sIndex.get(severity)!.add({id,warningKey}); + if (!sIndex.get(severity)!.has(compositeKey)) { + sIndex.get(severity)!.add(compositeKey); + } set({ editorWarningRegistry: wRegistry, @@ -149,17 +159,19 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor }, 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; - const wRegistry = get().editorWarningRegistry; - const sIndex = get().severityIndex; - - const warning = wRegistry.get(id)!.get(warningKey); // remove from warning registry wRegistry.get(id)!.delete(warningKey); // remove from severityIndex - sIndex.get(warning!.severity)!.delete({id,warningKey}); + sIndex.get(warning.severity)!.delete(`${id}|${warningKey}`); set({ editorWarningRegistry: wRegistry, @@ -168,15 +180,15 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor }, unregisterWarningsForId: (id) => { - const wRegistry = get().editorWarningRegistry; - const sIndex = get().severityIndex; + 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}); + sIndex.get(warning.severity)?.delete(`${id}|${warningKey}`); }); } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index 741b190..d4bf0f6 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 "../EditorWarnings.tsx"; import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts"; +import useFlowStore from "../VisProgStores.tsx"; export type StartNodeData = { @@ -25,6 +28,29 @@ 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 ( <> From 022a6708eadcdb50746d6f345d9b9adcf5a61e5f Mon Sep 17 00:00:00 2001 From: JGerla Date: Thu, 15 Jan 2026 16:03:33 +0100 Subject: [PATCH 10/52] feat: added a Warnings sidebar warnings are now displayed in the sidebar ref: N25B-450 --- package-lock.json | 16 ++- package.json | 1 + src/pages/VisProgPage/VisProg.tsx | 7 +- .../visualProgrammingUI/VisProgStores.tsx | 2 +- .../visualProgrammingUI/VisProgTypes.tsx | 2 +- .../{ => components}/EditorWarnings.tsx | 18 +++- .../components/WarningSidebar.module.css | 73 ++++++++++++++ .../components/WarningSidebar.tsx | 99 +++++++++++++++++++ .../visualProgrammingUI/nodes/StartNode.tsx | 2 +- 9 files changed, 208 insertions(+), 12 deletions(-) rename src/pages/VisProgPage/visualProgrammingUI/{ => components}/EditorWarnings.tsx (92%) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx 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 ef759c1..032dfb3 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -12,6 +12,7 @@ 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 {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'; @@ -93,7 +94,7 @@ const VisProgUI = () => { return ( -
+
{ snapToGrid fitView proOptions={{hideAttribution: true}} + style={{flexGrow: 3}} > {/* contains the drag and drop panel for nodes */} @@ -128,6 +130,7 @@ const VisProgUI = () => { +
); }; @@ -190,10 +193,8 @@ function graphReducer() { */ 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, diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 570d0df..8305bb2 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -9,7 +9,7 @@ import { type XYPosition, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import { editorWarningRegistry } from "./EditorWarnings.tsx"; +import { editorWarningRegistry } from "./components/EditorWarnings.tsx"; import type { FlowState } from './VisProgTypes'; import { NodeDefaults, diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index bf1bffd..afb1024 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -9,7 +9,7 @@ import type { OnEdgesDelete, OnNodesDelete } from '@xyflow/react'; -import type {EditorWarningRegistry} from "./EditorWarnings.tsx"; +import type {EditorWarningRegistry} from "./components/EditorWarnings.tsx"; import type {HandleRule} from "./HandleRuleLogic.ts"; import type { NodeTypes } from './NodeRegistry'; import type {FlowSnapshot} from "./EditorUndoRedo.ts"; diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx similarity index 92% rename from src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx rename to src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx index 436fc21..a0c90d6 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/EditorWarnings.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx @@ -5,12 +5,11 @@ * - 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 |-- -import type {FlowState} from "./VisProgTypes.tsx"; - - export type WarningId = NodeId | "GLOBAL_WARNINGS"; export type NodeId = string; @@ -202,3 +201,16 @@ export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : Editor }, }} + +// 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..182d99e --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css @@ -0,0 +1,73 @@ +.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; +} + +.warning-item--error { + border: 2px solid red; +} + +.warning-item--warning { + border-left: 3px solid orange; +} + +.warning-item--info { + border-left: 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..b91a17a --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx @@ -0,0 +1,99 @@ +import clsx from "clsx"; +import {useEffect, useState} from "react"; +import useFlowStore from "../VisProgStores.tsx"; +import { + warningSummary, + type WarningSeverity, type EditorWarning +} 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 }) { + return ( +
+
+ {props.warning.description} +
+ +
+ {props.warning.scope.id} + {props.warning.scope.handleId && ( + @{props.warning.scope.handleId} + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index d4bf0f6..3d6c2b2 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -7,7 +7,7 @@ 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 "../EditorWarnings.tsx"; +import type {EditorWarning} from "../components/EditorWarnings.tsx"; import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts"; import useFlowStore from "../VisProgStores.tsx"; From a6f24b677ff0a075853eb63b472af701237f7941 Mon Sep 17 00:00:00 2001 From: JGerla Date: Thu, 15 Jan 2026 16:09:24 +0100 Subject: [PATCH 11/52] feat: added two new warnings ref: N25B-450 --- .../components/WarningSidebar.module.css | 4 +-- .../visualProgrammingUI/nodes/EndNode.tsx | 26 ++++++++++++++++++- .../visualProgrammingUI/nodes/PhaseNode.tsx | 25 +++++++++++++++++- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css index 182d99e..97f04b1 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css @@ -60,11 +60,11 @@ } .warning-item--warning { - border-left: 3px solid orange; + border: 3px solid orange; } .warning-item--info { - border-left: 3px solid steelblue; + border: 3px solid steelblue; } .warning-item .meta { 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 9a48103..e80aec3 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,27 @@ 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, + 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}:source`); } + }, [connections.length, props.id, registerWarning, unregisterWarning]); + return ( <> From 5a9b78fdda24a7f93fb9b554dd7cabc684476d6a Mon Sep 17 00:00:00 2001 From: JGerla Date: Thu, 15 Jan 2026 16:24:08 +0100 Subject: [PATCH 12/52] feat: added jump to node on clicking the warning ref: N25B-450 --- .../components/WarningSidebar.module.css | 5 ++++ .../components/WarningSidebar.tsx | 27 ++++++++++++++++++- .../visualProgrammingUI/nodes/PhaseNode.tsx | 3 ++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css index 97f04b1..2a8241e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css @@ -53,6 +53,11 @@ gap: 8px; padding: 8px 12px; border-radius: 5px; + cursor: pointer; +} + +.warning-item:hover { + background: ButtonFace; } .warning-item--error { diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx index b91a17a..fb740f6 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx @@ -1,3 +1,4 @@ +import {useReactFlow} from "@xyflow/react"; import clsx from "clsx"; import {useEffect, useState} from "react"; import useFlowStore from "../VisProgStores.tsx"; @@ -82,8 +83,13 @@ function WarningsList(props: { warnings: EditorWarning[] }) { } function WarningListItem(props: { warning: EditorWarning }) { + const jumpToNode = useJumpToNode(); + return ( -
+
jumpToNode(props.warning.scope.id)} + >
{props.warning.description}
@@ -96,4 +102,23 @@ function WarningListItem(props: { warning: EditorWarning }) {
); +} + +function useJumpToNode() { + const { getNode, setCenter } = useReactFlow(); + + return (nodeId: string) => { + 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 } + ); + + }; } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index e80aec3..cfb287f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -46,6 +46,7 @@ export default function PhaseNode(props: NodeProps) { const {registerWarning, unregisterWarning} = useFlowStore.getState(); const connections = useNodeConnections({ id: props.id, + handleType: "target", handleId: 'data' }) @@ -61,7 +62,7 @@ export default function PhaseNode(props: NodeProps) { } if (connections.length === 0) { registerWarning(noConnectionWarning); } - else { unregisterWarning(props.id, `${noConnectionWarning.type}:source`); } + else { unregisterWarning(props.id, `${noConnectionWarning.type}:data`); } }, [connections.length, props.id, registerWarning, unregisterWarning]); return ( From dcc50fd978c59a28307a8d94d0c14c1e8d985f35 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Sun, 18 Jan 2026 13:56:47 +0100 Subject: [PATCH 13/52] feat: implemented basic version of reset phase right now reset phase also clears LLM ref: N25B-400 --- src/pages/MonitoringPage/MonitoringPage.tsx | 33 +++++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/pages/MonitoringPage/MonitoringPage.tsx b/src/pages/MonitoringPage/MonitoringPage.tsx index 2ff91bb..9252448 100644 --- a/src/pages/MonitoringPage/MonitoringPage.tsx +++ b/src/pages/MonitoringPage/MonitoringPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import styles from './MonitoringPage.module.css'; // Store & API @@ -52,12 +52,16 @@ function useExperimentLogic() { const [phaseIndex, setPhaseIndex] = useState(0); const [isFinished, setIsFinished] = useState(false); + // Ref to suppress stream updates during the "Reset Phase" fast-forward sequence + const suppressUpdates = useRef(false); + const phaseIds = getPhaseIds(); const phaseNames = getPhaseNames(); // --- Stream Handlers --- const handleStreamUpdate = useCallback((data: ExperimentStreamData) => { + if (suppressUpdates.current) return; if (data.type === 'phase_update' && data.id) { const payload = data as PhaseUpdate; console.log(`${data.type} received, id : ${data.id}`); @@ -101,7 +105,7 @@ function useExperimentLogic() { }, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex, phaseNames]); const handleStatusUpdate = useCallback((data: unknown) => { - + if (suppressUpdates.current) return; const payload = data as CondNormsStateUpdate; if (payload.type !== 'cond_norms_state_update') return; @@ -156,7 +160,30 @@ function useExperimentLogic() { case "nextPhase": await nextPhase(); break; - // Case for resetPhase if implemented in API + case "resetPhase": + //make sure you don't see the phases pass to arrive back at current phase + suppressUpdates.current = true; + + const targetIndex = phaseIndex; + console.log(`Resetting phase: Restarting and skipping to index ${targetIndex}`); + const phases = graphReducer(); + setProgramState({ phases }); + + setActiveIds({}); + setPhaseIndex(0); // Visually reset to start + setGoalIndex(0); + setIsFinished(false); + + // Restart backend + await runProgramm(); + for (let i = 0; i < targetIndex; i++) { + console.log(`Skipping phase ${i}...`); + await nextPhase(); + } + suppressUpdates.current = false; + setPhaseIndex(targetIndex); + setIsPlaying(true); //Maybe you pause and then reset + break; } } catch (err) { console.error(err); From 2ca0c9c4c0a3c55884ce45072222c063c9195491 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Mon, 19 Jan 2026 18:17:47 +0100 Subject: [PATCH 14/52] chore: added Storms change --- .../VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index 467187d..c0c1bf6 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -112,8 +112,8 @@ export default function BasicBeliefNode(props: NodeProps) { updateNodeData(props.id, {...data, belief: {...data.belief, description: value}}); } - // Use this - const emotionOptions = ["Happy", "Angry", "Sad", "Cheerful"] + // These are the labels outputted by our emotion detection model + const emotionOptions = ["sad", "angry", "surprise", "fear", "happy", "disgust", "neutral"]; let placeholder = "" From 487ee30923df76e2befd653f66b008e840d292ad Mon Sep 17 00:00:00 2001 From: JGerla Date: Tue, 20 Jan 2026 10:22:08 +0100 Subject: [PATCH 15/52] feat: made jumpToNode select the node after focussing the editor ref: N25B-450 --- .../components/WarningSidebar.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx index fb740f6..fc3b347 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx @@ -1,10 +1,11 @@ -import {useReactFlow} from "@xyflow/react"; +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 + type WarningSeverity, + type EditorWarning, globalWarning } from "./EditorWarnings.tsx"; import styles from "./WarningSidebar.module.css"; @@ -106,8 +107,11 @@ function WarningListItem(props: { warning: EditorWarning }) { 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; @@ -118,7 +122,10 @@ function useJumpToNode() { position!.x + width / 2, position!.y + height / 2, { zoom: 2, duration: 300 } - ); + ).then(() => { + // select the node + addSelectedNodes([nodeId]); + }); }; } \ No newline at end of file From 5d55ebaaa2b9e629ecd3a722f4cbc8df56a0b29a Mon Sep 17 00:00:00 2001 From: JGerla Date: Tue, 20 Jan 2026 11:53:51 +0100 Subject: [PATCH 16/52] feat: Added global warning for incomplete program chain ref: N25B-450 --- src/pages/VisProgPage/VisProg.tsx | 41 ++++++++++++++++++- .../visualProgrammingUI/VisProgStores.tsx | 7 +++- .../components/EditorWarnings.tsx | 1 + .../visualProgrammingUI/nodes/StartNode.tsx | 4 +- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 032dfb3..a5f7887 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,7 @@ 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'; @@ -90,6 +91,26 @@ 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]) @@ -184,6 +205,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'); +}; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 8305bb2..dd754bf 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -9,7 +9,7 @@ import { type XYPosition, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import { editorWarningRegistry } from "./components/EditorWarnings.tsx"; +import {editorWarningRegistry} from "./components/EditorWarnings.tsx"; import type { FlowState } from './VisProgTypes'; import { NodeDefaults, @@ -50,7 +50,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 @@ -312,4 +312,7 @@ const useFlowStore = create(UndoRedo((set, get) => ({ })) ); + + export default useFlowStore; + diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx index a0c90d6..eb5a0f6 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx @@ -18,6 +18,7 @@ export type WarningType = | 'MISSING_INPUT' | 'MISSING_OUTPUT' | 'PLAN_IS_UNDEFINED' + | 'INCOMPLETE_PROGRAM' | string export type WarningSeverity = diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index 3d6c2b2..fab9b93 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -7,7 +7,7 @@ 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 {type EditorWarning} from "../components/EditorWarnings.tsx"; import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts"; import useFlowStore from "../VisProgStores.tsx"; @@ -48,8 +48,6 @@ export default function StartNode(props: NodeProps) { if (connections.length === 0) { registerWarning(noConnectionWarning); } else { unregisterWarning(props.id, `${noConnectionWarning.type}:source`); } }, [connections.length, props.id, registerWarning, unregisterWarning]); - - return ( <> From a8f99653914db05d5ed5d408db8a9697d8457f30 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Tue, 20 Jan 2026 12:31:34 +0100 Subject: [PATCH 17/52] chore: added recursive goals to monitor page --- src/pages/MonitoringPage/MonitoringPage.tsx | 9 ++-- .../MonitoringPageComponents.tsx | 10 ++-- src/utils/programStore.ts | 48 +++++++++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/pages/MonitoringPage/MonitoringPage.tsx b/src/pages/MonitoringPage/MonitoringPage.tsx index 9252448..cd7f95e 100644 --- a/src/pages/MonitoringPage/MonitoringPage.tsx +++ b/src/pages/MonitoringPage/MonitoringPage.tsx @@ -306,14 +306,17 @@ function PhaseDashboard({ setActiveIds: React.Dispatch>>, goalIndex: number }) { - const getGoals = useProgramStore((s) => s.getGoalsInPhase); + const getGoalsWithDepth = useProgramStore((s) => s.getGoalsWithDepth); const getTriggers = useProgramStore((s) => s.getTriggersInPhase); const getNorms = useProgramStore((s) => s.getNormsInPhase); // Prepare data view models - const goals = (getGoals(phaseId) as GoalNode[]).map(g => ({ + const goals = getGoalsWithDepth(phaseId).map((g) => ({ ...g, - achieved: activeIds[g.id] ?? false, + id: g.id as string, + name: g.name as string, + achieved: activeIds[g.id as string] ?? false, + level: g.level, // Pass this new property to the UI })); const triggers = (getTriggers(phaseId) as TriggerNode[]).map(t => ({ diff --git a/src/pages/MonitoringPage/MonitoringPageComponents.tsx b/src/pages/MonitoringPage/MonitoringPageComponents.tsx index 6efeab5..d1d2854 100644 --- a/src/pages/MonitoringPage/MonitoringPageComponents.tsx +++ b/src/pages/MonitoringPage/MonitoringPageComponents.tsx @@ -91,13 +91,14 @@ export const DirectSpeechInput: React.FC = () => { }; // --- interface for goals/triggers/norms/conditional norms --- -type StatusItem = { +export type StatusItem = { id?: string | number; achieved?: boolean; description?: string; label?: string; norm?: string; name?: string; + level?: number; }; interface StatusListProps { @@ -129,7 +130,7 @@ export const StatusList: React.FC = ({ const isCurrentGoal = type === 'goal' && idx === currentGoalIndex; const canOverride = (showIndicator && !isActive) || (type === 'cond_norm' && isActive); - + const indentation = (item.level || 0) * 20; const handleOverrideClick = () => { if (!canOverride) return; @@ -147,7 +148,10 @@ export const StatusList: React.FC = ({ }; return ( -
  • +
  • {showIndicator && ( [] }; +export type GoalWithDepth = Record & { level: number }; + /** * the type definition of the programStore */ @@ -18,6 +20,7 @@ export type ProgramState = { getPhaseNames: () => string[]; getNormsInPhase: (currentPhaseId: string) => Record[]; getGoalsInPhase: (currentPhaseId: string) => Record[]; + getGoalsWithDepth: (currentPhaseId: string) => GoalWithDepth[]; getTriggersInPhase: (currentPhaseId: string) => Record[]; // if more specific utility functions are needed they can be added here: } @@ -70,6 +73,51 @@ const useProgramStore = create((set, get) => ({ } throw new Error(`phase with id:"${currentPhaseId}" not found`) }, + + getGoalsWithDepth: (currentPhaseId: string) => { + const program = get().currentProgram; + const phase = program.phases.find(val => val["id"] === currentPhaseId); + + if (!phase) { + throw new Error(`phase with id:"${currentPhaseId}" not found`); + } + + const rootGoals = phase["goals"] as Record[]; + const flatList: GoalWithDepth[] = []; + + // Helper: Define this ONCE, outside the loop + const isGoal = (item: Record) => { + return item["plan"] !== undefined && item["plan"] !== null; + }; + + // Recursive helper function + const traverse = (goals: Record[], depth: number) => { + goals.forEach((goal) => { + // 1. Add the current goal to the list + flatList.push({ ...goal, level: depth }); + + // 2. Check for children + const plan = goal["plan"] as Record | undefined; + + if (plan && Array.isArray(plan["steps"])) { + const steps = plan["steps"] as Record[]; + + // 3. FILTER: Only recurse on steps that are actually goals + // If we just passed 'steps', we might accidentally add Actions/Speeches to the goal list + const childGoals = steps.filter(isGoal); + + if (childGoals.length > 0) { + traverse(childGoals, depth + 1); + } + } + }); + }; + + // Start traversal + traverse(rootGoals, 0); + + return flatList; + }, /** * gets the triggers for the provided phase */ From 3f6d95683dbf620e391b75a9d1dfa7ad5a14b5af Mon Sep 17 00:00:00 2001 From: JGerla Date: Tue, 20 Jan 2026 12:50:29 +0100 Subject: [PATCH 18/52] feat: Added visual separation between global and node or handle specific warnings ref: N25B-450 --- .../components/WarningSidebar.module.css | 4 ++ .../components/WarningSidebar.tsx | 49 +++++++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css index 2a8241e..a3a60de 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css @@ -38,6 +38,10 @@ border: 2px solid currentColor; } +.warning-group-header { + background: ButtonFace; +} + .warnings-list { flex: 1; overflow-y: auto; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx index fc3b347..da64e89 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx @@ -19,6 +19,9 @@ export function WarningsSidebar() { ? warnings : warnings.filter(w => w.severity === severityFilter); + + + return (
  • From 883f0a95a6aa1fee1c2dee238ea53b77968d6e40 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Tue, 20 Jan 2026 13:55:34 +0100 Subject: [PATCH 20/52] chore: only check if play is undefined --- src/utils/programStore.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/utils/programStore.ts b/src/utils/programStore.ts index ac34ef4..4e12bb3 100644 --- a/src/utils/programStore.ts +++ b/src/utils/programStore.ts @@ -85,9 +85,8 @@ const useProgramStore = create((set, get) => ({ const rootGoals = phase["goals"] as Record[]; const flatList: GoalWithDepth[] = []; - // Helper: Define this ONCE, outside the loop const isGoal = (item: Record) => { - return item["plan"] !== undefined && item["plan"] !== null; + return item["plan"] !== undefined; }; // Recursive helper function From 363054afda829f16083c0520429d947ee70bb5e1 Mon Sep 17 00:00:00 2001 From: JGerla Date: Tue, 20 Jan 2026 14:00:01 +0100 Subject: [PATCH 21/52] feat: updated visuals ref: N25B-450 --- .../visualProgrammingUI/components/WarningSidebar.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx index 113467d..eb2efee 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx @@ -119,10 +119,11 @@ function WarningListItem(props: { warning: EditorWarning }) {
    - {props.warning.scope.id} - {props.warning.scope.handleId && ( - @{props.warning.scope.handleId} - )} + {props.warning.type} + {/*{props.warning.scope.id}*/} + {/*{props.warning.scope.handleId && (*/} + {/* @{props.warning.scope.handleId}*/} + {/*)}*/}
    ); From f73bbb9d02bfcc41b1ffa1fc658fee83ee20c68e Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Thu, 22 Jan 2026 10:15:20 +0100 Subject: [PATCH 22/52] chore: added tests and removed restart phase this version also has recursive goals functional --- .../MonitoringPage/MonitoringPage.module.css | 5 -- src/pages/MonitoringPage/MonitoringPage.tsx | 41 +-------- src/pages/MonitoringPage/MonitoringPageAPI.ts | 10 --- .../nodes/BasicBeliefNode.tsx | 4 +- .../monitoringPage/MonitoringPage.test.tsx | 8 +- .../monitoringPage/MonitoringPageAPI.test.ts | 11 +-- test/utils/programStore.test.ts | 83 +++++++++++++++++++ 7 files changed, 96 insertions(+), 66 deletions(-) diff --git a/src/pages/MonitoringPage/MonitoringPage.module.css b/src/pages/MonitoringPage/MonitoringPage.module.css index 5f23eea..183fe4b 100644 --- a/src/pages/MonitoringPage/MonitoringPage.module.css +++ b/src/pages/MonitoringPage/MonitoringPage.module.css @@ -77,11 +77,6 @@ color: white; } -.restartPhase{ - background-color: rgb(255, 123, 0); - color: white; -} - .restartExperiment{ background-color: red; color: white; diff --git a/src/pages/MonitoringPage/MonitoringPage.tsx b/src/pages/MonitoringPage/MonitoringPage.tsx index cd7f95e..3b79df9 100644 --- a/src/pages/MonitoringPage/MonitoringPage.tsx +++ b/src/pages/MonitoringPage/MonitoringPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import styles from './MonitoringPage.module.css'; // Store & API @@ -52,16 +52,12 @@ function useExperimentLogic() { const [phaseIndex, setPhaseIndex] = useState(0); const [isFinished, setIsFinished] = useState(false); - // Ref to suppress stream updates during the "Reset Phase" fast-forward sequence - const suppressUpdates = useRef(false); - const phaseIds = getPhaseIds(); const phaseNames = getPhaseNames(); // --- Stream Handlers --- const handleStreamUpdate = useCallback((data: ExperimentStreamData) => { - if (suppressUpdates.current) return; if (data.type === 'phase_update' && data.id) { const payload = data as PhaseUpdate; console.log(`${data.type} received, id : ${data.id}`); @@ -105,7 +101,6 @@ function useExperimentLogic() { }, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex, phaseNames]); const handleStatusUpdate = useCallback((data: unknown) => { - if (suppressUpdates.current) return; const payload = data as CondNormsStateUpdate; if (payload.type !== 'cond_norms_state_update') return; @@ -145,7 +140,7 @@ function useExperimentLogic() { } }, [setProgramState]); - const handleControlAction = async (action: "pause" | "play" | "nextPhase" | "resetPhase") => { + const handleControlAction = async (action: "pause" | "play" | "nextPhase") => { try { setLoading(true); switch (action) { @@ -160,30 +155,6 @@ function useExperimentLogic() { case "nextPhase": await nextPhase(); break; - case "resetPhase": - //make sure you don't see the phases pass to arrive back at current phase - suppressUpdates.current = true; - - const targetIndex = phaseIndex; - console.log(`Resetting phase: Restarting and skipping to index ${targetIndex}`); - const phases = graphReducer(); - setProgramState({ phases }); - - setActiveIds({}); - setPhaseIndex(0); // Visually reset to start - setGoalIndex(0); - setIsFinished(false); - - // Restart backend - await runProgramm(); - for (let i = 0; i < targetIndex; i++) { - console.log(`Skipping phase ${i}...`); - await nextPhase(); - } - suppressUpdates.current = false; - setPhaseIndex(targetIndex); - setIsPlaying(true); //Maybe you pause and then reset - break; } } catch (err) { console.error(err); @@ -251,7 +222,7 @@ function ControlPanel({ }: { loading: boolean, isPlaying: boolean, - onAction: (a: "pause" | "play" | "nextPhase" | "resetPhase") => void, + onAction: (a: "pause" | "play" | "nextPhase") => void, onReset: () => void }) { return ( @@ -276,12 +247,6 @@ function ControlPanel({ disabled={loading} >⏭ - - + @@ -189,7 +189,7 @@ function VisProgPage() { return ( <> - + ) } From 641d794cf09f8f1e95c5abd18b5f8628dbd3afca Mon Sep 17 00:00:00 2001 From: JGerla Date: Fri, 23 Jan 2026 11:42:06 +0100 Subject: [PATCH 40/52] fix: updated styles to work with darkMode ref: N25B-450 --- .../visualProgrammingUI/components/WarningSidebar.module.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css index c0d0c24..a724712 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css @@ -4,14 +4,14 @@ width: 320px; height: 100%; background: canvas; - border-left: 2px solid black; + border-left: 2px solid CanvasText; display: flex; flex-direction: column; } .warnings-header { padding: 12px; - border-bottom: 1px solid #2a2a2e; + border-bottom: 2px solid CanvasText; } .severity-tabs { @@ -62,6 +62,7 @@ padding: 0; border-radius: 5px; cursor: pointer; + color: GrayText; } .warning-item:hover { From 820884f8aaf52530f2a6f635f7e24ddd60b1b64c Mon Sep 17 00:00:00 2001 From: JGerla Date: Fri, 23 Jan 2026 11:45:08 +0100 Subject: [PATCH 41/52] fix: updated styles to work with darkMode ref: N25B-450 --- .../visualProgrammingUI/components/WarningSidebar.module.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css index a724712..461ff09 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css @@ -38,6 +38,9 @@ .severity-tab.active { color: ButtonText; border: 2px solid currentColor; + .count { + color: ButtonText; + } } .warning-group-header { From 47c5e94b8f3987654fabec68279724a4a08bc63e Mon Sep 17 00:00:00 2001 From: "Gerla, J. (Justin)" Date: Fri, 23 Jan 2026 12:57:34 +0000 Subject: [PATCH 42/52] feat: added an inferred belief node to the editor --- src/pages/VisProgPage/VisProg.module.css | 13 +- src/pages/VisProgPage/VisProg.tsx | 1 + .../visualProgrammingUI/HandleRuleLogic.ts | 12 + .../visualProgrammingUI/HandleRules.ts | 1 + .../visualProgrammingUI/NodeRegistry.ts | 26 +- .../visualProgrammingUI/VisProgStores.tsx | 39 ++- .../nodes/BasicBeliefNode.default.ts | 2 +- .../nodes/BasicBeliefNode.tsx | 14 +- .../nodes/BeliefGlobals.ts | 63 ++++ .../nodes/InferredBeliefNode.default.ts | 16 + .../nodes/InferredBeliefNode.module.css | 80 +++++ .../nodes/InferredBeliefNode.tsx | 176 +++++++++++ .../visualProgrammingUI/nodes/NormNode.tsx | 9 +- .../visualProgrammingUI/nodes/TriggerNode.tsx | 20 +- .../nodes/BeliefGlobals.test.ts | 274 ++++++++++++++++++ .../nodes/BeliefNode.test.tsx | 2 +- .../nodes/InferredBeliefNode.test.tsx | 129 +++++++++ 17 files changed, 847 insertions(+), 30 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts create mode 100644 src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts create mode 100644 src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css create mode 100644 src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/BeliefGlobals.test.ts create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 00268eb..3e099d8 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -84,7 +84,10 @@ filter: drop-shadow(0 0 0.25rem plum); } - +.node-inferred_belief { + outline: mediumpurple solid 2pt; + filter: drop-shadow(0 0 0.25rem mediumpurple); +} .draggable-node { padding: 3px 10px; @@ -158,6 +161,14 @@ filter: drop-shadow(0 0 0.25rem plum); } +.draggable-node-inferred_belief { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: mediumpurple solid 2pt; + filter: drop-shadow(0 0 0.25rem mediumpurple); +} + .planNoIterate { opacity: 0.5; font-style: italic; diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index a083bbf..753dcbe 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -164,6 +164,7 @@ function runProgram() { // 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); } /** 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/HandleRules.ts b/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts index a04282c..aca415e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts @@ -43,3 +43,4 @@ export const noSelfConnections : HandleRule = } + diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 54f0241..a4285ec 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -52,15 +52,24 @@ import TriggerNode, { TriggerTooltip } from "./nodes/TriggerNode"; import { TriggerNodeDefaults } from "./nodes/TriggerNode.default"; +import InferredBeliefNode, { + InferredBeliefConnectionTarget, + InferredBeliefConnectionSource, + InferredBeliefDisconnectionTarget, + InferredBeliefDisconnectionSource, + InferredBeliefReduce, InferredBeliefTooltip +} from "./nodes/InferredBeliefNode"; +import { InferredBeliefNodeDefaults } from "./nodes/InferredBeliefNode.default"; import BasicBeliefNode, { BasicBeliefConnectionSource, BasicBeliefConnectionTarget, BasicBeliefDisconnectionSource, BasicBeliefDisconnectionTarget, - BasicBeliefReduce, + BasicBeliefReduce +, BasicBeliefTooltip -} from "./nodes/BasicBeliefNode"; -import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default"; +} from "./nodes/BasicBeliefNode.tsx"; +import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default.ts"; /** * Registered node types in the visual programming system. @@ -76,6 +85,7 @@ export const NodeTypes = { goal: GoalNode, trigger: TriggerNode, basic_belief: BasicBeliefNode, + inferred_belief: InferredBeliefNode, }; /** @@ -91,6 +101,7 @@ export const NodeDefaults = { goal: GoalNodeDefaults, trigger: TriggerNodeDefaults, basic_belief: BasicBeliefNodeDefaults, + inferred_belief: InferredBeliefNodeDefaults, }; @@ -108,6 +119,7 @@ export const NodeReduces = { goal: GoalReduce, trigger: TriggerReduce, basic_belief: BasicBeliefReduce, + inferred_belief: InferredBeliefReduce, } @@ -126,6 +138,7 @@ export const NodeConnections = { goal: GoalConnectionTarget, trigger: TriggerConnectionTarget, basic_belief: BasicBeliefConnectionTarget, + inferred_belief: InferredBeliefConnectionTarget, }, Sources: { start: StartConnectionSource, @@ -134,7 +147,8 @@ export const NodeConnections = { norm: NormConnectionSource, goal: GoalConnectionSource, trigger: TriggerConnectionSource, - basic_belief: BasicBeliefConnectionSource + basic_belief: BasicBeliefConnectionSource, + inferred_belief: InferredBeliefConnectionSource, } } @@ -153,6 +167,7 @@ export const NodeDisconnections = { goal: GoalDisconnectionTarget, trigger: TriggerDisconnectionTarget, basic_belief: BasicBeliefDisconnectionTarget, + inferred_belief: InferredBeliefDisconnectionTarget, }, Sources: { start: StartDisconnectionSource, @@ -162,6 +177,7 @@ export const NodeDisconnections = { goal: GoalDisconnectionSource, trigger: TriggerDisconnectionSource, basic_belief: BasicBeliefDisconnectionSource, + inferred_belief: InferredBeliefDisconnectionSource, }, } @@ -186,6 +202,7 @@ export const NodesInPhase = { end: () => false, phase: () => false, basic_belief: () => false, + inferred_belief: () => false, } /** @@ -199,4 +216,5 @@ export const NodeTooltips = { goal: GoalTooltip, trigger: TriggerTooltip, basic_belief: BasicBeliefTooltip, + inferred_belief: InferredBeliefTooltip, } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index defa934..2831748 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -9,6 +9,7 @@ import { type XYPosition, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; +import {type ConnectionContext, validateConnectionWithRules} from "./HandleRuleLogic.ts"; import type { FlowState } from './VisProgTypes'; import { NodeDefaults, @@ -129,7 +130,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 @@ -188,7 +223,7 @@ const useFlowStore = create(UndoRedo((set, get) => ({ // 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({ diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts index 655aaaa..01f1cfa 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts @@ -1,4 +1,4 @@ -import type { BasicBeliefNodeData } from "./BasicBeliefNode"; +import type { BasicBeliefNodeData } from "./BasicBeliefNode.tsx"; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index bed642f..5348b06 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -3,13 +3,14 @@ import { Position, type Node, } from '@xyflow/react'; -import { Toolbar } from '../components/NodeComponents'; +import { Toolbar } from '../components/NodeComponents.tsx'; import styles from '../../VisProg.module.css'; import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx"; import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts"; -import useFlowStore from '../VisProgStores'; -import { TextField } from '../../../../components/TextField'; -import { MultilineTextField } from '../../../../components/MultilineTextField'; +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 @@ -31,7 +32,7 @@ export type BasicBeliefNodeData = { }; // These are all the types a basic belief could be. -type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion +export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"}; type Semantic = { type: "semantic", id: string, value: string, description: string, label: "Detected with LLM:"}; type DetectedObject = { type: "object", id: string, value: string, label: "Object found:"}; @@ -189,7 +190,8 @@ export default function BasicBeliefNode(props: NodeProps) { )} diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts new file mode 100644 index 0000000..b92c5b2 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts @@ -0,0 +1,63 @@ +import {getOutgoers, type Node} from '@xyflow/react'; +import {type HandleRule, type RuleResult, ruleResult} from "../HandleRuleLogic.ts"; +import useFlowStore from "../VisProgStores.tsx"; +import {BasicBeliefReduce} from "./BasicBeliefNode.tsx"; +import {type InferredBeliefNodeData, InferredBeliefReduce} from "./InferredBeliefNode.tsx"; + +export function BeliefGlobalReduce(beliefNode: Node, nodes: Node[]) { + switch (beliefNode.type) { + case 'basic_belief': + return BasicBeliefReduce(beliefNode, nodes); + case 'inferred_belief': + return InferredBeliefReduce(beliefNode, nodes); + } +} + +export const noMatchingLeftRightBelief : HandleRule = (connection, _)=> { + const { nodes } = useFlowStore.getState(); + const thisNode = nodes.find(node => node.id === connection.target && node.type === 'inferred_belief'); + if (!thisNode) return ruleResult.satisfied; + + const iBelief = (thisNode.data as InferredBeliefNodeData).inferredBelief; + return (iBelief.left === connection.source || iBelief.right === connection.source) + ? ruleResult.notSatisfied("Connecting one belief to both input handles of an inferred belief node is not allowed") + : ruleResult.satisfied; +} +/** + * makes it impossible to connect Inferred belief nodes + * if the connection would create a cyclical connection between inferred beliefs + */ +export const noBeliefCycles: HandleRule = (connection, _): RuleResult => { + const {nodes, edges} = useFlowStore.getState(); + const defaultErrorMessage = "Cyclical connection exists between inferred beliefs"; + + /** + * recursively checks for cyclical connections between InferredBelief nodes + * + * to check for a cycle provide the source of an attempted connection as the targetNode for the cycle check, + * the currentNodeId should be initialised with the id of the targetNode of the attempted connection. + * + * @param {string} targetNodeId - the id of the node we are looking for as the endpoint of a cyclical connection + * @param {string} currentNodeId - the id of the node we are checking for outgoing connections to the provided target node + * @returns {RuleResult} + */ + function checkForCycle(targetNodeId: string, currentNodeId: string): RuleResult { + const outgoingBeliefs = getOutgoers({id: currentNodeId}, nodes, edges) + .filter(node => node.type === 'inferred_belief'); + + if (outgoingBeliefs.length === 0) return ruleResult.satisfied; + if (outgoingBeliefs.some(node => node.id === targetNodeId)) return ruleResult + .notSatisfied(defaultErrorMessage); + + const next = outgoingBeliefs.map(node => checkForCycle(targetNodeId, node.id)) + .find(result => !result.isSatisfied); + + return next + ? next + : ruleResult.satisfied; + } + + return connection.source === connection.target + ? ruleResult.notSatisfied(defaultErrorMessage) + : checkForCycle(connection.source, connection.target); +}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts new file mode 100644 index 0000000..976ee16 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts @@ -0,0 +1,16 @@ +import type { InferredBeliefNodeData } from "./InferredBeliefNode.tsx"; + + +/** + * Default data for this node + */ +export const InferredBeliefNodeDefaults: InferredBeliefNodeData = { + label: "AND/OR", + droppable: true, + inferredBelief: { + left: undefined, + operator: true, + right: undefined + }, + hasReduce: true, +}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css new file mode 100644 index 0000000..2f9b7ae --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css @@ -0,0 +1,80 @@ +.operator-switch { + display: inline-flex; + align-items: center; + gap: 0.5em; + cursor: pointer; + font-family: sans-serif; + /* Change this font-size to scale the whole component */ + font-size: 12px; +} + +/* hide the default checkbox */ +.operator-switch input { + display: none; +} + +/* The Track */ +.switch-visual { + position: relative; + /* height is now 3x the font size */ + height: 3em; + aspect-ratio: 1 / 2; + background-color: ButtonFace; + border-radius: 2em; + transition: 0.2s; +} + +/* The Knob */ +.switch-visual::after { + content: ""; + position: absolute; + top: 0.1em; + left: 0.1em; + width: 1em; + height: 1em; + background: Canvas; + border: 0.175em solid mediumpurple; + border-radius: 50%; + transition: transform 0.2s ease-in-out, border-color 0.2s; +} + +/* Labels */ +.switch-labels { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 3em; /* Matches the track height */ + font-weight: 800; + color: Canvas; + line-height: 1.4; + padding: 0.2em 0; +} + +.operator-switch input:checked + .switch-visual::after { + /* Moves the slider down */ + transform: translateY(1.4em); +} + +/*change the colours to highlight the selected operator*/ +.operator-switch input:checked ~ .switch-labels{ + :first-child { + transition: ease-in-out color 0.2s; + color: ButtonFace; + } + :last-child { + transition: ease-in-out color 0.2s; + color: mediumpurple; + } +} + +.operator-switch input:not(:checked) ~ .switch-labels{ + :first-child { + transition: ease-in-out color 0.2s; + color: mediumpurple; + } + :last-child { + transition: ease-in-out color 0.2s; + color: ButtonFace; + } + +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx new file mode 100644 index 0000000..be5d4ec --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx @@ -0,0 +1,176 @@ +import {getConnectedEdges, type Node, type NodeProps, Position} from '@xyflow/react'; +import {useState} from "react"; +import styles from '../../VisProg.module.css'; +import {Toolbar} from '../components/NodeComponents.tsx'; +import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; +import {allowOnlyConnectionsFromType} from "../HandleRules.ts"; +import useFlowStore from "../VisProgStores.tsx"; +import {BeliefGlobalReduce, noBeliefCycles, noMatchingLeftRightBelief} from "./BeliefGlobals.ts"; +import switchStyles from './InferredBeliefNode.module.css'; + + +/** + * The default data structure for an InferredBelief node + */ +export type InferredBeliefNodeData = { + label: string; + droppable: boolean; + inferredBelief: InferredBelief; + hasReduce: boolean; +}; + +/** + * stores a boolean to represent the operator + * and a left and right BeliefNode (can be both an inferred and a basic belief) + * in the form of their corresponding id's + */ +export type InferredBelief = { + left: string | undefined, + operator: boolean, + right: string | undefined, +} + +export type InferredBeliefNode = Node; + +/** + * This function is called whenever a connection is made with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the received connection + */ +export function InferredBeliefConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + const data = _thisNode.data as InferredBeliefNodeData; + + if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId + && ['basic_belief', 'inferred_belief'].includes(node.type!))) + ) { + const connectedEdges = getConnectedEdges([_thisNode], useFlowStore.getState().edges); + switch(connectedEdges.find(edge => edge.source === _sourceNodeId)?.targetHandle){ + case 'beliefLeft': data.inferredBelief.left = _sourceNodeId; break; + case 'beliefRight': data.inferredBelief.right = _sourceNodeId; break; + } + } +} + +/** + * This function is called whenever a connection is made with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function InferredBeliefConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function InferredBeliefDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + const data = _thisNode.data as InferredBeliefNodeData; + + if (_sourceNodeId === data.inferredBelief.left) data.inferredBelief.left = undefined; + if (_sourceNodeId === data.inferredBelief.right) data.inferredBelief.right = undefined; +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function InferredBeliefDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +export const InferredBeliefTooltip = ` + Combines two beliefs into a single belief using logical inference, + the node can be toggled between using "AND" and "OR" mode for inference`; +/** + * Defines how an InferredBelief node should be rendered + * @param {NodeProps} props - Node properties provided by React Flow, including `id` and `data`. + * @returns The rendered InferredBeliefNode React element. (React.JSX.Element) + */ +export default function InferredBeliefNode(props: NodeProps) { + const data = props.data; + const { updateNodeData } = useFlowStore(); + // start of as an AND operator, true: "AND", false: "OR" + const [enforceAllBeliefs, setEnforceAllBeliefs] = useState(true); + + // used to toggle operator + function onToggle() { + const newOperator = !enforceAllBeliefs; // compute the new value + setEnforceAllBeliefs(newOperator); + + updateNodeData(props.id, { + ...data, + inferredBelief: { + ...data.inferredBelief, + operator: enforceAllBeliefs, + } + }); + } + + return ( + <> + +
    + {/* The checkbox used to toggle the operator between 'AND' and 'OR' */} + + + + {/* outgoing connections */} + + + {/* incoming connections */} + + +
    + + ); +}; + +/** + * Reduces each BasicBelief, including its children down into its core data. + * @param {Node} node - The BasicBelief node to reduce. + * @param {Node[]} nodes - The list of all nodes in the current flow graph. + * @returns A simplified object containing the node label and its list of BasicBeliefs. + */ +export function InferredBeliefReduce(node: Node, nodes: Node[]) { + const data = node.data as InferredBeliefNodeData; + const leftBelief = nodes.find((node) => node.id === data.inferredBelief.left); + const rightBelief = nodes.find((node) => node.id === data.inferredBelief.right); + + if (!leftBelief) { throw new Error("No Left belief found")} + if (!rightBelief) { throw new Error("No Right Belief found")} + + const result: Record = { + id: node.id, + left: BeliefGlobalReduce(leftBelief, nodes), + operator: data.inferredBelief.operator ? "AND" : "OR", + right: BeliefGlobalReduce(rightBelief, nodes), + }; + + return result +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 6cde46a..8ee5462 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -9,7 +9,7 @@ import { TextField } from '../../../../components/TextField'; import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts"; import useFlowStore from '../VisProgStores'; -import { BasicBeliefReduce } from './BasicBeliefNode'; +import {BeliefGlobalReduce} from "./BeliefGlobals.ts"; /** * The default data dot a phase node @@ -81,7 +81,7 @@ export default function NormNode(props: NodeProps) { allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]) ]}/> ; @@ -105,11 +105,10 @@ export function NormReduce(node: Node, nodes: Node[]) { }; if (data.condition) { - const reducer = BasicBeliefReduce; // TODO: also add inferred. const conditionNode = nodes.find((node) => node.id === data.condition); // In case something went wrong, and our condition doesn't actually exist; if (conditionNode == undefined) return result; - result["condition"] = reducer(conditionNode, nodes) + result["condition"] = BeliefGlobalReduce(conditionNode, nodes) } return result } @@ -126,7 +125,7 @@ export const NormTooltip = ` export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) { const data = _thisNode.data as NormNodeData; // If we got a belief connected, this is the condition for the norm. - if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { + if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && ['basic_belief', 'inferred_belief'].includes(node.type!)))) { data.condition = _sourceNodeId; } } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 8b3378a..3004fe8 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -9,8 +9,8 @@ import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleB import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts"; import useFlowStore from '../VisProgStores'; import {PlanReduce, type Plan } from '../components/Plan'; -import PlanEditorDialog from '../components/PlanEditor'; -import { BasicBeliefReduce } from './BasicBeliefNode'; +import PlanEditorDialog from '../components/PlanEditor'; +import {BeliefGlobalReduce} from "./BeliefGlobals.ts"; import type { GoalNode } from './GoalNode.tsx'; import { defaultPlan } from '../components/Plan.default.ts'; import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx'; @@ -50,9 +50,9 @@ export default function TriggerNode(props: NodeProps) { const setName= (value: string) => { updateNodeData(props.id, {...data, name: value}) } - + return <> - +
    ) { type="target" position={Position.Bottom} id="TriggerBeliefs" - style={{ left: '40%' }} + style={{ left: '40%' }} rules={[ - allowOnlyConnectionsFromType(['basic_belief']), + allowOnlyConnectionsFromType(["basic_belief","inferred_belief"]), ]} /> @@ -102,13 +102,13 @@ export default function TriggerNode(props: NodeProps) { /** * Reduces each Trigger, including its children down into its core data. * @param node - The Trigger node to reduce. - * @param _nodes - The list of all nodes in the current flow graph. + * @param nodes - The list of all nodes in the current flow graph. * @returns A simplified object containing the node label and its list of triggers. */ export function TriggerReduce(node: Node, nodes: Node[]) { const data = node.data as TriggerNodeData; const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined - const conditionData = conditionNode ? BasicBeliefReduce(conditionNode, nodes) : "" + const conditionData = conditionNode ? BeliefGlobalReduce(conditionNode, nodes) : "" return { id: node.id, name: node.data.name, @@ -136,7 +136,7 @@ export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string) const otherNode = nodes.find((x) => x.id === _sourceNodeId) if (!otherNode) return; - if (otherNode.type === 'basic_belief' /* TODO: Add the option for an inferred belief */) { + if (['basic_belief', 'inferred_belief'].includes(otherNode.type!)) { data.condition = _sourceNodeId; } @@ -172,7 +172,7 @@ export function TriggerDisconnectionTarget(_thisNode: Node, _sourceNodeId: strin const data = _thisNode.data as TriggerNodeData; // remove if the target of disconnection was our condition if (_sourceNodeId == data.condition) data.condition = undefined - + data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId) } diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefGlobals.test.ts b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefGlobals.test.ts new file mode 100644 index 0000000..54cfa7f --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefGlobals.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import {type Connection, getOutgoers, type Node} from '@xyflow/react'; +import {ruleResult} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts"; +import {BasicBeliefReduce} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx"; +import { + BeliefGlobalReduce, noBeliefCycles, + noMatchingLeftRightBelief +} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts"; +import { InferredBeliefReduce } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx"; +import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; +import * as BasicModule from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode'; +import * as InferredModule from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx'; +import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; + + +describe('BeliefGlobalReduce', () => { + const nodes: Node[] = []; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('delegates to BasicBeliefReduce for basic_belief nodes', () => { + const spy = jest + .spyOn(BasicModule, 'BasicBeliefReduce') + .mockReturnValue('basic-result' as any); + + const node = { id: '1', type: 'basic_belief' } as Node; + + const result = BeliefGlobalReduce(node, nodes); + + expect(spy).toHaveBeenCalledWith(node, nodes); + expect(result).toBe('basic-result'); + }); + + it('delegates to InferredBeliefReduce for inferred_belief nodes', () => { + const spy = jest + .spyOn(InferredModule, 'InferredBeliefReduce') + .mockReturnValue('inferred-result' as any); + + const node = { id: '2', type: 'inferred_belief' } as Node; + + const result = BeliefGlobalReduce(node, nodes); + + expect(spy).toHaveBeenCalledWith(node, nodes); + expect(result).toBe('inferred-result'); + }); + + it('returns undefined for unknown node types', () => { + const node = { id: '3', type: 'other' } as Node; + + const result = BeliefGlobalReduce(node, nodes); + + expect(result).toBeUndefined(); + expect(BasicBeliefReduce).not.toHaveBeenCalled(); + expect(InferredBeliefReduce).not.toHaveBeenCalled(); + }); +}); + +describe('noMatchingLeftRightBelief rule', () => { + let getStateSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + getStateSpy = jest.spyOn(FlowStore.default, 'getState'); + }); + + it('is satisfied when target node is not an inferred belief', () => { + getStateSpy.mockReturnValue({ + nodes: [{ id: 't1', type: 'basic_belief' }], + } as any); + + const result = noMatchingLeftRightBelief( + { source: 's1', target: 't1' } as Connection, + null as any + ); + + expect(result).toBe(ruleResult.satisfied); + }); + + it('is satisfied when inferred belief has no matching left/right', () => { + getStateSpy.mockReturnValue({ + nodes: [ + { + id: 't1', + type: 'inferred_belief', + data: { + inferredBelief: { + left: 'a', + right: 'b', + }, + }, + }, + ], + } as any); + + const result = noMatchingLeftRightBelief( + { source: 'c', target: 't1' } as Connection, + null as any + ); + + expect(result).toBe(ruleResult.satisfied); + }); + + it('is NOT satisfied when source matches left input', () => { + getStateSpy.mockReturnValue({ + nodes: [ + { + id: 't1', + type: 'inferred_belief', + data: { + inferredBelief: { + left: 's1', + right: 's2', + }, + }, + }, + ], + } as any); + + const result = noMatchingLeftRightBelief( + { source: 's1', target: 't1' } as Connection, + null as any + ); + + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain( + 'Connecting one belief to both input handles of an inferred belief node is not allowed' + ); + } + }); + + it('is NOT satisfied when source matches right input', () => { + getStateSpy.mockReturnValue({ + nodes: [ + { + id: 't1', + type: 'inferred_belief', + data: { + inferredBelief: { + left: 's1', + right: 's2', + }, + }, + }, + ], + } as any); + + const result = noMatchingLeftRightBelief( + { source: 's2', target: 't1' } as Connection, + null as any + ); + + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain( + 'Connecting one belief to both input handles of an inferred belief node is not allowed' + ); + } + }); +}); + + +jest.mock('@xyflow/react', () => ({ + getOutgoers: jest.fn(), + getConnectedEdges: jest.fn(), // include if some tests require it +})); + +describe('noBeliefCycles rule', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns notSatisfied when source === target', () => { + const result = noBeliefCycles({ source: 'n1', target: 'n1' } as any, null as any); + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain('Cyclical connection exists'); + } + }); + + it('returns satisfied when there are no outgoing inferred beliefs', () => { + jest.spyOn(useFlowStore, 'getState').mockReturnValue({ + nodes: [{ id: 'n1', type: 'inferred_belief' }], + edges: [], + } as any); + + (getOutgoers as jest.Mock).mockReturnValue([]); + + const result = noBeliefCycles({ source: 'n1', target: 'n2' } as any, null as any); + expect(result).toBe(ruleResult.satisfied); + }); + + it('returns notSatisfied for direct cycle', () => { + jest.spyOn(useFlowStore, 'getState').mockReturnValue({ + nodes: [ + { id: 'n1', type: 'inferred_belief' }, + { id: 'n2', type: 'inferred_belief' }, + ], + edges: [{ source: 'n2', target: 'n1' }], + } as any); + + // @ts-expect-error is acting up + (getOutgoers as jest.Mock).mockImplementation(({ id }) => { + if (id === 'n2') return [{ id: 'n1', type: 'inferred_belief' }]; + return []; + }); + + const result = noBeliefCycles({ source: 'n1', target: 'n2' } as any, null as any); + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain('Cyclical connection exists'); + } + }); + + it('returns notSatisfied for indirect cycle', () => { + jest.spyOn(useFlowStore, 'getState').mockReturnValue({ + nodes: [ + { id: 'A', type: 'inferred_belief' }, + { id: 'B', type: 'inferred_belief' }, + { id: 'C', type: 'inferred_belief' }, + ], + edges: [ + { source: 'A', target: 'B' }, + { source: 'B', target: 'C' }, + { source: 'C', target: 'A' }, + ], + } as any); + + // @ts-expect-error is acting up + (getOutgoers as jest.Mock).mockImplementation(({ id }) => { + const mapping: Record = { + A: [{ id: 'B', type: 'inferred_belief' }], + B: [{ id: 'C', type: 'inferred_belief' }], + C: [{ id: 'A', type: 'inferred_belief' }], + }; + return mapping[id] || []; + }); + + const result = noBeliefCycles({ source: 'A', target: 'B' } as any, null as any); + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain('Cyclical connection exists'); + } + }); + + it('returns satisfied when no cycle exists in a multi-node graph', () => { + jest.spyOn(useFlowStore, 'getState').mockReturnValue({ + nodes: [ + { id: 'A', type: 'inferred_belief' }, + { id: 'B', type: 'inferred_belief' }, + { id: 'C', type: 'inferred_belief' }, + ], + edges: [ + { source: 'A', target: 'B' }, + { source: 'B', target: 'C' }, + ], + } as any); + + // @ts-expect-error is acting up + (getOutgoers as jest.Mock).mockImplementation(({ id }) => { + const mapping: Record = { + A: [{ id: 'B', type: 'inferred_belief' }], + B: [{ id: 'C', type: 'inferred_belief' }], + C: [], + }; + return mapping[id] || []; + }); + + const result = noBeliefCycles({ source: 'A', target: 'B' } as any, null as any); + expect(result).toBe(ruleResult.satisfied); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx index 34872c9..a023769 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx @@ -3,7 +3,7 @@ import { describe, it, beforeEach } from '@jest/globals'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; -import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode'; +import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; import '@testing-library/jest-dom'; diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx new file mode 100644 index 0000000..d683b23 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import type {Node, Edge} from '@xyflow/react'; +import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; +import { + type InferredBelief, + InferredBeliefConnectionTarget, + InferredBeliefDisconnectionTarget, + InferredBeliefReduce, +} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx'; + +// helper functions +function inferredNode(overrides = {}): Node { + return { + id: 'i1', + type: 'inferred_belief', + position: {x: 0, y: 0}, + data: { + inferredBelief: { + left: undefined, + operator: true, + right: undefined, + }, + ...overrides, + }, + } as Node; +} + +describe('InferredBelief connection logic', () => { + let getStateSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + getStateSpy = jest.spyOn(FlowStore.default, 'getState'); + }); + + it('sets left belief when connected on beliefLeft handle', () => { + const node = inferredNode(); + + getStateSpy.mockReturnValue({ + nodes: [{ id: 'b1', type: 'basic_belief' }], + edges: [ + { + source: 'b1', + target: 'i1', + targetHandle: 'beliefLeft', + } as Edge, + ], + } as any); + + InferredBeliefConnectionTarget(node, 'b1'); + + expect((node.data.inferredBelief as InferredBelief).left).toBe('b1'); + expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined(); + }); + + it('sets right belief when connected on beliefRight handle', () => { + const node = inferredNode(); + + getStateSpy.mockReturnValue({ + nodes: [{ id: 'b2', type: 'basic_belief' }], + edges: [ + { + source: 'b2', + target: 'i1', + targetHandle: 'beliefRight', + } as Edge, + ], + } as any); + + InferredBeliefConnectionTarget(node, 'b2'); + + expect((node.data.inferredBelief as InferredBelief).right).toBe('b2'); + }); + + it('ignores connections from unsupported node types', () => { + const node = inferredNode(); + + getStateSpy.mockReturnValue({ + nodes: [{ id: 'x', type: 'norm' }], + edges: [], + } as any); + + InferredBeliefConnectionTarget(node, 'x'); + + expect((node.data.inferredBelief as InferredBelief).left).toBeUndefined(); + expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined(); + }); + + it('clears left or right belief on disconnection', () => { + const node = inferredNode({ + inferredBelief: { left: 'a', right: 'b', operator: true }, + }); + + InferredBeliefDisconnectionTarget(node, 'a'); + expect((node.data.inferredBelief as InferredBelief).left).toBeUndefined(); + + InferredBeliefDisconnectionTarget(node, 'b'); + expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined(); + }); +}); + +describe('InferredBeliefReduce', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws if left belief is missing', () => { + const node = inferredNode({ + inferredBelief: { left: 'l', right: 'r', operator: true }, + }); + + expect(() => + InferredBeliefReduce(node, [{ id: 'r' } as Node]) + ).toThrow('No Left belief found'); + }); + + it('throws if right belief is missing', () => { + const node = inferredNode({ + inferredBelief: { left: 'l', right: 'r', operator: true }, + }); + + expect(() => + InferredBeliefReduce(node, [{ id: 'l' } as Node]) + ).toThrow('No Right Belief found'); + }); + +}); + + From 85b84c2281a7d4f74d06605a73ab1a320313f538 Mon Sep 17 00:00:00 2001 From: JGerla Date: Fri, 23 Jan 2026 17:10:48 +0100 Subject: [PATCH 43/52] feat: added visibility toggle with autoHide option ref: N25B-450 --- src/pages/VisProgPage/VisProg.tsx | 10 +-- .../components/WarningSidebar.module.css | 83 +++++++++++++++++-- .../components/WarningSidebar.tsx | 71 ++++++++++++---- 3 files changed, 134 insertions(+), 30 deletions(-) diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index f543eaa..d6e45e6 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -7,6 +7,7 @@ import { MarkerType, getOutgoers } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; +import warningStyles from './visualProgrammingUI/components/WarningSidebar.module.css' import {type CSSProperties, useEffect, useState} from "react"; import {useShallow} from 'zustand/react/shallow'; import orderPhaseNodeArray from "../../utils/orderPhaseNodes.ts"; @@ -114,8 +115,6 @@ const VisProgUI = () => { } },[edges, registerWarning, unregisterWarning]) - - return (
    { + + + - +
    ); }; @@ -221,7 +223,6 @@ const checkPhaseChain = (): boolean => { const next = outgoingPhases.map(node => checkForCompleteChain(node.id)) .find(result => result); - console.log(next); return !!next; } @@ -246,7 +247,6 @@ function VisProgPage() { // however this would cause unneeded updates // eslint-disable-next-line react-hooks/exhaustive-deps }, [severityIndex]); - console.log(severityIndex); return ( <> diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css index 461ff09..82168dc 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css @@ -1,12 +1,51 @@ .warnings-sidebar { - min-width: 320px; - max-width: 320px; - width: 320px; + min-width: auto; + max-width: 340px; + margin-right: 0; height: 100%; background: canvas; - border-left: 2px solid CanvasText; 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 { @@ -45,12 +84,14 @@ .warning-group-header { background: ButtonFace; - padding: 4px; + padding: 6px; + font-weight: bold; } .warnings-list { flex: 1; - overflow-y: auto; + min-height: 0; + overflow-y: scroll; } .warnings-empty { @@ -131,4 +172,32 @@ .warning-item .description { padding: 5px 10px; font-size: 0.8rem; -} \ No newline at end of file +} + +.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 index 49a22a7..9e030e1 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx @@ -11,23 +11,64 @@ import styles from "./WarningSidebar.module.css"; 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]); - useEffect(() => {}, [warnings]); const filtered = severityFilter === 'ALL' ? warnings : warnings.filter(w => w.severity === severityFilter); - return ( -