feat: add multidimensional search, preview mode prioritization, and collapse/expand all to Object Editor

This commit is contained in:
Dwindi Ramadhana
2026-06-13 20:11:07 +07:00
parent 6a14eebf25
commit 13e694aa82
3 changed files with 476 additions and 215 deletions

Binary file not shown.

View File

@@ -1,19 +1,45 @@
import React, { useState, useEffect, useRef } from 'react';
import { Plus, Minus, ChevronDown, ChevronRight, Type, Hash, ToggleLeft, List, Braces, Edit3, X, Eye, Pencil } from 'lucide-react';
import React, { useState, useEffect, useRef } from "react";
import {
Plus,
Minus,
ChevronDown,
ChevronRight,
ChevronsUpDown,
ChevronsDownUp,
Type,
Hash,
ToggleLeft,
List,
Braces,
Edit3,
X,
Eye,
Pencil,
Search,
} from "lucide-react";
const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyProp = false }) => {
const StructuredEditor = ({
onDataChange,
initialData = {},
readOnly: readOnlyProp = false,
}) => {
const [data, setData] = useState(initialData);
const [expandedNodes, setExpandedNodes] = useState(new Set(['root']));
const [expandedNodes, setExpandedNodes] = useState(new Set(["root"]));
const [fieldTypes, setFieldTypes] = useState({}); // Track intended types for fields
const isInternalUpdate = useRef(false);
const [nestedEditModal, setNestedEditModal] = useState(null); // { path, value, type: 'json' | 'serialized' }
const [nestedData, setNestedData] = useState(null);
// Start in edit mode if readOnly is false
const [editMode, setEditMode] = useState(readOnlyProp === false);
// Start in preview mode if readOnly is false
const [editMode, setEditMode] = useState(
readOnlyProp === false ? false : !readOnlyProp,
);
// Use internal editMode if readOnlyProp is not explicitly set, otherwise use prop
const readOnly = readOnlyProp !== false ? readOnlyProp : !editMode;
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState(new Set());
// Update internal data when initialData prop changes (but not from internal updates)
useEffect(() => {
// Skip update if this change came from internal editor actions
@@ -25,7 +51,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
setData(initialData);
// Expand root node if there's data
if (Object.keys(initialData).length > 0) {
setExpandedNodes(new Set(['root']));
setExpandedNodes(new Set(["root"]));
}
}, [initialData]);
@@ -37,13 +63,13 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
// PHP serialize/unserialize functions
const phpSerialize = (data) => {
if (data === null) return 'N;';
if (typeof data === 'boolean') return data ? 'b:1;' : 'b:0;';
if (typeof data === 'number') {
if (data === null) return "N;";
if (typeof data === "boolean") return data ? "b:1;" : "b:0;";
if (typeof data === "number") {
return Number.isInteger(data) ? `i:${data};` : `d:${data};`;
}
if (typeof data === 'string') {
const escapedData = data.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
if (typeof data === "string") {
const escapedData = data.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const byteLength = new TextEncoder().encode(escapedData).length;
return `s:${byteLength}:"${escapedData}";`;
}
@@ -52,72 +78,78 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
data.forEach((item, index) => {
result += phpSerialize(index) + phpSerialize(item);
});
result += '}';
result += "}";
return result;
}
if (typeof data === 'object') {
if (typeof data === "object") {
const keys = Object.keys(data);
let result = `a:${keys.length}:{`;
keys.forEach(key => {
keys.forEach((key) => {
result += phpSerialize(key) + phpSerialize(data[key]);
});
result += '}';
result += "}";
return result;
}
return 'N;';
return "N;";
};
const phpUnserialize = (str) => {
let index = 0;
const parseValue = () => {
if (index >= str.length) throw new Error('Unexpected end of string');
if (index >= str.length) throw new Error("Unexpected end of string");
const type = str[index];
if (type === 'N') {
if (type === "N") {
index += 2;
return null;
}
if (str[index + 1] !== ':') throw new Error(`Expected ':' after type '${type}'`);
if (str[index + 1] !== ":")
throw new Error(`Expected ':' after type '${type}'`);
index += 2;
switch (type) {
case 'b':
const boolVal = str[index] === '1';
case "b":
const boolVal = str[index] === "1";
index += 2;
return boolVal;
case 'i':
let intStr = '';
while (index < str.length && str[index] !== ';') intStr += str[index++];
case "i":
let intStr = "";
while (index < str.length && str[index] !== ";")
intStr += str[index++];
index++;
return parseInt(intStr);
case 'd':
let floatStr = '';
while (index < str.length && str[index] !== ';') floatStr += str[index++];
case "d":
let floatStr = "";
while (index < str.length && str[index] !== ";")
floatStr += str[index++];
index++;
return parseFloat(floatStr);
case 's':
let lenStr = '';
while (index < str.length && str[index] !== ':') lenStr += str[index++];
case "s":
let lenStr = "";
while (index < str.length && str[index] !== ":")
lenStr += str[index++];
index++;
if (str[index] !== '"') throw new Error('Expected opening quote');
if (str[index] !== '"') throw new Error("Expected opening quote");
index++;
const byteLength = parseInt(lenStr);
if (byteLength === 0) {
index += 2;
return '';
return "";
}
let endQuotePos = -1;
for (let i = index; i < str.length - 1; i++) {
if (str[i] === '"' && str[i + 1] === ';') {
if (str[i] === '"' && str[i + 1] === ";") {
endQuotePos = i;
break;
}
}
if (endQuotePos === -1) throw new Error('Could not find closing quote');
if (endQuotePos === -1)
throw new Error("Could not find closing quote");
const strValue = str.substring(index, endQuotePos);
index = endQuotePos + 2;
return strValue.replace(/\\"/g, '"').replace(/\\\\/g, '\\');
case 'a':
let countStr = '';
while (index < str.length && str[index] !== ':') countStr += str[index++];
return strValue.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
case "a":
let countStr = "";
while (index < str.length && str[index] !== ":")
countStr += str[index++];
const count = parseInt(countStr);
index += 2;
const result = {};
@@ -139,13 +171,13 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
// Detect if a string contains JSON or serialized data
const detectNestedData = (value) => {
if (typeof value !== 'string' || value.length < 5) return null;
if (typeof value !== "string" || value.length < 5) return null;
// Try JSON first
try {
const parsed = JSON.parse(value);
if (typeof parsed === 'object' && parsed !== null) {
return { type: 'json', data: parsed };
if (typeof parsed === "object" && parsed !== null) {
return { type: "json", data: parsed };
}
} catch (e) {
// Not JSON, continue
@@ -156,8 +188,8 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
// Check if it looks like PHP serialized format
if (/^[abidsNO]:[^;]*;/.test(value) || /^a:\d+:\{/.test(value)) {
const parsed = phpUnserialize(value);
if (typeof parsed === 'object' && parsed !== null) {
return { type: 'serialized', data: parsed };
if (typeof parsed === "object" && parsed !== null) {
return { type: "serialized", data: parsed };
}
}
} catch (e) {
@@ -182,9 +214,9 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
// Convert back to string based on type
let stringValue;
if (nestedEditModal.type === 'json') {
if (nestedEditModal.type === "json") {
stringValue = JSON.stringify(nestedData);
} else if (nestedEditModal.type === 'serialized') {
} else if (nestedEditModal.type === "serialized") {
stringValue = phpSerialize(nestedData);
}
@@ -212,8 +244,112 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
setExpandedNodes(newExpanded);
};
const expandAll = () => {
const allPaths = new Set(["root"]);
// Helper to traverse and collect all paths
const traverse = (obj, currentPath) => {
if (typeof obj === "object" && obj !== null) {
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
const path = `${currentPath}.${index}`;
if (typeof item === "object" && item !== null) {
allPaths.add(path);
traverse(item, path);
}
});
} else {
Object.entries(obj).forEach(([key, value]) => {
const path = `${currentPath}.${key}`;
if (typeof value === "object" && value !== null) {
allPaths.add(path);
traverse(value, path);
}
});
}
}
};
traverse(data, "root");
setExpandedNodes(allPaths);
};
const collapseAll = () => {
setExpandedNodes(new Set(["root"]));
};
// Search effect to auto-expand paths containing matches
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults(new Set());
return;
}
const query = searchQuery.toLowerCase();
const results = new Set();
const pathsToExpand = new Set(["root"]);
// Returns true if a match is found in this node or its descendants
const searchTraverse = (obj, currentPath) => {
let foundInCurrent = false;
if (typeof obj === "object" && obj !== null) {
if (Array.isArray(obj)) {
obj.forEach((item, index) => {
const path = `${currentPath}.${index}`;
const keyMatches = index.toString().includes(query);
let foundInChild = false;
if (typeof item === "object" && item !== null) {
foundInChild = searchTraverse(item, path);
} else {
const valueStr = getDisplayValue(item).toLowerCase();
if (valueStr.includes(query)) foundInChild = true;
}
if (keyMatches || foundInChild) {
results.add(path);
pathsToExpand.add(currentPath);
pathsToExpand.add(path);
foundInCurrent = true;
}
});
} else {
Object.entries(obj).forEach(([key, value]) => {
const path = `${currentPath}.${key}`;
const keyMatches = key.toLowerCase().includes(query);
let foundInChild = false;
if (typeof value === "object" && value !== null) {
foundInChild = searchTraverse(value, path);
} else {
const valueStr = getDisplayValue(value).toLowerCase();
if (valueStr.includes(query)) foundInChild = true;
}
if (keyMatches || foundInChild) {
results.add(path);
pathsToExpand.add(currentPath);
pathsToExpand.add(path);
foundInCurrent = true;
}
});
}
}
return foundInCurrent;
};
searchTraverse(data, "root");
setSearchResults(results);
// Merge expanded nodes with paths that need to be expanded for search
if (results.size > 0) {
setExpandedNodes((prev) => new Set([...prev, ...pathsToExpand]));
}
}, [searchQuery, data]);
const addProperty = (obj, path) => {
const pathParts = path.split('.');
const pathParts = path.split(".");
const newData = { ...data };
let current = newData;
@@ -225,15 +361,15 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
// Add new property to the target object
const keys = Object.keys(current);
const newKey = `property${keys.length + 1}`;
current[newKey] = '';
current[newKey] = "";
updateData(newData);
setExpandedNodes(new Set([...expandedNodes, path]));
};
const addArrayItem = (arr, path) => {
const newArr = [...arr, ''];
const pathParts = path.split('.');
const newArr = [...arr, ""];
const pathParts = path.split(".");
const newData = { ...data };
let current = newData;
@@ -251,7 +387,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
};
const removeProperty = (key, parentPath) => {
const pathParts = parentPath.split('.');
const pathParts = parentPath.split(".");
const newData = { ...data };
let current = newData;
@@ -261,7 +397,8 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
}
// Remove field type tracking for the removed property
const removedPath = parentPath === 'root' ? `root.${key}` : `${parentPath}.${key}`;
const removedPath =
parentPath === "root" ? `root.${key}` : `${parentPath}.${key}`;
const newFieldTypes = { ...fieldTypes };
delete newFieldTypes[removedPath];
setFieldTypes(newFieldTypes);
@@ -276,16 +413,16 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
}
// Check if we're removing from root level and it's the last property
if (parentPath === 'root' && Object.keys(newData).length === 0) {
if (parentPath === "root" && Object.keys(newData).length === 0) {
// Add an empty property to maintain initial state, like TableEditor maintains at least one row
newData[''] = '';
newData[""] = "";
}
updateData(newData);
};
const updateValue = (value, path) => {
const pathParts = path.split('.');
const pathParts = path.split(".");
const newData = { ...data };
let current = newData;
@@ -298,29 +435,30 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
const currentType = typeof currentValue;
// Preserve the current type when updating value
if (currentType === 'boolean') {
current[key] = value === 'true';
} else if (currentType === 'number') {
if (currentType === "boolean") {
current[key] = value === "true";
} else if (currentType === "number") {
const numValue = Number(value);
current[key] = isNaN(numValue) ? 0 : numValue;
} else if (currentValue === null) {
current[key] = value === 'null' ? null : value;
current[key] = value === "null" ? null : value;
} else {
// For strings and initial empty values, use smart detection
if (currentValue === '' || currentValue === undefined) {
if (currentValue === "" || currentValue === undefined) {
// Check if this is a newly added property (starts with "property" + number)
const isNewProperty = typeof key === 'string' && key.match(/^property\d+$/);
const isNewProperty =
typeof key === "string" && key.match(/^property\d+$/);
if (isNewProperty) {
// New properties added by user are always strings (no auto-detection)
current[key] = value;
} else {
// Existing properties from loaded data - use auto-detection
if (value === 'true' || value === 'false') {
current[key] = value === 'true';
} else if (value === 'null') {
if (value === "true" || value === "false") {
current[key] = value === "true";
} else if (value === "null") {
current[key] = null;
} else if (!isNaN(value) && value !== '' && value.trim() !== '') {
} else if (!isNaN(value) && value !== "" && value.trim() !== "") {
current[key] = Number(value);
} else {
current[key] = value;
@@ -336,7 +474,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
};
const changeType = (newType, path) => {
const pathParts = path.split('.');
const pathParts = path.split(".");
const newData = { ...data };
let current = newData;
@@ -354,69 +492,98 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
// Try to preserve value when changing types if possible
switch (newType) {
case 'string':
case 'longtext':
current[key] = currentValue === null ? '' : currentValue.toString();
case "string":
case "longtext":
current[key] = currentValue === null ? "" : currentValue.toString();
break;
case 'number':
if (typeof currentValue === 'string' && !isNaN(currentValue) && currentValue.trim() !== '') {
case "number":
if (
typeof currentValue === "string" &&
!isNaN(currentValue) &&
currentValue.trim() !== ""
) {
current[key] = Number(currentValue);
} else if (typeof currentValue === 'boolean') {
} else if (typeof currentValue === "boolean") {
current[key] = currentValue ? 1 : 0;
} else {
current[key] = 0;
}
break;
case 'boolean':
if (typeof currentValue === 'string') {
current[key] = currentValue.toLowerCase() === 'true';
} else if (typeof currentValue === 'number') {
case "boolean":
if (typeof currentValue === "string") {
current[key] = currentValue.toLowerCase() === "true";
} else if (typeof currentValue === "number") {
current[key] = currentValue !== 0;
} else {
current[key] = false;
}
break;
case 'array':
case "array":
current[key] = [];
break;
case 'object':
case "object":
current[key] = {};
break;
case 'null':
case "null":
current[key] = null;
break;
default:
current[key] = '';
current[key] = "";
}
updateData(newData);
setExpandedNodes(new Set([...expandedNodes, path]));
};
// Helper function to display string values with proper unescaping
const getDisplayValue = (value) => {
if (value === null) return 'null';
if (value === undefined) return '';
if (value === null) return "null";
if (value === undefined) return "";
const stringValue = value.toString();
// If it's a string, unescape common JSON escape sequences for display
if (typeof value === 'string') {
if (typeof value === "string") {
return stringValue
.replace(/\\"/g, '"') // Unescape quotes
.replace(/\\'/g, "'") // Unescape single quotes
.replace(/\\\//g, '/') // Unescape forward slashes
.replace(/\\\\/g, '\\'); // Unescape backslashes (do this last)
.replace(/\\"/g, '"') // Unescape quotes
.replace(/\\'/g, "'") // Unescape single quotes
.replace(/\\\//g, "/") // Unescape forward slashes
.replace(/\\\\/g, "\\"); // Unescape backslashes (do this last)
}
return stringValue;
};
// Helper function to render text with search highlighting
const renderHighlightedText = (text) => {
if (!searchQuery.trim() || typeof text !== "string") return text;
const query = searchQuery.toLowerCase();
const textLower = text.toLowerCase();
const index = textLower.indexOf(query);
if (index === -1) return text;
const before = text.substring(0, index);
const match = text.substring(index, index + query.length);
const after = text.substring(index + query.length);
return (
<>
{before}
<span className="bg-yellow-200 dark:bg-yellow-800 text-black dark:text-white rounded-sm">
{match}
</span>
{renderHighlightedText(after)}{" "}
{/* Handle multiple matches in same string if needed */}
</>
);
};
const renameKey = (oldKey, newKey, path) => {
if (oldKey === newKey || !newKey.trim()) return;
const pathParts = path.split('.');
const pathParts = path.split(".");
const newData = { ...data };
let current = newData;
@@ -465,7 +632,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
);
}
if (typeof value === 'string') {
if (typeof value === "string") {
return (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-300">
<Type className="h-3 w-3" />
@@ -473,7 +640,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
);
}
if (typeof value === 'number') {
if (typeof value === "number") {
return (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-300">
<Hash className="h-3 w-3" />
@@ -481,7 +648,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
);
}
if (typeof value === 'boolean') {
if (typeof value === "boolean") {
return (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300">
<ToggleLeft className="h-3 w-3" />
@@ -497,7 +664,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
);
}
if (typeof value === 'object') {
if (typeof value === "object") {
return (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300">
<Braces className="h-3 w-3" />
@@ -514,16 +681,41 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
const renderValue = (value, key, path, parentPath) => {
const isExpanded = expandedNodes.has(path);
const canExpand = typeof value === 'object' && value !== null;
const canExpand = typeof value === "object" && value !== null;
// Check if this node matches the search query (if active)
// A node matches if its path is in searchResults
const isSearchActive = searchQuery.trim() !== "";
const isMatch = isSearchActive && searchResults.has(path);
// Check if any of its children match
let hasMatchingChildren = false;
if (isSearchActive && canExpand) {
// Look through searchResults to see if any path starts with this node's path
hasMatchingChildren = Array.from(searchResults).some((resPath) =>
resPath.startsWith(`${path}.`),
);
}
// Hide node if:
// 1. Search is active AND
// 2. Node itself doesn't match AND
// 3. Node has no matching children AND
// 4. Node is not the root (we always render root level if it matches something to keep structure)
// Exception: If we're at root level but the node has no matches and no matching children, hide it
const isHiddenBySearch = isSearchActive && !isMatch && !hasMatchingChildren;
// If there is an active search and this node doesn't match and has no matching children, don't render it
if (isHiddenBySearch) return null;
// Check if parent is an array by looking at the parent path
const isArrayItem = (() => {
if (parentPath === 'root') {
if (parentPath === "root") {
// If parent is root, check if root data is an array
return Array.isArray(data);
} else {
// Navigate to parent and check if it's an array
const parentPathParts = parentPath.split('.');
const parentPathParts = parentPath.split(".");
let current = data;
for (let i = 1; i < parentPathParts.length; i++) {
current = current[parentPathParts[i]];
@@ -533,7 +725,10 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
})();
return (
<div key={path} className="ml-4 border-l border-gray-200 dark:border-gray-700 pl-4 overflow-hidden">
<div
key={path}
className="ml-4 border-l border-gray-200 dark:border-gray-700 pl-4 overflow-hidden"
>
<div className="flex items-center space-x-2 mb-2">
{canExpand && (
<button
@@ -553,38 +748,41 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
<div className="flex items-center space-x-2 flex-1">
{isArrayItem ? (
// Array items: icon + index span (compact)
<>
<div className="flex items-center space-x-1 w-[120px] shrink-0">
{getTypeIcon(value)}
<span className="px-2 py-1 text-sm text-gray-600 dark:text-gray-600 font-mono whitespace-nowrap">
[{key}]
<span className="text-gray-500 dark:text-gray-400 font-mono text-sm">
{renderHighlightedText(key)}
</span>
</>
<span className="text-gray-600 inline">:</span>
</div>
) : (
// Object properties: icon + editable key + colon (compact)
// Object properties: icon + editable key input
<>
{getTypeIcon(value)}
{readOnly ? (
<span className="px-2 py-1 text-sm text-gray-900 dark:text-gray-100 font-mono">
{key}
<span
className="px-2 py-1 text-sm font-medium text-gray-900 dark:text-gray-100 min-w-0 break-all"
style={{ width: "120px" }}
>
{renderHighlightedText(key)}
</span>
) : (
<input
type="text"
defaultValue={key}
onBlur={(e) => {
const newKey = e.target.value.trim();
if (newKey && newKey !== key) {
renameKey(key, newKey, path);
if (e.target.value !== key) {
renameKey(key, e.target.value, path);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.key === "Enter") {
e.target.blur(); // Trigger blur to save changes
}
}}
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-0"
placeholder="Property name"
style={{width: '120px'}} // Fixed width for consistency
style={{ width: "120px" }} // Fixed width for consistency
/>
)}
<span className="text-gray-600 inline">:</span>
@@ -592,23 +790,23 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
)}
{!canExpand ? (
typeof value === 'boolean' ? (
typeof value === "boolean" ? (
<div className="flex-1 flex items-center space-x-2">
{readOnly ? (
<span className="text-sm text-gray-900 dark:text-gray-100 font-mono">
{value.toString()}
{renderHighlightedText(value.toString())}
</span>
) : (
<>
<button
onClick={() => updateValue((!value).toString(), path)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
value ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-600'
value ? "bg-blue-600" : "bg-gray-200 dark:bg-gray-600"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
value ? 'translate-x-6' : 'translate-x-1'
value ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
@@ -621,22 +819,23 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
) : (
<div className="flex-1 flex items-center gap-2">
{readOnly ? (
typeof value === 'string' && detectNestedData(value) ? (
typeof value === "string" && detectNestedData(value) ? (
<span
onClick={() => openNestedEditor(value, path)}
className="px-2 py-1 text-sm text-blue-600 dark:text-blue-400 font-mono break-all cursor-pointer hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
title={`Click to view nested ${detectNestedData(value).type} data`}
>
{getDisplayValue(value)}
{renderHighlightedText(getDisplayValue(value))}
</span>
) : (
<span className="px-2 py-1 text-sm text-gray-900 dark:text-gray-100 font-mono break-all">
{getDisplayValue(value)}
{renderHighlightedText(getDisplayValue(value))}
</span>
)
) : (
<>
{(fieldTypes[path] === 'longtext' || (typeof value === 'string' && value.includes('\n'))) ? (
{fieldTypes[path] === "longtext" ||
(typeof value === "string" && value.includes("\n")) ? (
<textarea
value={getDisplayValue(value)}
onChange={(e) => updateValue(e.target.value, path)}
@@ -653,7 +852,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
placeholder="Value"
/>
)}
{typeof value === 'string' && detectNestedData(value) && (
{typeof value === "string" && detectNestedData(value) && (
<button
onClick={() => openNestedEditor(value, path)}
className="p-1 text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded flex-shrink-0"
@@ -668,7 +867,9 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
)
) : (
<span className="flex-1 text-sm text-gray-600 dark:text-gray-600">
{Array.isArray(value) ? `Array (${value.length} items)` : `Object (${Object.keys(value).length} properties)`}
{Array.isArray(value)
? `Array (${value.length} items)`
: `Object (${Object.keys(value).length} properties)`}
</span>
)}
@@ -676,14 +877,22 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
<div className="flex items-center space-x-2 sm:space-x-2">
<select
value={
fieldTypes[path] || (
value === null ? 'null' :
value === undefined ? 'string' :
typeof value === 'string' ? (value.includes('\n') ? 'longtext' : 'string') :
typeof value === 'number' ? 'number' :
typeof value === 'boolean' ? 'boolean' :
Array.isArray(value) ? 'array' : 'object'
)
fieldTypes[path] ||
(value === null
? "null"
: value === undefined
? "string"
: typeof value === "string"
? value.includes("\n")
? "longtext"
: "string"
: typeof value === "number"
? "number"
: typeof value === "boolean"
? "boolean"
: Array.isArray(value)
? "array"
: "object")
}
onChange={(e) => changeType(e.target.value, path)}
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
@@ -714,7 +923,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
{Array.isArray(value) ? (
<>
{value.map((item, index) =>
renderValue(item, index.toString(), `${path}.${index}`, path)
renderValue(item, index.toString(), `${path}.${index}`, path),
)}
{!readOnly && (
<button
@@ -729,7 +938,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
) : (
<>
{Object.entries(value).map(([k, v]) =>
renderValue(v, k, `${path}.${k}`, path)
renderValue(v, k, `${path}.${k}`, path),
)}
{!readOnly && (
<button
@@ -751,36 +960,82 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
return (
<div className="min-h-96 w-full">
<div className="mb-4">
<div className="flex flex-col gap-3 mb-3">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Structured Data Editor</h3>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-3">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap">
Structured Data Editor
</h3>
{/* Mode Toggle - Below title on mobile, inline on desktop */}
{readOnlyProp === false && (
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm w-fit">
<div className="flex flex-wrap items-center gap-3 w-full sm:w-auto sm:justify-end">
{/* Search Bar Inline */}
<div className="relative flex-grow max-w-[200px] sm:max-w-xs order-last sm:order-first">
<div className="absolute inset-y-0 left-0 pl-2.5 flex items-center pointer-events-none">
<Search className="h-3.5 w-3.5 text-gray-400" />
</div>
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-8 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-xs text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute inset-y-0 right-0 pr-2.5 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Expand/Collapse All Buttons */}
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm shrink-0">
<button
onClick={() => setEditMode(false)}
className={`flex items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors ${
!editMode
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300'
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
onClick={expandAll}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
title="Expand All"
>
<Eye className="h-3.5 w-3.5" />
<span>Preview</span>
<ChevronsUpDown className="h-3.5 w-3.5" />
<span className="hidden lg:inline">Expand All</span>
</button>
<button
onClick={() => setEditMode(true)}
className={`flex items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${
editMode
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300'
: 'text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
onClick={collapseAll}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-l border-gray-200 dark:border-gray-700"
title="Collapse All"
>
<Pencil className="h-3.5 w-3.5" />
<span>Edit</span>
<ChevronsDownUp className="h-3.5 w-3.5" />
<span className="hidden lg:inline">Collapse All</span>
</button>
</div>
)}
{/* Mode Toggle - Below title on mobile, inline on desktop */}
{readOnlyProp === false && (
<div className="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm shrink-0">
<button
onClick={() => setEditMode(false)}
className={`flex items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors ${
!editMode
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300"
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
<Eye className="h-3.5 w-3.5" />
<span>Preview</span>
</button>
<button
onClick={() => setEditMode(true)}
className={`flex items-center gap-2 px-3 py-1.5 text-xs font-medium transition-colors border-l border-gray-200 dark:border-gray-700 ${
editMode
? "bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300"
: "text-gray-600 dark:text-gray-600 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
<Pencil className="h-3.5 w-3.5" />
<span>Edit</span>
</button>
</div>
)}
</div>
</div>
</div>
@@ -790,18 +1045,21 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
{Object.keys(data).length === 0 ? (
<div className="text-center text-gray-600 dark:text-gray-600 py-8">
<Braces className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No properties yet. Click "Add Property" to start building your data structure.</p>
<p>
No properties yet. Click "Add Property" to start building your
data structure.
</p>
</div>
) : (
Object.entries(data).map(([key, value]) =>
renderValue(value, key, `root.${key}`, 'root')
renderValue(value, key, `root.${key}`, "root"),
)
)}
{/* Root level Add Property button */}
{!readOnly && (
<button
onClick={() => addProperty(data, 'root')}
onClick={() => addProperty(data, "root")}
className="flex items-center space-x-1 px-2 py-1 text-sm text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900 rounded"
>
<Plus className="h-4 w-4" />
@@ -820,10 +1078,13 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20 flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Edit Nested {nestedEditModal.type === 'json' ? 'JSON' : 'Serialized'} Data
Edit Nested{" "}
{nestedEditModal.type === "json" ? "JSON" : "Serialized"} Data
</h3>
<p className="text-sm text-gray-600 dark:text-gray-600 mt-1">
Changes will be saved back as a {nestedEditModal.type === 'json' ? 'JSON' : 'serialized'} string
Changes will be saved back as a{" "}
{nestedEditModal.type === "json" ? "JSON" : "serialized"}{" "}
string
</p>
</div>
<button

BIN
src/pages/._ObjectEditor.js Executable file

Binary file not shown.