feat: add multidimensional search, preview mode prioritization, and collapse/expand all to Object Editor
This commit is contained in:
BIN
src/components/._StructuredEditor.js
Executable file
BIN
src/components/._StructuredEditor.js
Executable file
Binary file not shown.
@@ -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
BIN
src/pages/._ObjectEditor.js
Executable file
Binary file not shown.
Reference in New Issue
Block a user