Merge branch 'feat/add-node-tooltips' into 'demo'

feat: added node-tooltips to the editor

See merge request ics/sp/2025/n25b/pepperplus-ui!39
This commit was merged in pull request #39.
This commit is contained in:
Björn Otgaar
2026-01-13 11:29:45 +00:00
16 changed files with 332 additions and 28 deletions

View File

@@ -33,6 +33,12 @@
/* Node Styles */
:global(.react-flow__node.selected) {
outline: 1px dashed blue !important;
border-radius: 5pt;
outline-offset: 4px;
}
.default-node {
padding: 10px 15px;
background-color: canvas;
@@ -41,6 +47,8 @@
filter: drop-shadow(0 0 0.75rem black);
}
.node-norm {
outline: rgb(0, 149, 25) solid 2pt;
filter: drop-shadow(0 0 0.25rem forestgreen);
@@ -76,10 +84,13 @@
filter: drop-shadow(0 0 0.25rem plum);
}
.draggable-node {
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: black solid 2pt;
filter: drop-shadow(0 0 0.75rem black);
}
@@ -88,6 +99,7 @@
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: forestgreen solid 2pt;
filter: drop-shadow(0 0 0.25rem forestgreen);
}
@@ -96,6 +108,7 @@
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: yellow solid 2pt;
filter: drop-shadow(0 0 0.25rem yellow);
}
@@ -104,6 +117,7 @@
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: teal solid 2pt;
filter: drop-shadow(0 0 0.25rem teal);
}
@@ -112,6 +126,7 @@
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: dodgerblue solid 2pt;
filter: drop-shadow(0 0 0.25rem dodgerblue);
}
@@ -120,6 +135,7 @@
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: orange solid 2pt;
filter: drop-shadow(0 0 0.25rem orange);
}
@@ -128,6 +144,7 @@
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: red solid 2pt;
filter: drop-shadow(0 0 0.25rem red);
}
@@ -136,6 +153,7 @@
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: plum solid 2pt;
filter: drop-shadow(0 0 0.25rem plum);
}
@@ -145,3 +163,52 @@
font-style: italic;
text-decoration: line-through;
}
.node-toolbar-tooltip {
background-color: darkgray;
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-family: sans-serif;
font-weight: bold;
cursor: help;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
white-space: nowrap;
}
.custom-tooltip {
pointer-events: none;
background-color: Canvas;
color: CanvasText;
padding: 8px 12px;
border-radius: 0 6px 6px 0;
outline: CanvasText solid 2px;
font-size: 14px;
filter: drop-shadow(0 0 0.25rem CanvasText);
white-space: nowrap;
position: relative;
animation: fadeIn 0.2s ease-out;
}
.custom-tooltip-header {
pointer-events: none;
background-color: CanvasText;
color: Canvas;
padding: 8px 12px;
border-radius: 6px 0 0 6px;
outline: CanvasText solid 2px;
font-size: 14px;
font-weight: bold;
font-variant-caps: small-caps;
filter: drop-shadow(0 0 0.25rem CanvasText);
white-space: nowrap;
position: relative;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}

View File

