From d4d1aecb8c6c286b48d83eb72e766eddb026649e Mon Sep 17 00:00:00 2001 From: "Gerla, J. (Justin)" Date: Tue, 11 Nov 2025 13:50:45 +0000 Subject: [PATCH] feat: added basic functionality for editable name bar --- src/pages/VisProgPage/VisProg.module.css | 41 +++--- .../visualProgrammingUI/VisProgStores.tsx | 134 +++++++++++------- .../visualProgrammingUI/VisProgTypes.tsx | 1 + .../components/NodeDefinitions.tsx | 71 ++++++++-- .../visualProgrammingUI/GraphReducer.test.ts | 2 +- .../VisProgStores.test.tsx | 128 +++++++++++++++++ 6 files changed, 298 insertions(+), 79 deletions(-) diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index f2f90c7..c58d0f3 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -19,7 +19,28 @@ +.node-text-input { + border: 1px solid transparent; + border-radius: 5pt; + padding: 4px 8px; + outline: none; + background-color: white; + transition: border-color 0.2s, box-shadow 0.2s; + cursor: text; +} +.node-text-input:focus { + border-color: gainsboro; +} + +.node-text-input:read-only { + cursor: pointer; + background-color: whitesmoke; +} + +.node-text-input:read-only:hover { + border-color: gainsboro; +} .dnd-panel { margin-inline-start: auto; @@ -55,34 +76,22 @@ filter: drop-shadow(0 0 0.75rem black); } -.default-node-norm { - padding: 10px 15px; - background-color: canvas; - border-radius: 5pt; +.node-norm { outline: forestgreen solid 2pt; filter: drop-shadow(0 0 0.25rem forestgreen); } -.default-node-phase { - padding: 10px 15px; - background-color: canvas; - border-radius: 5pt; +.node-phase { outline: dodgerblue solid 2pt; filter: drop-shadow(0 0 0.25rem dodgerblue); } -.default-node-start { - padding: 10px 15px; - background-color: canvas; - border-radius: 5pt; +.node-start { outline: orange solid 2pt; filter: drop-shadow(0 0 0.25rem orange); } -.default-node-end { - padding: 10px 15px; - background-color: canvas; - border-radius: 5pt; +.node-end { outline: red solid 2pt; filter: drop-shadow(0 0 0.25rem red); } diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index e27fb28..300c14b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -6,7 +6,7 @@ import { reconnectEdge, type Edge, type Connection } from '@xyflow/react'; -import {type FlowState} from './VisProgTypes.tsx'; +import {type AppNode, type FlowState} from './VisProgTypes.tsx'; /** * contains the nodes that are created when the editor is loaded, @@ -55,61 +55,87 @@ const initialEdges = [ * and use any implemented functionality */ const useFlowStore = create((set, get) => ({ - nodes: initialNodes, - edges: initialEdges, - edgeReconnectSuccessful: true, - onNodesChange: (changes) => { + nodes: initialNodes, + edges: initialEdges, + edgeReconnectSuccessful: true, + onNodesChange: (changes) => { + set({ + nodes: applyNodeChanges(changes, get().nodes) + }); + }, + onEdgesChange: (changes) => { + set({ + edges: applyEdgeChanges(changes, get().edges) + }); + }, + // handles connection of newly created edges + onConnect: (connection) => { + set({ + edges: addEdge(connection, get().edges) + }); + }, + // handles attempted reconnections of a previously disconnected edge + onReconnect: (oldEdge: Edge, newConnection: Connection) => { + get().edgeReconnectSuccessful = true; + set({ + edges: reconnectEdge(oldEdge, newConnection, get().edges) + }); + }, + // Handles initiation of reconnection of edges that are manually disconnected from a node + onReconnectStart: () => { + set({ + edgeReconnectSuccessful: false + }); + }, + // Drops the edge from the set of edges, removing it from the flow, if no successful reconnection occurred + onReconnectEnd: (_: unknown, edge: { id: string; }) => { + if (!get().edgeReconnectSuccessful) { set({ - nodes: applyNodeChanges(changes, get().nodes) + edges: get().edges.filter((e) => e.id !== edge.id), }); - }, - onEdgesChange: (changes) => { - set({ - edges: applyEdgeChanges(changes, get().edges) - }); - }, - // handles connection of newly created edges - onConnect: (connection) => { - set({ - edges: addEdge(connection, get().edges) - }); - }, - // handles attempted reconnections of a previously disconnected edge - onReconnect: (oldEdge: Edge, newConnection: Connection) => { - get().edgeReconnectSuccessful = true; - set({ - edges: reconnectEdge(oldEdge, newConnection, get().edges) - }); - }, - // Handles initiation of reconnection of edges that are manually disconnected from a node - onReconnectStart: () => { - set({ - edgeReconnectSuccessful: false - }); - }, - // Drops the edge from the set of edges, removing it from the flow, if no successful reconnection occurred - onReconnectEnd: (_: unknown, edge: { id: string; }) => { - if (!get().edgeReconnectSuccessful) { - set({ - edges: get().edges.filter((e) => e.id !== edge.id), - }); - } - set({ - edgeReconnectSuccessful: true - }); - }, - deleteNode: (nodeId: string) => { - set({ - nodes: get().nodes.filter((n) => n.id !== nodeId), - edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId) - }); - }, - setNodes: (nodes) => { - set({nodes}); - }, - setEdges: (edges) => { - set({edges}); - }, + } + set({ + edgeReconnectSuccessful: true + }); + }, + deleteNode: (nodeId: string) => { + set({ + nodes: get().nodes.filter((n) => n.id !== nodeId), + edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId) + }); + }, + setNodes: (nodes) => { + set({nodes}); + }, + setEdges: (edges) => { + set({edges}); + }, +/** + * handles updating the data component of a node, + * if the provided data object contains entries that aren't present in the updated node's data component + * those entries are added to the data component, + * entries that do exist within the node's data component, + * are simply updated to contain the new value + * + * the data object + * @param {string} nodeId + * @param {object} data + */ + updateNodeData: (nodeId: string, data) => { + set({ + nodes: get().nodes.map((node) : AppNode => { + if (node.id === nodeId) { + return { + ...node, + data: { + ...node.data, + ...data + } + }; + } else { return node; } + }) + }); + } }), ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index 378f9be..bb7c28c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -43,4 +43,5 @@ export type FlowState = { deleteNode: (nodeId: string) => void; setNodes: (nodes: AppNode[]) => void; setEdges: (edges: Edge[]) => void; + updateNodeData: (nodeId: string, data: object) => void; }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx index f74dd2b..19f56dd 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx @@ -1,4 +1,9 @@ -import {Handle, type NodeProps, NodeToolbar, Position} from '@xyflow/react'; +import { + Handle, + type NodeProps, + NodeToolbar, + Position +} from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import styles from '../../VisProg.module.css'; import useFlowStore from "../VisProgStores.tsx"; @@ -9,7 +14,7 @@ import type { NormNode } from "../VisProgTypes.tsx"; -// +//Toolbar definitions type ToolbarProps = { nodeId: string; @@ -39,6 +44,56 @@ export function Toolbar({nodeId, allowDelete}: ToolbarProps) { ); } +// Renaming component + +/** + * Adds a component that can be used to edit a node's label entry inside its Data + * can be added to any custom node that has a label inside its Data + * + * @param {string} nodeLabel + * @param {string} nodeId + * @returns {React.JSX.Element} + * @constructor + */ +export function EditableName({nodeLabel = "new node", nodeId} : { nodeLabel : string, nodeId: string}) { + const {updateNodeData} = useFlowStore(); + + const updateData = (event: React.FocusEvent) => { + const input = event.target.value; + updateNodeData(nodeId, {label: input}); + event.currentTarget.setAttribute("readOnly", "true"); + window.getSelection()?.empty(); + event.currentTarget.classList.replace("nodrag", "drag"); // enable dragging of the node with cursor on the input box + }; + + const updateOnEnter = (event: React.KeyboardEvent) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); }; + + const enableEditing = (event: React.MouseEvent) => { + if(event.currentTarget.hasAttribute("readOnly")) { + event.currentTarget.removeAttribute("readOnly"); // enable editing + event.currentTarget.select(); // select the text input + window.getSelection()?.collapseToEnd(); // move the caret to the end of the current value + event.currentTarget.classList.replace("drag", "nodrag"); // disable dragging using input box + } + } + + return ( +
+ + +
+ ) +} + // Definitions of Nodes @@ -54,7 +109,7 @@ export const StartNodeComponent = ({id, data}: NodeProps) => { return ( <> -
+
data test {data.label}
@@ -75,7 +130,7 @@ export const EndNodeComponent = ({id, data}: NodeProps) => { return ( <> -
+
{data.label}
@@ -96,8 +151,8 @@ export const PhaseNodeComponent = ({id, data}: NodeProps) => { return ( <> -
-
phase {data.number} {data.label}
+
+ @@ -119,8 +174,8 @@ export const NormNodeComponent = ({id, data}: NodeProps) => { return ( <> -
-
Norm {data.label}
+
+
diff --git a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts index a907a58..4473b82 100644 --- a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts +++ b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts @@ -955,7 +955,7 @@ describe('Graph Reducer Tests', () => { state: onlyStartEnd, expected: [], } - ])("`tests state: $state.name`", ({state, expected}) => { + ])(`tests state: $state.name`, ({state, expected}) => { useFlowStore.setState({nodes: state.nodes, edges: state.edges}); const output = graphReducer(); // uses default reducers expect(output).toEqual(expected); diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx index 9b3ab80..63fec3d 100644 --- a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -221,4 +221,132 @@ describe('FlowStore Functionality', () => { }); }); }); + describe('ReactFlow updateNodeData', () => { + test.each([ + { + state: { + name: 'updateName', + nodes: [{ + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'name', number: '2'} + }] + }, + input: { + id: 'phase-1', + changedData: {label: 'new name'} + }, + expected: { + node: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'new name', number: '2'} + } + } + }, + { + state: { + name: 'updateNumber', + nodes: [{ + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'name', number: '2'} + }] + }, + input: { + id: 'phase-1', + changedData: {number: '3'} + }, + expected: { + node: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'name', number: '3'} + } + } + }, + { + state: { + name: 'updateNameAndNumber', + nodes: [{ + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'name', number: '2'} + }] + }, + input: { + id: 'phase-1', + changedData: {label: 'new name', number: '3'} + }, + expected: { + node: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'new name', number: '3'} + } + } + }, + { + state: { + name: 'AddNewEntry', + nodes: [{ + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'name', number: '2'} + }] + }, + input: { + id: 'phase-1', + changedData: {newEntry: 20} + }, + expected: { + node: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'name', number: '2', newEntry: 20} + } + } + }, + { + state: { + name: 'AddNewEntryAndUpdateOneValue_UnorderedInput', + nodes: [{ + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'name', number: '2'} + }] + }, + input: { + id: 'phase-1', + changedData: {newEntry: 20, number: '3'} + }, + expected: { + node: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 300}, + data: {label: 'name', number: '3', newEntry: 20} + } + } + } + ])(`tests state: $state.name`, ({state, input,expected}) => { + useFlowStore.setState({ nodes: state.nodes }) + const {updateNodeData} = useFlowStore.getState(); + act(() => { + updateNodeData(input.id, input.changedData); + }) + const updatedState = useFlowStore.getState(); + expect(updatedState.nodes).toHaveLength(1); + expect(updatedState.nodes[0]).toMatchObject(expected.node); + }) + }) });