feat: comprehensive editor UX refinement with collapsible sections and data loss prevention
Major improvements to Object Editor, Table Editor, and Invoice Editor: ## UX Enhancements: - Made export sections collapsible across all editors to reduce page height - Added comprehensive, collapsible usage tips with eye-catching design - Implemented consistent input method patterns (file auto-load, inline URL buttons) - Paste sections now collapse after successful parsing with data summaries ## Data Loss Prevention: - Added confirmation modals when switching input methods with existing data - Amber-themed warning design with specific data summaries - Suggests saving before proceeding with destructive actions - Prevents accidental data loss across all editor tools ## Consistency Improvements: - Standardized file input styling with 'tool-input' class - URL fetch buttons now inline (not below input) across all editors - Parse buttons positioned consistently on bottom-right - Auto-load behavior for file inputs matching across editors ## Bug Fixes: - Fixed Table Editor cell text overflow with proper truncation - Fixed Object Editor file input to auto-load content - Removed unnecessary parse buttons and checkboxes - Fixed Invoice Editor URL form layout ## Documentation: - Created EDITOR_TOOL_GUIDE.md with comprehensive patterns - Created EDITOR_CHECKLIST.md for quick reference - Created PROJECT_ROADMAP.md with future plans - Created TODO.md with detailed task lists - Documented data loss prevention patterns - Added code examples and best practices All editors now follow consistent UX patterns with improved user experience and data protection.
This commit is contained in:
@@ -123,7 +123,7 @@ const CodeMirrorEditor = ({
|
||||
<div className={`relative ${className}`}>
|
||||
<div
|
||||
ref={editorRef}
|
||||
className={`dewedev-code-mirror border border-gray-300 dark:border-gray-600 rounded-md ${
|
||||
className={`dewedev-code-mirror border border-gray-300 dark:border-gray-600 rounded-md overflow-hidden ${
|
||||
isDark ? 'bg-gray-900' : 'bg-white'
|
||||
} ${isExpanded ? 'h-auto' : 'h-[350px]'}`}
|
||||
/>
|
||||
|
||||
@@ -454,7 +454,7 @@ const MindmapView = React.memo(({ data }) => {
|
||||
return (
|
||||
<div className={`w-full border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 ${
|
||||
isFullscreen
|
||||
? 'fixed inset-0 z-50 rounded-none'
|
||||
? 'fixed inset-0 z-[99999] rounded-none'
|
||||
: 'h-[600px]'
|
||||
}`}>
|
||||
<ReactFlow
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Plus, Minus, ChevronDown, ChevronRight, Type, Hash, ToggleLeft, List, Braces } from 'lucide-react';
|
||||
import { Plus, Minus, ChevronDown, ChevronRight, Type, Hash, ToggleLeft, List, Braces, Edit3, X } from 'lucide-react';
|
||||
|
||||
const StructuredEditor = ({ onDataChange, initialData = {} }) => {
|
||||
const [data, setData] = useState(initialData);
|
||||
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);
|
||||
|
||||
// Update internal data when initialData prop changes (but not from internal updates)
|
||||
useEffect(() => {
|
||||
@@ -28,6 +30,173 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
|
||||
onDataChange(newData);
|
||||
};
|
||||
|
||||
// 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') {
|
||||
return Number.isInteger(data) ? `i:${data};` : `d:${data};`;
|
||||
}
|
||||
if (typeof data === 'string') {
|
||||
const escapedData = data.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const byteLength = new TextEncoder().encode(escapedData).length;
|
||||
return `s:${byteLength}:"${escapedData}";`;
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
let result = `a:${data.length}:{`;
|
||||
data.forEach((item, index) => {
|
||||
result += phpSerialize(index) + phpSerialize(item);
|
||||
});
|
||||
result += '}';
|
||||
return result;
|
||||
}
|
||||
if (typeof data === 'object') {
|
||||
const keys = Object.keys(data);
|
||||
let result = `a:${keys.length}:{`;
|
||||
keys.forEach(key => {
|
||||
result += phpSerialize(key) + phpSerialize(data[key]);
|
||||
});
|
||||
result += '}';
|
||||
return result;
|
||||
}
|
||||
return 'N;';
|
||||
};
|
||||
|
||||
const phpUnserialize = (str) => {
|
||||
let index = 0;
|
||||
const parseValue = () => {
|
||||
if (index >= str.length) throw new Error('Unexpected end of string');
|
||||
const type = str[index];
|
||||
if (type === 'N') {
|
||||
index += 2;
|
||||
return null;
|
||||
}
|
||||
if (str[index + 1] !== ':') throw new Error(`Expected ':' after type '${type}'`);
|
||||
index += 2;
|
||||
switch (type) {
|
||||
case 'b':
|
||||
const boolVal = str[index] === '1';
|
||||
index += 2;
|
||||
return boolVal;
|
||||
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++];
|
||||
index++;
|
||||
return parseFloat(floatStr);
|
||||
case 's':
|
||||
let lenStr = '';
|
||||
while (index < str.length && str[index] !== ':') lenStr += str[index++];
|
||||
index++;
|
||||
if (str[index] !== '"') throw new Error('Expected opening quote');
|
||||
index++;
|
||||
const byteLength = parseInt(lenStr);
|
||||
if (byteLength === 0) {
|
||||
index += 2;
|
||||
return '';
|
||||
}
|
||||
let endQuotePos = -1;
|
||||
for (let i = index; i < str.length - 1; i++) {
|
||||
if (str[i] === '"' && str[i + 1] === ';') {
|
||||
endQuotePos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
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++];
|
||||
const count = parseInt(countStr);
|
||||
index += 2;
|
||||
const result = {};
|
||||
let isArray = true;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const key = parseValue();
|
||||
const value = parseValue();
|
||||
result[key] = value;
|
||||
if (key !== i) isArray = false;
|
||||
}
|
||||
index++;
|
||||
return isArray ? Object.values(result) : result;
|
||||
default:
|
||||
throw new Error(`Unknown type: ${type}`);
|
||||
}
|
||||
};
|
||||
return parseValue();
|
||||
};
|
||||
|
||||
// Detect if a string contains JSON or serialized data
|
||||
const detectNestedData = (value) => {
|
||||
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 };
|
||||
}
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not serialized
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Open nested editor modal
|
||||
const openNestedEditor = (value, path) => {
|
||||
const detected = detectNestedData(value);
|
||||
if (detected) {
|
||||
setNestedEditModal({ path, value, type: detected.type });
|
||||
setNestedData(detected.data);
|
||||
}
|
||||
};
|
||||
|
||||
// Save nested editor changes
|
||||
const saveNestedEdit = () => {
|
||||
if (!nestedEditModal || !nestedData) return;
|
||||
|
||||
// Convert back to string based on type
|
||||
let stringValue;
|
||||
if (nestedEditModal.type === 'json') {
|
||||
stringValue = JSON.stringify(nestedData);
|
||||
} 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);
|
||||
};
|
||||
|
||||
// Close nested editor modal
|
||||
const closeNestedEditor = () => {
|
||||
setNestedEditModal(null);
|
||||
setNestedData(null);
|
||||
};
|
||||
|
||||
const toggleNode = (path) => {
|
||||
const newExpanded = new Set(expandedNodes);
|
||||
if (newExpanded.has(path)) {
|
||||
@@ -431,23 +600,34 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
(fieldTypes[path] === 'longtext' || (typeof value === 'string' && value.includes('\n'))) ? (
|
||||
<textarea
|
||||
value={getDisplayValue(value)}
|
||||
onChange={(e) => updateValue(e.target.value, path)}
|
||||
className="flex-1 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 min-w-0 resize-y items"
|
||||
placeholder="Long text value"
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={getDisplayValue(value)}
|
||||
onChange={(e) => updateValue(e.target.value, path)}
|
||||
className="flex-1 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 min-w-0"
|
||||
placeholder="Value"
|
||||
/>
|
||||
)
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{(fieldTypes[path] === 'longtext' || (typeof value === 'string' && value.includes('\n'))) ? (
|
||||
<textarea
|
||||
value={getDisplayValue(value)}
|
||||
onChange={(e) => updateValue(e.target.value, path)}
|
||||
className="flex-1 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 min-w-0 resize-y items"
|
||||
placeholder="Long text value"
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={getDisplayValue(value)}
|
||||
onChange={(e) => updateValue(e.target.value, path)}
|
||||
className="flex-1 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 min-w-0"
|
||||
placeholder="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"
|
||||
title={`Edit nested ${detectNestedData(value).type} data`}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<span className="flex-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -552,6 +732,55 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
|
||||
<span>Add Property</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nested Data Editor Modal */}
|
||||
{nestedEditModal && nestedData && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[99999] p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<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
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Changes will be saved back as a {nestedEditModal.type === 'json' ? 'JSON' : 'serialized'} string
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeNestedEditor}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body - Nested Editor */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<StructuredEditor
|
||||
initialData={nestedData}
|
||||
onDataChange={setNestedData}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/20 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={closeNestedEditor}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={saveNestedEdit}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 rounded-md transition-colors"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user