@@ -7,7 +7,7 @@ import {
MarkerType,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {useEffect} from "react";
import {type CSSProperties, useEffect, useState} from "react";
import {useShallow} from 'zustand/react/shallow';
import orderPhaseNodeArray from "../../utils/orderPhaseNodes.ts";
import useProgramStore from "../../utils/programStore.ts";
@@ -79,7 +79,7 @@ const VisProgUI = () => {
endBatchAction,
scrollable
} = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore
const [zoom, setZoom] = useState(1);
// adds ctrl+z and ctrl+y support to respectively undo and redo actions
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@@ -91,7 +91,7 @@ const VisProgUI = () => {
});
return (
<div className={`${styles.innerEditorContainer} round-lg border-lg`}>
<div className={`${styles.innerEditorContainer} round-lg border-lg`} style={({'--flow-zoom': zoom} as CSSProperties)}>
<ReactFlow
nodes={nodes}
edges={edges}
@@ -107,6 +107,7 @@ const VisProgUI = () => {
onNodeDragStart={beginBatchAction}
onNodeDragStop={endBatchAction}
preventScrolling={scrollable}
onMove={(_, viewport) => setZoom(viewport.zoom)}
snapToGrid
fitView
proOptions={{hideAttribution: true}}

View File

@@ -3,7 +3,8 @@ import EndNode, {
EndConnectionSource,
EndDisconnectionTarget,
EndDisconnectionSource,
EndReduce
EndReduce,
EndTooltip
} from "./nodes/EndNode";
import { EndNodeDefaults } from "./nodes/EndNode.default";
import StartNode, {
@@ -11,7 +12,8 @@ import StartNode, {
StartConnectionSource,
StartDisconnectionTarget,
StartDisconnectionSource,
StartReduce
StartReduce,
StartTooltip
} from "./nodes/StartNode";
import { StartNodeDefaults } from "./nodes/StartNode.default";
import PhaseNode, {
@@ -19,7 +21,8 @@ import PhaseNode, {
PhaseConnectionSource,
PhaseDisconnectionTarget,
PhaseDisconnectionSource,
PhaseReduce
PhaseReduce,
PhaseTooltip
} from "./nodes/PhaseNode";
import { PhaseNodeDefaults } from "./nodes/PhaseNode.default";
import NormNode, {
@@ -27,7 +30,8 @@ import NormNode, {
NormConnectionSource,
NormDisconnectionTarget,
NormDisconnectionSource,
NormReduce
NormReduce,
NormTooltip
} from "./nodes/NormNode";
import { NormNodeDefaults } from "./nodes/NormNode.default";
import GoalNode, {
@@ -35,7 +39,8 @@ import GoalNode, {
GoalConnectionSource,
GoalDisconnectionTarget,
GoalDisconnectionSource,
GoalReduce
GoalReduce,
GoalTooltip
} from "./nodes/GoalNode";
import { GoalNodeDefaults } from "./nodes/GoalNode.default";
import TriggerNode, {
@@ -43,10 +48,18 @@ import TriggerNode, {
TriggerConnectionSource,
TriggerDisconnectionTarget,
TriggerDisconnectionSource,
TriggerReduce
TriggerReduce,
TriggerTooltip
} from "./nodes/TriggerNode";
import { TriggerNodeDefaults } from "./nodes/TriggerNode.default";
import BasicBeliefNode, { BasicBeliefConnectionSource, BasicBeliefConnectionTarget, BasicBeliefDisconnectionSource, BasicBeliefDisconnectionTarget, BasicBeliefReduce } from "./nodes/BasicBeliefNode";
import BasicBeliefNode, {
BasicBeliefConnectionSource,
BasicBeliefConnectionTarget,
BasicBeliefDisconnectionSource,
BasicBeliefDisconnectionTarget,
BasicBeliefReduce,
BasicBeliefTooltip
} from "./nodes/BasicBeliefNode";
import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default";
/**
@@ -174,3 +187,16 @@ export const NodesInPhase = {
phase: () => false,
basic_belief: () => false,
}
/**
* Collects the tooltips for all nodeTypes so they can be accessed by the tooltip component
*/
export const NodeTooltips = {
start: StartTooltip,
end: EndTooltip,
phase: PhaseTooltip,
norm: NormTooltip,
goal: GoalTooltip,
trigger: TriggerTooltip,
basic_belief: BasicBeliefTooltip,
}

View File

@@ -45,9 +45,9 @@ function createNode(id: string, type: string, position: XYPosition, data: Record
//* Initial nodes, created by using createNode. */
// Start and End don't need to apply the UUID, since they are technically never compiled into a program.
const startNode = createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false)
const endNode = createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false)
const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:200, y:100}, {label: "Phase 1", children : [], isFirstPhase: false, nextPhaseId: null})
const startNode = createNode('start', 'start', {x: 110, y: 100}, {label: "Start"}, false)
const endNode = createNode('end', 'end', {x: 590, y: 100}, {label: "End"}, false)
const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:235, y:100}, {label: "Phase 1", children : [], isFirstPhase: false, nextPhaseId: null})
const initialNodes : Node[] = [startNode, endNode, initialPhaseNode,];

View File

@@ -3,7 +3,8 @@ import { useReactFlow, type XYPosition } from '@xyflow/react';
import { type ReactNode, useCallback, useRef, useState } from 'react';
import useFlowStore from '../VisProgStores';
import styles from '../../VisProg.module.css';
import { NodeDefaults, type NodeTypes } from '../NodeRegistry'
import { NodeDefaults, type NodeTypes} from '../NodeRegistry'
import {Tooltip} from "./NodeComponents.tsx";
/**
* Props for a draggable node within the drag-and-drop toolbar.
@@ -47,14 +48,17 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP
});
return (
<div className={className}
ref={draggableRef}
id={`draggable-${nodeType}`}
data-testid={`draggable-${nodeType}`}
>
{children}
</div>
);
<Tooltip nodeType={nodeType}>
<div>
<div className={className}
ref={draggableRef}
id={`draggable-${nodeType}`}
data-testid={`draggable-${nodeType}`}
>
{children}
</div>
</div>
</Tooltip>)
}
/**
@@ -133,7 +137,7 @@ export function DndToolbar() {
}));
return (
<div className={`flex-col gap-lg padding-md ${styles.innerDndPanel}`}>
<div className={`flex-col gap-lg padding-md ${styles.innerDndPanel}`} id={"draggable-sidebar"}>
<div className="description">
You can drag these nodes to the pane to create new nodes.
</div>

View File

@@ -1,7 +1,12 @@
import { NodeToolbar } from '@xyflow/react';
import {NodeToolbar} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {type JSX, useState} from "react";
import {createPortal} from "react-dom";
import styles from "../../VisProg.module.css";
import {NodeTooltips} from "../NodeRegistry.ts";
import useFlowStore from "../VisProgStores.tsx";
/**
* Props for the Toolbar component.
*
@@ -24,14 +29,94 @@ type ToolbarProps = {
* @constructor
*/
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
const {deleteNode} = useFlowStore();
const {nodes, deleteNode} = useFlowStore();
const deleteParentNode = ()=> {
const deleteParentNode = () => {
deleteNode(nodeId);
}
};
const nodeType = nodes.find((node) => node.id === nodeId)?.type as keyof typeof NodeTooltips;
return (
<NodeToolbar>
<NodeToolbar className={"flex-row align-center"}>
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
<Tooltip nodeType={nodeType}>
<div className={styles.nodeToolbarTooltip}>i</div>
</Tooltip>
</NodeToolbar>);
}
type TooltipProps = {
nodeType?: keyof typeof NodeTooltips;
children: JSX.Element;
};
/**
* A general tooltip component, that can be used as a wrapper for any component
* that has a nodeType and a corresponding nodeTooltip.
*
* currently used to show tooltips for draggable-nodes and nodes inside the editor
*
* @param {"start" | "end" | "phase" | "norm" | "goal" | "trigger" | "basic_belief" | undefined} nodeType
* @param {React.JSX.Element} children
* @returns {React.JSX.Element}
* @constructor
*/
export function Tooltip({ nodeType, children }: TooltipProps) {
const [showTooltip, setShowTooltip] = useState(false);
const [disabled , setDisabled] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const updateTooltipPos = () => {
const rect = document.getElementById("draggable-sidebar")!.getBoundingClientRect();
setCoords({
// Position exactly below the bottom edge of the draggable sidebar (plus a small gap)
top: rect.bottom + 10,
left: rect.left + rect.width / 2, // Keep it horizontally centered
});
};
return nodeType ?
(<div>
<div
onMouseDown={() => {
updateTooltipPos();
setShowTooltip(false);
setDisabled(true);
}}
onMouseUp={() => {
setDisabled(false);
}}
onMouseOver={() => {
if (!disabled) {
updateTooltipPos();
setShowTooltip(true);
}
}}
onMouseLeave={ () => setShowTooltip(false)}
>
{children}
</div>
{showTooltip && createPortal(
<div
className={"flex-row"}
style={{
pointerEvents: 'none',
position: 'fixed',
top: `${coords.top}px`,
left: `${coords.left}px`,
transform: 'translateX(-50%)', // Center based on the midpoint
}}
>
<span className={styles.customTooltipHeader}>{nodeType}</span>
<span className={styles.customTooltip}>
{NodeTooltips[nodeType] || "Available for drag"}
</span>
</div>,
document.body
)}
</div>
) : children
}

View File

@@ -31,4 +31,13 @@
filter: drop-shadow(0 0 0.25rem green);
}
:global(.react-flow__handle) {
width: calc(8px / var(--flow-zoom, 1));
height: calc(8px / var(--flow-zoom, 1));
transition: width 0.1s ease, height 0.1s ease;
min-width: 8px;
min-height: 8px;
}

