Merge branch 'feat/add-naming-component-to-editor-nodes' into 'dev'
feat: added basic functionality for editable name bar See merge request ics/sp/2025/n25b/pepperplus-ui!17
This commit was merged in pull request #17.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<FlowState>((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; }
|
||||
})
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) {
|
||||
</NodeToolbar>);
|
||||
}
|
||||
|
||||
// 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<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); };
|
||||
|
||||
const enableEditing = (event: React.MouseEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className={styles.NodeTextBar }>
|
||||
<label>name: </label>
|
||||
<input
|
||||
className={`drag ${styles.nodeTextInput}`} // prevents dragging the component when user has focused the text input
|
||||
type={"text"}
|
||||
defaultValue={nodeLabel}
|
||||
onKeyDown={updateOnEnter}
|
||||
onBlur={updateData}
|
||||
onClick={enableEditing}
|
||||
maxLength={25}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Definitions of Nodes
|
||||
|
||||
@@ -54,7 +109,7 @@ export const StartNodeComponent = ({id, data}: NodeProps<StartNode>) => {
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={id} allowDelete={false}/>
|
||||
<div className={styles.defaultNodeStart}>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeStart}`}>
|
||||
<div> data test {data.label} </div>
|
||||
<Handle type="source" position={Position.Right} id="start"/>
|
||||
</div>
|
||||
@@ -75,7 +130,7 @@ export const EndNodeComponent = ({id, data}: NodeProps<EndNode>) => {
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={id} allowDelete={false}/>
|
||||
<div className={styles.defaultNodeEnd}>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeEnd}`}>
|
||||
<div> {data.label} </div>
|
||||
<Handle type="target" position={Position.Left} id="end"/>
|
||||
</div>
|
||||
@@ -96,8 +151,8 @@ export const PhaseNodeComponent = ({id, data}: NodeProps<PhaseNode>) => {
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={id} allowDelete={true}/>
|
||||
<div className={styles.defaultNodePhase}>
|
||||
<div> phase {data.number} {data.label} </div>
|
||||
<div className={`${styles.defaultNode} ${styles.nodePhase}`}>
|
||||
<EditableName nodeLabel={data.label} nodeId={id}/>
|
||||
<Handle type="target" position={Position.Left} id="target"/>
|
||||
<Handle type="target" position={Position.Bottom} id="norms"/>
|
||||
<Handle type="source" position={Position.Right} id="source"/>
|
||||
@@ -119,8 +174,8 @@ export const NormNodeComponent = ({id, data}: NodeProps<NormNode>) => {
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={id} allowDelete={true}/>
|
||||
<div className={styles.defaultNodeNorm}>
|
||||
<div> Norm {data.label} </div>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
|
||||
<EditableName nodeLabel={data.label} nodeId={id}/>
|
||||
<Handle type="source" position={Position.Right} id="NormSource"/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user