Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 121 additions & 10 deletions src/features/modals/NodeModal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React from "react";
import React, { useState, useMemo } 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 useGraph from "../../editor/views/GraphView/stores/useGraph";
import useJson from "../../../store/useJson";

// return object from json removing array and object fields
const normalizeNodeData = (nodeRows: NodeData["text"]) => {
Expand All @@ -28,6 +29,85 @@ const jsonPathToString = (path?: NodeData["path"]) => {

export const NodeModal = ({ opened, onClose }: ModalProps) => {
const nodeData = useGraph(state => state.selectedNode);
const [editing, setEditing] = useState(false);
const [name, setName] = useState("");
const [value, setValue] = useState("");

// prepare initial fields when modal opens / nodeData changes
React.useEffect(() => {
setEditing(false);
// prefer explicit 'name' and 'color' rows when available
const nameRow = nodeData?.text?.find(r => r.key === "name");
const colorRow = nodeData?.text?.find(r => r.key === "color");

if (nameRow) {
setName(nameRow.value !== undefined ? String(nameRow.value) : "");
} else {
// fallback: use first primitive row value
const first = nodeData?.text?.find(r => r.key !== null);
setName(first?.value !== undefined ? String(first.value) : "");
}

if (colorRow) {
setValue(colorRow.value !== undefined ? String(colorRow.value) : "");
} else {
// fallback: if node itself is a single primitive, use that
const single = nodeData?.text?.[0];
setValue(single?.value !== undefined ? String(single.value) : "");
}
}, [nodeData]);

const pathForKey = (key: string | undefined) => {
if (!nodeData) return undefined;
// if node has the key as a row, target that; otherwise target parent path and key
const row = nodeData.text?.find(r => r.key === key);
if (row && key) return nodeData.path ? [...nodeData.path, key] : [key];
if (nodeData.path) return [...nodeData.path, key ?? ""]; // create
return key ? [key] : undefined;
};

const handleSave = () => {
if (!nodeData) return;
try {
// handle renaming a key named 'name' (rare) - we don't rename arbitrary keys here
// set name value
const namePath = pathForKey("name");
if (namePath) {
let parsedName: any = name;
try {
parsedName = JSON.parse(name);
} catch (e) {
parsedName = name;
}
useJson.getState().setValueAtPath(namePath, parsedName);
}

// set color/value
const colorPath = pathForKey("color");
if (colorPath) {
let parsedColor: any = value;
try {
parsedColor = JSON.parse(value);
} catch (e) {
parsedColor = value;
}
useJson.getState().setValueAtPath(colorPath, parsedColor);
}

setEditing(false);
onClose();
} catch (err) {
console.warn("Failed to save node edits", err);
}
};

const handleCancel = () => {
// discard local changes
const first = nodeData?.text?.[0];
setName(first?.key ?? "");
setValue(first?.value !== undefined ? String(first.value) : "");
setEditing(false);
};

return (
<Modal size="auto" opened={opened} onClose={onClose} centered withCloseButton={false}>
Expand All @@ -37,16 +117,47 @@ export const NodeModal = ({ opened, onClose }: ModalProps) => {
<Text fz="xs" fw={500}>
Content
</Text>
<CloseButton onClick={onClose} />
<Flex gap="xs" align="center">
{!editing ? (
<Button
size="xs"
variant="outline"
onClick={() => setEditing(true)}
data-testid="node-edit-button"
>
Edit
</Button>
) : (
<Group spacing={6}>
<Button size="xs" color="green" onClick={handleSave} data-testid="node-save-button">
Save
</Button>
<Button size="xs" variant="outline" color="red" onClick={handleCancel} data-testid="node-cancel-button">
Cancel
</Button>
</Group>
)}
<CloseButton onClick={onClose} />
</Flex>
</Flex>
<ScrollArea.Autosize mah={250} maw={600}>
<CodeHighlight
code={normalizeNodeData(nodeData?.text ?? [])}
miw={350}
maw={600}
language="json"
withCopyButton
/>
{!editing ? (
<CodeHighlight
code={normalizeNodeData(nodeData?.text ?? [])}
miw={350}
maw={600}
language="json"
withCopyButton
/>
) : (
<Stack spacing={8}>
{/* Name input (only when key exists) */}
{nodeData?.text?.[0]?.key ? (
<TextInput label="Name" size="xs" value={name} onChange={e => setName(e.currentTarget.value)} />
) : null}
<TextInput label="Value / Color" size="xs" value={value} onChange={e => setValue(e.currentTarget.value)} />
</Stack>
)}
</ScrollArea.Autosize>
</Stack>
<Text fz="xs" fw={500}>
Expand Down
61 changes: 61 additions & 0 deletions src/store/useJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ interface JsonActions {
setJson: (json: string) => void;
getJson: () => string;
clear: () => void;
// Set a value at a given JSON path and update the graph
setValueAtPath: (path: any[] | undefined, value: unknown) => void;
// Rename a key at a given JSON path (path points to the key to rename)
setKeyAtPath: (path: any[] | undefined, newKey: string) => void;
}

const initialStates = {
Expand All @@ -21,6 +25,63 @@ const useJson = create<JsonStates & JsonActions>()((set, get) => ({
set({ json, loading: false });
useGraph.getState().setGraph(json);
},
setValueAtPath: (path, value) => {
try {
const current = get().json || "{}";
const obj = JSON.parse(current);

if (!path || path.length === 0) {
// replace root
const newJson = typeof value === "string" ? value : JSON.stringify(value, null, 2);
get().setJson(newJson);
return;
}

// traverse
let cur: any = obj;
for (let i = 0; i < path.length - 1; i++) {
const seg = path[i] as any;
if (cur[seg] === undefined) cur[seg] = {};
cur = cur[seg];
}

const last = path[path.length - 1] as any;
cur[last] = value;

const newJson = JSON.stringify(obj, null, 2);
get().setJson(newJson);
} catch (err) {
// fallback: do nothing
console.warn("Failed to set value at path", err);
}
},
setKeyAtPath: (path, newKey) => {
try {
if (!path || path.length === 0) return;
const current = get().json || "{}";
const obj = JSON.parse(current);

// traverse to parent
let cur: any = obj;
for (let i = 0; i < path.length - 1; i++) {
const seg = path[i] as any;
if (cur[seg] === undefined) cur[seg] = {};
cur = cur[seg];
}

const last = path[path.length - 1] as any;
// only rename if parent is object and has the key
if (cur && Object.prototype.hasOwnProperty.call(cur, last)) {
cur[newKey] = cur[last];
delete cur[last];
}

const newJson = JSON.stringify(obj, null, 2);
get().setJson(newJson);
} catch (err) {
console.warn("Failed to set key at path", err);
}
},
clear: () => {
set({ json: "", loading: false });
useGraph.getState().clearGraph();
Expand Down