View File

@@ -39,6 +39,10 @@ type Emotion = { type: "emotion", id: string, value: string, label: "Emotion rec
export type BasicBeliefNode = Node<BasicBeliefNodeData>
// update the tooltip to reflect newly added connection options for a belief
export const BasicBeliefTooltip = `
A belief describes a condition that must be met
in order for a connected norm to be activated`;
/**
* This function is called whenever a connection is made with this node type as the target

View File

@@ -55,6 +55,10 @@ export function EndReduce(node: Node, _nodes: Node[]) {
}
}
export const EndTooltip = `
The end node signifies the endpoint of your program;
the output of the final phase of your program should connect to the end 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

View File

@@ -136,6 +136,11 @@ export function GoalReduce(node: Node, _nodes: Node[]) {
}
}
export const GoalTooltip = `
The goal node allows you to set goals that Pepper has to achieve
before moving to the next phase of your program`;
/**
* 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

View File

@@ -114,6 +114,10 @@ export function NormReduce(node: Node, nodes: Node[]) {
return result
}
export const NormTooltip = `
A norm describes a behavioral rule Pepper must follow during the connected phase(-s),
for example: "respond using formal language"`;
/**
* 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

View File

@@ -115,6 +115,10 @@ export function PhaseReduce(node: Node, nodes: Node[]) {
return result;
}
export const PhaseTooltip = `
A phase is a single stage of the program, during a phase Pepper will behave
in accordance with any connected norms, goals and triggers`;
/**
* This function is called whenever a connection is made with this node type as the target (phase)
* @param _thisNode the node of this node type which function is called

View File

@@ -53,6 +53,10 @@ export function StartReduce(node: Node, _nodes: Node[]) {
}
}
export const StartTooltip = `
The start node acts as the starting point for a program,
it should be connected to the left handle of the first phase of your program`;
/**
* 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

View File

@@ -101,6 +101,11 @@ export function TriggerReduce(node: Node, nodes: Node[]) {
}
export const TriggerTooltip = `
A trigger node is used to make Pepper execute a predefined plan -
consisting of one or more actions - when the connected beliefs are met`;
/**
* 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

View File

@@ -0,0 +1,40 @@
import { fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import {Tooltip} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx";
import {renderWithSidebar} from "../../../../test-utils/test-utils.tsx";
describe('Tooltip component test', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders and shows tooltip content on hover', () => {
renderWithSidebar(
<Tooltip nodeType="phase">
<div>?</div>
</Tooltip>
);
const trigger = screen.getByText('?');
// initially hidden
expect(
screen.queryByText('Phase tooltip text')
).not.toBeInTheDocument();
// hover shows tooltip
fireEvent.mouseOver(trigger);
expect(screen.getByText('phase')).toBeInTheDocument();
expect(
screen.getByText('A phase is a single stage of the program, during a phase Pepper will behave in accordance with any connected norms, goals and triggers')
).toBeInTheDocument();
// rendered via portal
expect(
document.body.contains(
screen.getByText('A phase is a single stage of the program, during a phase Pepper will behave in accordance with any connected norms, goals and triggers')
)
).toBe(true);
});
});

View File

@@ -19,6 +19,48 @@ export function renderWithProviders(
}
type SidebarRect = Partial<DOMRect>;
const defaultRect: DOMRect = {
top: 0,
left: 0,
bottom: 100,
right: 200,
width: 200,
height: 100,
x: 0,
y: 0,
toJSON: () => {},
};
/**
* Renders a component and injects a mock `#draggable-sidebar`
* element required by Tooltip positioning logic.
*/
export function renderWithSidebar(
ui: ReactElement,
rect: SidebarRect = {},
options?: RenderOptions
) {
const sidebar = document.createElement('div');
sidebar.id = 'draggable-sidebar';
sidebar.getBoundingClientRect = jest.fn(() => ({
...defaultRect,
...rect,
}));
document.body.appendChild(sidebar);
const result = render(ui, options);
return {
...result,
sidebar,
};
}
// Re-export everything from testing library
//eslint-disable-next-line react-refresh/only-export-components
export * from '@testing-library/react';