diff --git a/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx b/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx index bd87e928f01..dd45828dcec 100644 --- a/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx +++ b/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx @@ -1,35 +1,92 @@ import React from "react"; +import styled from "styled-components"; import type { CustomNodeProps } from "."; import { NODE_DIMENSIONS } from "../../../../../constants/graph"; import type { NodeData } from "../../../../../types/graph"; import { TextRenderer } from "./TextRenderer"; import * as Styled from "./styles"; +import useNodeEdit from "../../../../../store/useNodeEdit"; +import { useModal } from "../../../../../store/useModal"; type RowProps = { row: NodeData["text"][number]; x: number; y: number; index: number; + node: NodeData; }; -const Row = ({ row, x, y, index }: RowProps) => { +const StyledRowWrapper = styled.span` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + gap: 8px; + + .edit-button { + opacity: 0; + transition: opacity 0.2s; + } + + &:hover .edit-button { + opacity: 1; + } +`; + +const EditButton = styled.button` + background: transparent; + border: none; + color: ${({ theme }) => theme.INTERACTIVE_NORMAL}; + cursor: pointer; + font-size: 12px; + padding: 2px 6px; + pointer-events: all; + flex-shrink: 0; + + &:hover { + color: ${({ theme }) => theme.TEXT_POSITIVE}; + } +`; + +const Row = ({ row, x, y, index, node }: RowProps) => { const rowPosition = index * NODE_DIMENSIONS.ROW_HEIGHT; + const startEditNode = useNodeEdit(state => state.startEditNode); + const setVisible = useModal(state => state.setVisible); + const getRowText = () => { if (row.type === "object") return `{${row.childrenCount ?? 0} keys}`; if (row.type === "array") return `[${row.childrenCount ?? 0} items]`; return row.value; }; + const handleEditClick = (e: React.MouseEvent) => { + e.stopPropagation(); + startEditNode(node); + setVisible("EditNodeModal", true); + }; + + const canEdit = row.type !== "object" && row.type !== "array"; + return ( e.stopPropagation()} > - {row.key}: - {getRowText()} + + + {row.key}: + {getRowText()} + + {canEdit && ( + + ✎ + + )} + ); }; @@ -44,7 +101,7 @@ const Node = ({ node, x, y }: CustomNodeProps) => ( $isObject > {node.text.map((row, index) => ( - + ))} ); diff --git a/src/features/editor/views/GraphView/CustomNode/TextNode.tsx b/src/features/editor/views/GraphView/CustomNode/TextNode.tsx index 718ced9d989..542bafaab99 100644 --- a/src/features/editor/views/GraphView/CustomNode/TextNode.tsx +++ b/src/features/editor/views/GraphView/CustomNode/TextNode.tsx @@ -5,6 +5,8 @@ import useConfig from "../../../../../store/useConfig"; import { isContentImage } from "../lib/utils/calculateNodeSize"; import { TextRenderer } from "./TextRenderer"; import * as Styled from "./styles"; +import useNodeEdit from "../../../../../store/useNodeEdit"; +import { useModal } from "../../../../../store/useModal"; const StyledTextNodeWrapper = styled.span<{ $isParent: boolean }>` display: flex; @@ -14,6 +16,11 @@ const StyledTextNodeWrapper = styled.span<{ $isParent: boolean }>` width: 100%; overflow: hidden; padding: 0 10px; + position: relative; + + &:hover .edit-button { + opacity: 1; + } `; const StyledImageWrapper = styled.div` @@ -26,12 +33,38 @@ const StyledImage = styled.img` background: ${({ theme }) => theme.BACKGROUND_MODIFIER_ACCENT}; `; +const EditButton = styled.button` + margin-left: 8px; + background: transparent; + border: none; + color: ${({ theme }) => theme.INTERACTIVE_NORMAL}; + cursor: pointer; + font-size: 14px; + padding: 2px 6px; + opacity: 0; + transition: opacity 0.2s; + pointer-events: all; + + &:hover { + color: ${({ theme }) => theme.TEXT_POSITIVE}; + } +`; + const Node = ({ node, x, y }: CustomNodeProps) => { const { text, width, height } = node; const imagePreviewEnabled = useConfig(state => state.imagePreviewEnabled); const isImage = imagePreviewEnabled && isContentImage(JSON.stringify(text[0].value)); const value = text[0].value; + const startEditNode = useNodeEdit(state => state.startEditNode); + const setVisible = useModal(state => state.setVisible); + + const handleEditClick = (e: React.MouseEvent) => { + e.stopPropagation(); + startEditNode(node); + setVisible("EditNodeModal", true); + }; + return ( { {value} + + ✎ + )} diff --git a/src/features/editor/views/GraphView/CustomNode/styles.tsx b/src/features/editor/views/GraphView/CustomNode/styles.tsx index 175b4524b50..c9bd2dbd3fd 100644 --- a/src/features/editor/views/GraphView/CustomNode/styles.tsx +++ b/src/features/editor/views/GraphView/CustomNode/styles.tsx @@ -30,7 +30,7 @@ export const StyledForeignObject = styled.foreignObject<{ $isObject?: boolean }> font-size: 12px; font-weight: 500; overflow: hidden; - pointer-events: none; + pointer-events: all; &.searched { background: rgba(27, 255, 0, 0.1); diff --git a/src/features/editor/views/GraphView/lib/updateJsonAtPath.ts b/src/features/editor/views/GraphView/lib/updateJsonAtPath.ts new file mode 100644 index 00000000000..eb828916169 --- /dev/null +++ b/src/features/editor/views/GraphView/lib/updateJsonAtPath.ts @@ -0,0 +1,48 @@ +import { modify, applyEdits } from "jsonc-parser"; + +export function parseEditedValue(input: string, originalType?: string) { + const trimmed = input?.trim(); + + if (originalType === "string") return String(input); + + if (trimmed === "null") return null; + if (trimmed === "true") return true; + if (trimmed === "false") return false; + + // number detection + if (/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(trimmed)) { + try { + return Number(trimmed); + } catch (e) { + return String(input); + } + } + + // try JSON parse for arrays or objects + if (trimmed && (trimmed.startsWith("[") || trimmed.startsWith("{"))) { + try { + return JSON.parse(trimmed); + } catch (e) { + // fallthrough to string + } + } + + // fallback to string + return String(input); +} + +export function updateJsonAtPath(json: string, path: Array, value: unknown) { + try { + const edits = modify(json, path as any, value, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }); + + const newJson = applyEdits(json, edits); + return newJson; + } catch (e) { + // rethrow so callers can show errors + throw e; + } +} + +export default updateJsonAtPath; diff --git a/src/features/modals/EditNodeModal/index.tsx b/src/features/modals/EditNodeModal/index.tsx new file mode 100644 index 00000000000..8ad310d0d5b --- /dev/null +++ b/src/features/modals/EditNodeModal/index.tsx @@ -0,0 +1,210 @@ +import React, { useCallback, useMemo } from "react"; +import type { ModalProps } from "@mantine/core"; +import { + Modal, + Stack, + Text, + TextInput, + Textarea, + Flex, + CloseButton, + Button, + Group, + ScrollArea, + Divider, +} from "@mantine/core"; +import useNodeEdit from "../../../store/useNodeEdit"; +import useJson from "../../../store/useJson"; +import useFile from "../../../store/useFile"; +import updateJsonAtPath, { parseEditedValue } from "../../editor/views/GraphView/lib/updateJsonAtPath"; +import toast from "react-hot-toast"; + +// Helper to get JSON path string representation +const jsonPathToString = (path?: Array) => { + if (!path || path.length === 0) return "$"; + const segments = path.map(seg => (typeof seg === "number" ? seg : `"${seg}"`)); + return `$[${segments.join("][")}]`; +}; + +export const EditNodeModal = ({ opened, onClose }: ModalProps) => { + const editingNode = useNodeEdit(state => state.editingNode); + const editedItems = useNodeEdit(state => state.editedItems); + const updateEditedValue = useNodeEdit(state => state.updateEditedValue); + const resetEditedValues = useNodeEdit(state => state.resetEditedValues); + const clearEdit = useNodeEdit(state => state.clearEdit); + + // group editedItems by their group property (undefined grouped as top-level) + const grouped = useMemo(() => { + const groups: Record = {}; + editedItems.forEach(item => { + const g = item.group ?? "__root__"; + if (!groups[g]) groups[g] = []; + groups[g].push(item); + }); + return groups; + }, [editedItems]); + + const handleSave = useCallback(() => { + try { + if (!editingNode) return; + + let updatedJson = useJson.getState().json; + + // Apply all edits to the JSON + for (const item of editedItems) { + if (item.value === item.original) continue; // skip unchanged + + const parsed = parseEditedValue(item.value, item.type as any); + try { + updatedJson = updateJsonAtPath(updatedJson, item.path, parsed); + } catch (err) { + console.warn(`Failed to update path ${item.path}`, err); + throw new Error(`Failed to update ${item.label || "value"}: ${(err as Error).message}`); + } + } + + // Update stores with the final JSON + useFile.getState().setContents({ contents: updatedJson, hasChanges: true, skipUpdate: true }); + useJson.getState().setJson(updatedJson); + + const changedCount = editedItems.filter(item => item.value !== item.original).length; + + if (changedCount > 0) { + toast.success(`Updated ${changedCount} ${changedCount === 1 ? "property" : "properties"}`); + } else { + toast("No changes made"); + } + + clearEdit(); + onClose?.(); + } catch (err: any) { + console.warn("Failed to save edits", err); + toast.error(`Failed to save: ${err?.message || "Unknown error"}`); + } + }, [editingNode, editedItems, clearEdit, onClose]); + + const handleCancel = useCallback(() => { + resetEditedValues && resetEditedValues(); + clearEdit(); + onClose?.(); + }, [resetEditedValues, clearEdit, onClose]); + const hasChanges = useMemo(() => editedItems.some(item => item.value !== item.original), [editedItems]); + + if (!editingNode || editedItems.length === 0) return null; + + return ( + + + {/* Header */} + + + Content + + + + + {/* Editable Fields */} + + + {/* Render root group first */} + {grouped["__root__"]?.map(item => { + const isLong = item.value.length > 50 || item.value.includes("\n"); + return ( + + + {item.label} + + {isLong ? ( + updateEditedValue(item.id, e.currentTarget.value)} + placeholder="Enter value" + minRows={3} + maxRows={6} + styles={{ input: { fontFamily: "monospace", fontSize: "12px" } }} + /> + ) : ( + updateEditedValue(item.id, e.currentTarget.value)} + placeholder="Enter value" + styles={{ input: { fontFamily: "monospace", fontSize: "12px" } }} + /> + )} + + ); + })} + + {/* Render other groups */} + {Object.keys(grouped) + .filter(g => g !== "__root__") + .map(groupKey => ( + + + {groupKey} + + + {grouped[groupKey].map(item => { + const isLong = item.value.length > 50 || item.value.includes("\n"); + return ( + + + {item.label} + + {isLong ? ( + updateEditedValue(item.id, e.currentTarget.value)} + placeholder="Enter value" + minRows={3} + maxRows={6} + styles={{ input: { fontFamily: "monospace", fontSize: "12px" } }} + /> + ) : ( + updateEditedValue(item.id, e.currentTarget.value)} + placeholder="Enter value" + styles={{ input: { fontFamily: "monospace", fontSize: "12px" } }} + /> + )} + + ); + })} + + + ))} + + + + + + {/* JSON Path (Read-only) */} + + + JSON Path + + + {jsonPathToString(editingNode.path)} + + + + {/* Action Buttons */} + + + Cancel + + + Save + + + + + ); +}; diff --git a/src/features/modals/index.ts b/src/features/modals/index.ts index be818c22318..88c98297c5e 100644 --- a/src/features/modals/index.ts +++ b/src/features/modals/index.ts @@ -1,6 +1,7 @@ export { DownloadModal } from "./DownloadModal"; export { ImportModal } from "./ImportModal"; export { NodeModal } from "./NodeModal"; +export { EditNodeModal } from "./EditNodeModal"; export { JWTModal } from "./JWTModal"; export { SchemaModal } from "./SchemaModal"; export { JQModal } from "./JQModal"; diff --git a/src/store/useNodeEdit.ts b/src/store/useNodeEdit.ts new file mode 100644 index 00000000000..7f1c633652f --- /dev/null +++ b/src/store/useNodeEdit.ts @@ -0,0 +1,92 @@ +import { create } from "zustand"; +import type { NodeData, NodeRow } from "../types/graph"; +import useGraph from "../features/editor/views/GraphView/stores/useGraph"; + +type EditItem = { + id: string; // unique id for the editable field + label: string | null; // field name + path: Array; // JSON path to the value + type: NodeRow["type"]; // node type + value: string; // current edited value (stringified) + original: string; // original value stringified + group?: string; // grouping label (e.g., parent property) +}; + +interface NodeEditState { + editingNode: NodeData | null; + editedItems: EditItem[]; // flattened list of editable fields (including nested) +} + +interface NodeEditActions { + startEditNode: (node: NodeData) => void; + updateEditedValue: (id: string, value: string) => void; + resetEditedValues: () => void; + clearEdit: () => void; +} + +const initialStates: NodeEditState = { + editingNode: null, + editedItems: [], +}; + +const makeId = (parts: Array) => parts.join("|"); + +// Recursively collect primitive fields from a node and its referenced child nodes +const collectFields = (node: NodeData, parentGroup: string | undefined): EditItem[] => { + const graphNodes = useGraph.getState().nodes; + const items: EditItem[] = []; + + node.text.forEach((row, index) => { + const key = row.key ?? `_index_${index}`; + const basePath = (node.path ?? []).slice(); + const path = [...basePath, row.key ?? index]; + + if (row.type !== "object" && row.type !== "array") { + const valueStr = String(row.value ?? ""); + items.push({ + id: makeId(path), + label: row.key ?? null, + path, + type: row.type, + value: valueStr, + original: valueStr, + group: parentGroup, + }); + } else { + // find child nodes referenced by this row (row.to may contain node ids) + if (row.to && Array.isArray(row.to) && row.to.length > 0) { + row.to.forEach(targetId => { + const childNode = graphNodes.find(n => n.id === targetId); + if (childNode) { + // use this row.key as group label for child fields + const groupLabel = row.key ?? key; + const childItems = collectFields(childNode, groupLabel); + items.push(...childItems); + } + }); + } + } + }); + + return items; +}; + +export const useNodeEdit = create((set) => ({ + ...initialStates, + startEditNode: (node: NodeData) => { + // collect editable primitive fields from the node and referenced children + const items = collectFields(node, undefined); + set({ editingNode: node, editedItems: items }); + }, + updateEditedValue: (id: string, value: string) => { + set(state => ({ + editedItems: state.editedItems.map(item => (item.id === id ? { ...item, value } : item)), + })); + }, + resetEditedValues: () => { + set(state => ({ editedItems: state.editedItems.map(item => ({ ...item, value: item.original })) })); + }, + clearEdit: () => set({ editingNode: null, editedItems: [] }), +})); + +export default useNodeEdit;