diff --git a/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx b/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx index bd87e928f01..ebc0f5afe51 100644 --- a/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx +++ b/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx @@ -4,15 +4,28 @@ import { NODE_DIMENSIONS } from "../../../../../constants/graph"; import type { NodeData } from "../../../../../types/graph"; import { TextRenderer } from "./TextRenderer"; import * as Styled from "./styles"; +import useGraph from "../stores/useGraph"; +import { Button, Group } from "@mantine/core"; type RowProps = { row: NodeData["text"][number]; x: number; y: number; index: number; + nodeId: string; + isEditing: boolean; + editingValue: string | null; + onEdit: () => void; + onCancel: () => void; + onSave: () => void; + onChange: (val: string) => void; }; -const Row = ({ row, x, y, index }: RowProps) => { +const Row = ({ + row, x, y, index, nodeId, + isEditing, editingValue, + onEdit, onCancel, onSave, onChange, +}: RowProps) => { const rowPosition = index * NODE_DIMENSIONS.ROW_HEIGHT; const getRowText = () => { @@ -29,25 +42,70 @@ const Row = ({ row, x, y, index }: RowProps) => { data-y={y + rowPosition} > {row.key}: - {getRowText()} + {isEditing ? ( + + onChange(e.target.value)} + style={{ minWidth: 80 }} + /> + + + + ) : ( + <> + {getRowText()} + {(row.type !== "object" && row.type !== "array") && ( + + )} + + )} ); }; -const Node = ({ node, x, y }: CustomNodeProps) => ( - - {node.text.map((row, index) => ( - - ))} - -); +const Node = ({ node, x, y }: CustomNodeProps) => { + const { + editingNodeId, + editingRowIndex, + editingValue, + startEditingNode, + cancelEditingNode, + setEditingValue, + saveEditingNodeValue, + } = useGraph(); + + return ( + + {node.text.map((row, index) => { + const isEditing = editingNodeId === node.id && editingRowIndex === index; + return ( + startEditingNode(node.id, index, String(row.value ?? ""))} + onCancel={cancelEditingNode} + onSave={saveEditingNodeValue} + onChange={setEditingValue} + /> + ); + })} + + ); +}; function propsAreEqual(prev: CustomNodeProps, next: CustomNodeProps) { return ( diff --git a/src/features/editor/views/GraphView/stores/useGraph.ts b/src/features/editor/views/GraphView/stores/useGraph.ts index 6e067c3c2a7..1e85eacacec 100644 --- a/src/features/editor/views/GraphView/stores/useGraph.ts +++ b/src/features/editor/views/GraphView/stores/useGraph.ts @@ -16,6 +16,9 @@ export interface Graph { selectedNode: NodeData | null; path: string; aboveSupportedLimit: boolean; + editingNodeId: string | null; + editingRowIndex: number | null; + editingValue: string | null; } const initialStates: Graph = { @@ -28,6 +31,9 @@ const initialStates: Graph = { selectedNode: null, path: "", aboveSupportedLimit: false, + editingNodeId: null, + editingRowIndex: null, + editingValue: null, }; interface GraphActions { @@ -43,7 +49,55 @@ interface GraphActions { centerView: () => void; clearGraph: () => void; setZoomFactor: (zoomFactor: number) => void; + startEditingNode: (nodeId: string, rowIndex: number, value: string) => void; + cancelEditingNode: () => void; + setEditingValue: (value: string) => void; + saveEditingNodeValue: () => void; } +type JsonPath = (string | number)[]; + +function safeParseJson(text: string): any | null { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function setValueAtPath(target: any, path: JsonPath, newValue: any): any { + if (path.length === 0) return newValue; + + const [head, ...rest] = path; + const key = typeof head === "number" ? head : String(head); + + // Arrays + if (Array.isArray(target)) { + const index = typeof head === "number" ? head : Number(head); + const clone = [...target]; + const currentChild = clone[index]; + if (rest.length === 0) { + clone[index] = newValue; + } else { + clone[index] = setValueAtPath(currentChild, rest, newValue); + } + return clone; + } + + // Objects + const base: any = + target !== null && typeof target === "object" + ? { ...target } + : {}; + + if (rest.length === 0) { + base[key] = newValue; + } else { + base[key] = setValueAtPath(base[key], rest, newValue); + } + + return base; +} + const useGraph = create((set, get) => ({ ...initialStates, @@ -101,6 +155,104 @@ const useGraph = create((set, get) => ({ }, toggleFullscreen: fullscreen => set({ fullscreen }), setViewPort: viewPort => set({ viewPort }), + editingNodeId: null, + editingRowIndex: null, + editingValue: null, + + startEditingNode: (nodeId, rowIndex, value) => set({ + editingNodeId: nodeId, + editingRowIndex: rowIndex, + editingValue: value, + }), + cancelEditingNode: () => set({ + editingNodeId: null, + editingRowIndex: null, + editingValue: null, + }), + setEditingValue: value => set({ editingValue: value }), + saveEditingNodeValue: () => { + const { editingNodeId, editingRowIndex, editingValue, nodes, selectedNode } = get(); + if (!editingNodeId || editingValue == null) return; + + const jsonStr = useJson.getState().json; + const root = safeParseJson(jsonStr); + if (root == null) { + set({ editingNodeId: null, editingRowIndex: null, editingValue: null }); + return; + } + + let updatedRoot = root; + + // 1) Modal editing (editing the whole node JSON) + if (editingRowIndex == null && selectedNode?.path) { + // Here editingValue is the JSON text shown in the modal for that node. + const parsedNodeValue = safeParseJson(editingValue); + if (parsedNodeValue == null) { + // invalid JSON, abort and reset editing state + set({ editingNodeId: null, editingRowIndex: null, editingValue: null }); + return; + } + + updatedRoot = setValueAtPath( + root, + selectedNode.path as JsonPath, + parsedNodeValue + ); + } + + // 2) Row editing (single property in node details) + if (editingRowIndex != null) { + const node = nodes.find(n => n.id === editingNodeId); + if (!node || !node.path) { + set({ editingNodeId: null, editingRowIndex: null, editingValue: null }); + return; + } + + const row = node.text[editingRowIndex]; + if (!row) { + set({ editingNodeId: null, editingRowIndex: null, editingValue: null }); + return; + } + + // Try to preserve numbers/booleans/null by parsing simple literals + let value: any = editingValue; + const trimmed = editingValue.trim(); + const looksLikeLiteral = + trimmed === "true" || + trimmed === "false" || + trimmed === "null" || + /^-?\d+(\.\d+)?$/.test(trimmed); + + if (looksLikeLiteral) { + try { + value = JSON.parse(trimmed); + } catch { + value = editingValue; + } + } + + const fullPath: JsonPath = [...(node.path as JsonPath), row.key]; + updatedRoot = setValueAtPath(root, fullPath, value); + } + + const updatedJsonStr = JSON.stringify(updatedRoot, null, 2); + + // This will also update the left editor (via your setJson/useFile wiring) + useJson.getState().setJson(updatedJsonStr); + + // Optionally re-parse to refresh nodes/edges + selectedNode + const { nodes: updatedNodes, edges: updatedEdges } = parser(updatedJsonStr); + set({ + nodes: updatedNodes, + edges: updatedEdges, + selectedNode: updatedNodes.find(n => n.id === editingNodeId) ?? null, + editingNodeId: null, + editingRowIndex: null, + editingValue: null, + }); +}, })); + export default useGraph; + diff --git a/src/features/modals/NodeModal/index.tsx b/src/features/modals/NodeModal/index.tsx index caba85febac..37a8ee90041 100644 --- a/src/features/modals/NodeModal/index.tsx +++ b/src/features/modals/NodeModal/index.tsx @@ -1,6 +1,6 @@ import React from "react"; import type { ModalProps } from "@mantine/core"; -import { Modal, Stack, Text, ScrollArea, Flex, CloseButton } from "@mantine/core"; +import { Modal, Stack, Text, ScrollArea, Flex, CloseButton, Button, Group } from "@mantine/core"; import { CodeHighlight } from "@mantine/code-highlight"; import type { NodeData } from "../../../types/graph"; import useGraph from "../../editor/views/GraphView/stores/useGraph"; @@ -28,6 +28,32 @@ const jsonPathToString = (path?: NodeData["path"]) => { export const NodeModal = ({ opened, onClose }: ModalProps) => { const nodeData = useGraph(state => state.selectedNode); + const { + editingNodeId, + editingValue, + startEditingNode, + cancelEditingNode, + setEditingValue, + saveEditingNodeValue, + } = useGraph(); + + // Use node id for modal editing + const isEditing = editingNodeId === nodeData?.id; + + // Get normalized JSON string for Content + const normalizedContent = normalizeNodeData(nodeData?.text ?? []); + + // Start editing with normalized JSON + const handleEdit = () => { + startEditingNode(nodeData.id, null, normalizedContent); + }; + + // Save and immediately exit edit mode, so modal updates with new JSON + const handleSave = () => { + saveEditingNodeValue(); + // Wait for zustand update, then exit edit mode (triggers re-render with new selectedNode) + setTimeout(() => cancelEditingNode(), 0); + }; return ( @@ -40,13 +66,41 @@ export const NodeModal = ({ opened, onClose }: ModalProps) => { - + {isEditing ? ( + <> +