diff --git a/src/features/editor/views/GraphView/CustomNode/index.tsx b/src/features/editor/views/GraphView/CustomNode/index.tsx index ea3ac6be981..ee26715f1bc 100644 --- a/src/features/editor/views/GraphView/CustomNode/index.tsx +++ b/src/features/editor/views/GraphView/CustomNode/index.tsx @@ -19,15 +19,18 @@ const CustomNodeWrapper = (nodeProps: NodeProps) => { const setSelectedNode = useGraph(state => state.setSelectedNode); const setVisible = useModal(state => state.setVisible); const colorScheme = useComputedColorScheme(); - const handleNodeClick = React.useCallback( (_: React.MouseEvent, data: NodeData) => { + console.log("NODE_CLICKED RAW DATA:", data); + console.log("NODE_PROPS:", nodeProps); + console.log("NODE_PROPS.properties:", nodeProps.properties); + if (setSelectedNode) setSelectedNode(data); setVisible("NodeModal", true); }, - [setSelectedNode, setVisible] + [setSelectedNode, setVisible, nodeProps] ); - + return ( void; + + // The value stored at that node (usually an object) + value: any | null; + + // JSON path string like ${["fruits"][0]} + jsonPath: string | null; + + // Called when the user saves changes + onSave: (updatedValue: any) => void; +} + +const NodeContentModal: React.FC = ({ + opened, + onClose, + value, + jsonPath, + onSave, +}) => { + const [isEditing, setIsEditing] = React.useState(false); + const [fields, setFields] = React.useState>({}); + + // Whenever we open the modal or change node, reset local form state + React.useEffect(() => { + if (!opened) { + setIsEditing(false); + setFields({}); + return; + } + + if (value && typeof value === "object" && !Array.isArray(value)) { + const next: Record = {}; + Object.entries(value).forEach(([key, v]) => { + next[key] = v == null ? "" : String(v); + }); + setFields(next); + } else { + // Primitive value: treat as single field called "value" + setFields({ value: value == null ? "" : String(value) }); + } + }, [opened, value]); + + const handleFieldChange = (key: string, newVal: string) => { + setFields((prev) => ({ ...prev, [key]: newVal })); + }; + + const handleCancelEdit = () => { + // Discard edits and go back to JSON view + setIsEditing(false); + + if (value && typeof value === "object" && !Array.isArray(value)) { + const reset: Record = {}; + Object.entries(value).forEach(([key, v]) => { + reset[key] = v == null ? "" : String(v); + }); + setFields(reset); + } else { + setFields({ value: value == null ? "" : String(value) }); + } + }; + + const handleSave = () => { + let updated: any; + + if (value && typeof value === "object" && !Array.isArray(value)) { + updated = { ...(value as any) }; + Object.entries(fields).forEach(([key, text]) => { + const trimmed = text.trim(); + let parsed: any; + + if (trimmed === "") { + parsed = ""; + } else { + // Try to parse as JSON literal, fall back to string + try { + parsed = JSON.parse(trimmed); + } catch { + parsed = text; + } + } + + updated[key] = parsed; + }); + } else { + const onlyKey = Object.keys(fields)[0]; + const text = fields[onlyKey]; + const trimmed = text.trim(); + try { + updated = JSON.parse(trimmed); + } catch { + updated = text; + } + } + + onSave(updated); + setIsEditing(false); + }; + + const prettyJson = + value !== undefined ? JSON.stringify(value, null, 2) : ""; + + return ( + { + setIsEditing(false); + onClose(); + }} + title="Content" + centered + size="lg" + > + {/* VIEW MODE: pretty JSON + Edit button */} + {!isEditing && ( + + Content + +
+            {prettyJson}
+          
+ + + JSON Path + + {jsonPath ?? "-"} + + + + +
+ )} + + {/* EDIT MODE: individual fields + Save / Cancel */} + {isEditing && ( + + {Object.entries(fields).map(([key, val]) => ( +
+ + {key} + + + handleFieldChange(key, e.currentTarget.value) + } + /> +
+ ))} + + + JSON Path + + {jsonPath ?? "-"} + + + + + +
+ )} +
+ ); +}; + +export default NodeContentModal; diff --git a/src/features/modals/NodeModal/index.tsx b/src/features/modals/NodeModal/index.tsx index caba85febac..fa3bb158be5 100644 --- a/src/features/modals/NodeModal/index.tsx +++ b/src/features/modals/NodeModal/index.tsx @@ -1,69 +1,136 @@ +// src/features/modals/NodeModal/index.tsx + import React from "react"; import type { ModalProps } from "@mantine/core"; -import { Modal, Stack, Text, ScrollArea, Flex, CloseButton } from "@mantine/core"; -import { CodeHighlight } from "@mantine/code-highlight"; + import type { NodeData } from "../../../types/graph"; import useGraph from "../../editor/views/GraphView/stores/useGraph"; +import useJson from "../../../store/useJson"; +import NodeContentModal from "../NodeContentModal"; +import { updateJsonAtPath } from "../../../lib/utils/updateByJsonPath"; + +/** + * Build the value we want to expose for editing. + * - If the node is a single scalar (no key) → return that scalar + * - Otherwise return an object of only primitive fields (no array/object children) + * e.g. { name: "Apple", color: "#FF0000" } + */ +function buildEditableValue(nodeData: NodeData | null): any | null { + const rows = nodeData?.text; + if (!rows || rows.length === 0) return null; -// return object from json removing array and object fields -const normalizeNodeData = (nodeRows: NodeData["text"]) => { - if (!nodeRows || nodeRows.length === 0) return "{}"; - if (nodeRows.length === 1 && !nodeRows[0].key) return `${nodeRows[0].value}`; + // Single primitive value node (no key) + if (rows.length === 1 && !rows[0].key) { + return rows[0].value; + } - const obj = {}; - nodeRows?.forEach(row => { - if (row.type !== "array" && row.type !== "object") { - if (row.key) obj[row.key] = row.value; + // Object-like node: only keep primitive fields, skip array/object children + const obj: Record = {}; + rows.forEach(row => { + if (row.key && row.type !== "array" && row.type !== "object") { + obj[row.key] = row.value; } }); - return JSON.stringify(obj, null, 2); -}; -// return json path in the format $["customer"] -const jsonPathToString = (path?: NodeData["path"]) => { + return obj; +} + +/** Convert NodeData.path into a JSONPath-like string: $["fruits"][0]["name"] */ +function jsonPathToString(path?: NodeData["path"]): string { if (!path || path.length === 0) return "$"; const segments = path.map(seg => (typeof seg === "number" ? seg : `"${seg}"`)); return `$[${segments.join("][")}]`; -}; +} -export const NodeModal = ({ opened, onClose }: ModalProps) => { +/** Get the full value at a NodeData.path from a parsed JSON root */ +function getValueAtPath(root: any, path?: (string | number)[]): any { + if (!path || path.length === 0) return root; + let current = root; + for (const key of path) { + if (current == null || typeof current !== "object") return undefined; + current = (current as any)[key as any]; + } + return current; +} + +export const NodeModal: React.FC = ({ opened = false, onClose }) => { const nodeData = useGraph(state => state.selectedNode); + const { getJson, setJson } = useJson(); + + const editableValue = React.useMemo(() => buildEditableValue(nodeData), [nodeData]); + const jsonPath = React.useMemo( + () => (nodeData ? jsonPathToString(nodeData.path) : null), + [nodeData] + ); + + const handleSave = (updatedValue: any) => { + if (!nodeData || !jsonPath) return; + + const currentJson = getJson(); + let root: any; + + try { + root = currentJson ? JSON.parse(currentJson) : {}; + } catch { + // If the current JSON is somehow invalid, bail out gracefully + return; + } + + // Full object currently at this path in the JSON (may be primitive) + const currentAtPath = getValueAtPath(root, nodeData.path); + + let mergedValue: any; + + // If the node is an object, merge primitive updates into it so we don't + // lose nested fields like "details" or "nutrients" + if ( + currentAtPath && + typeof currentAtPath === "object" && + !Array.isArray(currentAtPath) && + updatedValue && + typeof updatedValue === "object" && + !Array.isArray(updatedValue) + ) { + mergedValue = { + ...currentAtPath, + ...updatedValue, + }; + } else { + // Scalar node or something non-object → just replace with the new value + mergedValue = updatedValue; + } + + const newRoot = updateJsonAtPath(root, jsonPath, mergedValue); + const newJson = JSON.stringify(newRoot, null, 2); + + // This will also re-parse and refresh the graph view + setJson(newJson); + + if (onClose) onClose(); + }; + + // If no node is selected, just render nothing (modal will effectively be closed) + if (!nodeData) { + return ( + {})} + value={null} + jsonPath={null} + onSave={() => {}} + /> + ); + } return ( - - - - - - Content - - - - - - - - - JSON Path - - - - - - + {})} + value={editableValue} + jsonPath={jsonPath} + onSave={handleSave} + /> ); }; + +export default NodeModal; diff --git a/src/lib/utils/updateByJsonPath.ts b/src/lib/utils/updateByJsonPath.ts new file mode 100644 index 00000000000..1c3c58b3361 --- /dev/null +++ b/src/lib/utils/updateByJsonPath.ts @@ -0,0 +1,85 @@ + +export function updateJsonAtPath( + root: unknown, + jsonPath: string, + newValue: unknown + ): unknown { + if (!jsonPath) return root; + + const segments = parseJsonPath(jsonPath); + if (!segments.length) return root; + + // Cheap deep clone so we don't mutate Zustand store data in place + const clone = + typeof structuredClone === "function" + ? structuredClone(root) + : JSON.parse(JSON.stringify(root)); + + let current: any = clone; + + for (let i = 0; i < segments.length - 1; i++) { + const key = segments[i]; + + if (current == null || typeof current !== "object") { + // Path is invalid, bail out but keep whatever we cloned + return clone; + } + + if (!(key in current)) { + // Create missing branch so we can still set the leaf + const nextKey = segments[i + 1]; + current[key] = typeof nextKey === "number" ? [] : {}; + } + + current = current[key]; + } + + const lastKey = segments[segments.length - 1]; + + if (current != null && typeof current === "object") { + (current as any)[lastKey as any] = newValue; + } + + return clone; + } + + /** + * Very small parser for JSONPath strings of the form: + * $["fruits"][0]["name"] + * ${["fruits"][0]["name"]} + * $.fruits[0].name + */ + function parseJsonPath(jsonPath: string): Array { + const result: Array = []; + + let s = jsonPath.trim(); + + // Strip leading ${ and trailing } if present + if (s.startsWith("${") && s.endsWith("}")) { + s = s.slice(2, -1); + } + + // Strip leading $ + if (s.startsWith("$")) { + s = s.slice(1); + } + + const re = /\["([^"]+)"\]|\[(\d+)\]|\.([A-Za-z0-9_$]+)/g; + let match: RegExpExecArray | null; + + while ((match = re.exec(s))) { + if (match[1] != null) { + // ["key"] + result.push(match[1]); + } else if (match[2] != null) { + // [0] + result.push(Number(match[2])); + } else if (match[3] != null) { + // .key + result.push(match[3]); + } + } + + return result; + } + \ No newline at end of file