diff --git a/src/components/._StructuredEditor.js b/src/components/._StructuredEditor.js new file mode 100755 index 00000000..3d095653 Binary files /dev/null and b/src/components/._StructuredEditor.js differ diff --git a/src/components/StructuredEditor.js b/src/components/StructuredEditor.js index d6c4df56..eb38611e 100755 --- a/src/components/StructuredEditor.js +++ b/src/components/StructuredEditor.js @@ -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 @@ -21,11 +47,11 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr isInternalUpdate.current = false; return; } - + 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,31 +171,31 @@ 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 } - + // Try PHP serialized try { // 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) { // Not serialized } - + return null; }; @@ -179,18 +211,18 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr // Save nested editor changes const saveNestedEdit = () => { if (!nestedEditModal || !nestedData) return; - + // 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); } - + // Update the value in the main data updateValue(stringValue, nestedEditModal.path); - + // Close modal setNestedEditModal(null); setNestedData(null); @@ -212,60 +244,165 @@ 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; - + // Navigate to the target object in the full data structure for (let i = 1; i < pathParts.length; i++) { current = current[pathParts[i]]; } - + // 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; - + for (let i = 1; i < pathParts.length - 1; i++) { current = current[pathParts[i]]; } - + if (pathParts.length === 2) { newData[pathParts[1]] = newArr; } else { current[pathParts[pathParts.length - 1]] = newArr; } - + updateData(newData); }; const removeProperty = (key, parentPath) => { - const pathParts = parentPath.split('.'); + const pathParts = parentPath.split("."); const newData = { ...data }; let current = newData; - + // Navigate to the parent object/array for (let i = 1; i < pathParts.length; i++) { current = current[pathParts[i]]; } - + // 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); - + // Delete the property/item from the parent if (Array.isArray(current)) { // For arrays, remove by index and reindex @@ -274,53 +411,54 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr // For objects, delete the property delete current[key]; } - + // 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; - + for (let i = 1; i < pathParts.length - 1; i++) { current = current[pathParts[i]]; } - + const key = pathParts[pathParts.length - 1]; const currentValue = current[key]; 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; @@ -331,105 +469,134 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr current[key] = value; } } - + updateData(newData); }; const changeType = (newType, path) => { - const pathParts = path.split('.'); + const pathParts = path.split("."); const newData = { ...data }; let current = newData; - + for (let i = 1; i < pathParts.length - 1; i++) { current = current[pathParts[i]]; } - + const key = pathParts[pathParts.length - 1]; const currentValue = current[key]; - + // Track the intended type for this field const newFieldTypes = { ...fieldTypes }; newFieldTypes[path] = newType; setFieldTypes(newFieldTypes); - + // 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} + + {match} + + {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; - + // Navigate to parent object for (let i = 1; i < pathParts.length - 1; i++) { current = current[pathParts[i]]; } - + // Check if new key already exists if (current.hasOwnProperty(newKey)) { return; // Don't rename if key already exists } - + // Update field type tracking for renamed key const oldPath = path; const newPath = path.replace(new RegExp(`\\.${oldKey}$`), `.${newKey}`); @@ -439,12 +606,12 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr delete newFieldTypes[oldPath]; setFieldTypes(newFieldTypes); } - + // Rename the key const value = current[oldKey]; delete current[oldKey]; current[newKey] = value; - + updateData(newData); }; @@ -456,7 +623,7 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr ); } - + if (value === undefined) { return ( @@ -464,31 +631,31 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr ); } - - if (typeof value === 'string') { + + if (typeof value === "string") { return ( ); } - - if (typeof value === 'number') { + + if (typeof value === "number") { return ( ); } - - if (typeof value === 'boolean') { + + if (typeof value === "boolean") { return ( ); } - + if (Array.isArray(value)) { return ( @@ -496,15 +663,15 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr ); } - - if (typeof value === 'object') { + + if (typeof value === "object") { return ( ); } - + return ( @@ -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 ( -
+
{canExpand && ( @@ -621,22 +819,23 @@ const StructuredEditor = ({ onDataChange, initialData = {}, readOnly: readOnlyPr ) : (
{readOnly ? ( - typeof value === 'string' && detectNestedData(value) ? ( - 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))} ) : ( - {getDisplayValue(value)} + {renderHighlightedText(getDisplayValue(value))} ) ) : ( <> - {(fieldTypes[path] === 'longtext' || (typeof value === 'string' && value.includes('\n'))) ? ( + {fieldTypes[path] === "longtext" || + (typeof value === "string" && value.includes("\n")) ? (