diff --git a/src/features/modals/NodeModal/index.tsx b/src/features/modals/NodeModal/index.tsx index caba85febac..b7bc736de8e 100644 --- a/src/features/modals/NodeModal/index.tsx +++ b/src/features/modals/NodeModal/index.tsx @@ -1,9 +1,13 @@ 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, TextInput, Group } from "@mantine/core"; import { CodeHighlight } from "@mantine/code-highlight"; -import type { NodeData } from "../../../types/graph"; +import type { NodeData, NodeRow } from "../../../types/graph"; import useGraph from "../../editor/views/GraphView/stores/useGraph"; +import { useEditNode } from "../../../store/useEditNode"; +import useJson from "../../../store/useJson"; +import useFile from "../../../store/useFile"; +import { updateJsonByPath } from "../../../lib/utils/jsonPathUpdater"; // return object from json removing array and object fields const normalizeNodeData = (nodeRows: NodeData["text"]) => { @@ -28,27 +32,135 @@ const jsonPathToString = (path?: NodeData["path"]) => { export const NodeModal = ({ opened, onClose }: ModalProps) => { const nodeData = useGraph(state => state.selectedNode); + const { isEditing, editedData, startEditing, updateEditedData, cancelEditing, resetEditState } = useEditNode(); + const setJson = useJson(state => state.setJson); + const getJson = useJson(state => state.getJson); + const setContents = useFile(state => state.setContents); + + const handleEdit = () => { + if (nodeData) { + startEditing(nodeData.id, nodeData.text); + } + }; + + const handleSave = () => { + if (!nodeData || !editedData) return; + + try { + // Parse the current JSON + const currentJson = JSON.parse(getJson()); + + // Update the JSON at the specific path + const updatedJson = updateJsonByPath(currentJson, nodeData.path, editedData); + + // Convert back to string with formatting + const updatedJsonString = JSON.stringify(updatedJson, null, 2); + + // Update both the JSON store (for graph) AND the file store (for text editor) + setJson(updatedJsonString); + setContents({ contents: updatedJsonString, hasChanges: true }); + + // Reset edit state and close modal + resetEditState(); + onClose(); + } catch (error) { + console.error("Error saving node data:", error); + alert("Failed to save changes. Please check the console for details."); + } + }; + + const handleCancel = () => { + cancelEditing(); + }; + + const handleValueChange = (index: number, newValue: string) => { + if (!editedData) return; + + const updated = [...editedData]; + updated[index] = { ...updated[index], value: newValue }; + updateEditedData(updated); + }; + + const handleClose = () => { + resetEditState(); + onClose(); + }; + + const displayData = isEditing ? editedData : nodeData?.text; return ( - + Content - + - - - + + {isEditing ? ( + + + {editedData?.map((row, index) => { + // Skip array and object types (they're references) + if (row.type === "array" || row.type === "object") { + return ( + + {row.key && {row.key}:} + + {row.type === "array" ? `[${row.childrenCount ?? 0} items]` : `{${row.childrenCount ?? 0} keys}`} + + + ); + } + + return ( + + {row.key && {row.key}:} + handleValueChange(index, e.currentTarget.value)} + style={{ flex: 1 }} + placeholder="Enter value" + /> + + ); + })} + + + ) : ( + + + + )} + + {/* Action Buttons */} + + {!isEditing ? ( + + ) : ( + <> + + + + )} + + JSON Path diff --git a/src/lib/utils/jsonPathUpdater.ts b/src/lib/utils/jsonPathUpdater.ts new file mode 100644 index 00000000000..391409fad9f --- /dev/null +++ b/src/lib/utils/jsonPathUpdater.ts @@ -0,0 +1,100 @@ +/** + * Helper functions for updating JSON by path + */ +import type { JSONPath } from "jsonc-parser"; +import type { NodeRow } from "../../types/graph"; + +/** + * Updates a value in a JSON object at a specific path + * @param json - The JSON object to update + * @param path - The path to the value to update (e.g., ["customer", "address", 0, "street"]) + * @param newData - The new data to set at the path + * @returns The updated JSON object + */ +export function updateJsonByPath( + json: any, + path: JSONPath | undefined, + newData: NodeRow[] +): any { + if (!path || path.length === 0) { + // Root level update + return createObjectFromNodeRows(newData); + } + + // Deep clone to avoid mutation + const result = JSON.parse(JSON.stringify(json)); + + // Navigate to the parent of the target + let current = result; + for (let i = 0; i < path.length - 1; i++) { + const segment = path[i]; + current = current[segment]; + + if (current === undefined) { + throw new Error(`Invalid path: cannot find segment "${segment}"`); + } + } + + // Update the target + const lastSegment = path[path.length - 1]; + const newValue = createObjectFromNodeRows(newData); + + // If it's a simple value (not an object/array), set it directly + if (newData.length === 1 && newData[0].key === null) { + current[lastSegment] = newData[0].value; + } else { + current[lastSegment] = newValue; + } + + return result; +} + +/** + * Creates an object or value from NodeRow data + * @param nodeRows - The node rows to convert + * @returns The created object or primitive value + */ +function createObjectFromNodeRows(nodeRows: NodeRow[]): any { + // Handle primitive values (single row with no key) + if (nodeRows.length === 1 && nodeRows[0].key === null) { + return parseValue(nodeRows[0].value, nodeRows[0].type); + } + + // Handle objects + const result: any = {}; + + for (const row of nodeRows) { + // Skip array and object types as they're references to other nodes + if (row.type === "array" || row.type === "object") { + continue; + } + + if (row.key !== null) { + result[row.key] = parseValue(row.value, row.type); + } + } + + return result; +} + +/** + * Parse a value based on its type + * @param value - The value to parse + * @param type - The type of the value + * @returns The parsed value + */ +function parseValue(value: string | number | null, type: string): any { + if (value === null || type === "null") return null; + + switch (type) { + case "boolean": + if (typeof value === "boolean") return value; + return value === "true"; + case "number": + return typeof value === "number" ? value : parseFloat(value as string); + case "string": + return String(value); + default: + return value; + } +} diff --git a/src/store/useEditNode.ts b/src/store/useEditNode.ts new file mode 100644 index 00000000000..e26ad25a002 --- /dev/null +++ b/src/store/useEditNode.ts @@ -0,0 +1,52 @@ +import { create } from "zustand"; +import type { NodeData, NodeRow } from "../types/graph"; + +interface EditNodeState { + isEditing: boolean; + editingNodeId: string | null; + originalData: NodeRow[] | null; + editedData: NodeRow[] | null; +} + +interface EditNodeActions { + startEditing: (nodeId: string, nodeData: NodeRow[]) => void; + updateEditedData: (data: NodeRow[]) => void; + cancelEditing: () => void; + resetEditState: () => void; +} + +const initialState: EditNodeState = { + isEditing: false, + editingNodeId: null, + originalData: null, + editedData: null, +}; + +export const useEditNode = create((set, get) => ({ + ...initialState, + + startEditing: (nodeId, nodeData) => { + // Deep copy the original data + const originalCopy = JSON.parse(JSON.stringify(nodeData)); + const editedCopy = JSON.parse(JSON.stringify(nodeData)); + + set({ + isEditing: true, + editingNodeId: nodeId, + originalData: originalCopy, + editedData: editedCopy, + }); + }, + + updateEditedData: (data) => { + set({ editedData: data }); + }, + + cancelEditing: () => { + set(initialState); + }, + + resetEditState: () => { + set(initialState); + }, +}));