diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 11db5cc..00268eb 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -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); } @@ -152,4 +170,53 @@ .bottomRightHandle { left: 60% !important; +} + +.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); } } \ No newline at end of file diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 486cc7f..a083bbf 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -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 ( -
+
{ onNodeDragStart={beginBatchAction} onNodeDragStop={endBatchAction} preventScrolling={scrollable} + onMove={(_, viewport) => setZoom(viewport.zoom)} snapToGrid fitView proOptions={{hideAttribution: true}} diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 023440c..54f0241 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -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"; /** @@ -173,4 +186,17 @@ export const NodesInPhase = { end: () => false, 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, } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index a70e54d..defa934 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -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,]; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 01e222e..338039a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -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 ( -
- {children} -
- ); + +
+
+ {children} +
+
+
) } /** @@ -133,7 +137,7 @@ export function DndToolbar() { })); return ( -
+
You can drag these nodes to the pane to create new nodes.
diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx index 460a508..38f03a1 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx @@ -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 ( - + + +
i
+
); } + +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 ? + (
+
{ + updateTooltipPos(); + setShowTooltip(false); + setDisabled(true); + }} + onMouseUp={() => { + setDisabled(false); + }} + onMouseOver={() => { + if (!disabled) { + updateTooltipPos(); + setShowTooltip(true); + } + }} + onMouseLeave={ () => setShowTooltip(false)} + > + {children} +
+ {showTooltip && createPortal( +
+ {nodeType} + + {NodeTooltips[nodeType] || "Available for drag"} + +
, + document.body + )} +
+ ) : children +} diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css index 48d2351..e0aa5de 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css +++ b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css @@ -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; +} + + diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index 8496d0b..bed642f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -39,6 +39,10 @@ type Emotion = { type: "emotion", id: string, value: string, label: "Emotion rec export type BasicBeliefNode = Node +// 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 diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx index 10fb038..5c456b5 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -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 diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 449a422..fea9914 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -144,6 +144,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 diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 3f9a93c..6cde46a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -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 diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index d585ed6..50e81b6 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -118,6 +118,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 diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index a148587..741b190 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -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 diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 3ab57cc..8b3378a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -118,6 +118,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 diff --git a/test/pages/visProgPage/visualProgrammingUI/components/NodeComponents.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/NodeComponents.test.tsx new file mode 100644 index 0000000..f7f56ed --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/NodeComponents.test.tsx @@ -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( + +
?
+
+ ); + + 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); + }); +}); \ No newline at end of file diff --git a/test/test-utils/test-utils.tsx b/test/test-utils/test-utils.tsx index 2379d9c..a39e01a 100644 --- a/test/test-utils/test-utils.tsx +++ b/test/test-utils/test-utils.tsx @@ -19,6 +19,48 @@ export function renderWithProviders( } +type SidebarRect = Partial; + +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';