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:
dwindown
2025-10-15 00:12:54 +07:00
parent 14a07a6cba
commit f60c1d16c8
11 changed files with 3222 additions and 254 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Plus, Upload, FileText, Globe, Download, X, Table, Trash2, Database, Braces, Code, Eye, Minimize2, Maximize2, Search, ArrowUpDown, AlertTriangle, Edit3 } from 'lucide-react';
import { Plus, Upload, FileText, Globe, Download, X, Table, Trash2, Database, Braces, Code, Eye, Minimize2, Maximize2, Search, ArrowUpDown, AlertTriangle, Edit3, ChevronUp, ChevronDown } from 'lucide-react';
import ToolLayout from '../components/ToolLayout';
import CodeEditor from '../components/CodeEditor';
import CodeMirrorEditor from '../components/CodeMirrorEditor';
@@ -67,6 +67,10 @@ const TableEditor = () => {
const [showInputChangeModal, setShowInputChangeModal] = useState(false); // For input method change confirmation
const [pendingTabChange, setPendingTabChange] = useState(null); // Store pending tab change
const [createNewCompleted, setCreateNewCompleted] = useState(false); // Track if user completed Create New step
const [pasteCollapsed, setPasteCollapsed] = useState(false); // Track if paste input is collapsed
const [pasteDataSummary, setPasteDataSummary] = useState(null); // Summary of pasted data
const [exportExpanded, setExportExpanded] = useState(false); // Track if export section is expanded
const [usageTipsExpanded, setUsageTipsExpanded] = useState(false); // Track if usage tips is expanded
// SQL Export specific state
const [sqlTableName, setSqlTableName] = useState(""); // Table name for SQL export
@@ -977,24 +981,50 @@ const TableEditor = () => {
const handleTextInput = () => {
if (!inputText.trim()) {
setError("Please enter some data");
setPasteCollapsed(false);
return;
}
const trimmed = inputText.trim();
let format = '';
let success = false;
// Try to detect format
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
// JSON array
parseJsonData(trimmed);
} else if (
trimmed.toLowerCase().includes("insert into") &&
trimmed.toLowerCase().includes("values")
) {
// SQL INSERT statements
parseSqlData(trimmed);
} else {
// CSV/TSV
parseData(trimmed, useFirstRowAsHeader);
try {
// Try to detect format
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
// JSON array
parseJsonData(trimmed);
format = 'JSON';
success = true;
} else if (
trimmed.toLowerCase().includes("insert into") &&
trimmed.toLowerCase().includes("values")
) {
// SQL INSERT statements
parseSqlData(trimmed);
format = 'SQL';
success = true;
} else {
// CSV/TSV
parseData(trimmed, useFirstRowAsHeader);
format = trimmed.includes('\t') ? 'TSV' : 'CSV';
success = true;
}
// If successful, collapse input and show summary
if (success && data.length > 0) {
setPasteDataSummary({
format: format,
size: inputText.length,
rows: data.length
});
setPasteCollapsed(true);
setError('');
}
} catch (err) {
// Keep input expanded on error
setPasteCollapsed(false);
setError(err.message || 'Failed to parse data');
}
};
@@ -1793,7 +1823,7 @@ const TableEditor = () => {
icon={Table}
>
{/* Input Section with Tabs */}
<div className="w-full bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mb-4 sm:mb-6">
<div className="w-full bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Tabs */}
<div className="flex border-b border-gray-200 dark:border-gray-700 overflow-x-auto scrollbar-hide">
<div className="flex min-w-max">
@@ -2006,34 +2036,59 @@ const TableEditor = () => {
)}
{activeTab === "paste" && (
<div className="space-y-3">
<CodeMirrorEditor
value={inputText}
onChange={setInputText}
language="json"
placeholder="Paste CSV, TSV, JSON, or SQL INSERT statements here..."
maxLines={12}
showToggle={true}
className="w-full"
/>
<div className="flex items-center justify-between">
<label className="flex items-center text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={useFirstRowAsHeader}
onChange={(e) => setUseFirstRowAsHeader(e.target.checked)}
className="mr-2"
/>
Use first row as column headers (for CSV/TSV)
</label>
<button
onClick={handleTextInput}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-4 py-2 rounded-md transition-colors"
>
Parse Data
</button>
pasteCollapsed ? (
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-green-700 dark:text-green-300">
✓ Data loaded: {pasteDataSummary.format} ({pasteDataSummary.size.toLocaleString()} chars, {pasteDataSummary.rows} rows)
</span>
<button
onClick={() => setPasteCollapsed(false)}
className="text-sm text-blue-600 hover:underline"
>
Edit Input ▼
</button>
</div>
</div>
</div>
) : (
<div className="space-y-3">
<div>
<CodeMirrorEditor
value={inputText}
onChange={setInputText}
language="json"
placeholder="Paste CSV, TSV, JSON, or SQL INSERT statements here..."
maxLines={12}
showToggle={true}
className="w-full"
/>
</div>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-600 dark:text-red-400">
<strong>Invalid Data:</strong> {error}
</p>
</div>
)}
<div className="flex items-center justify-between flex-shrink-0">
<label className="flex items-center text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={useFirstRowAsHeader}
onChange={(e) => setUseFirstRowAsHeader(e.target.checked)}
className="mr-2"
/>
Use first row as column headers (for CSV/TSV)
</label>
<button
onClick={handleTextInput}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-4 py-2 rounded-md transition-colors flex-shrink-0"
>
Parse Data
</button>
</div>
</div>
)
)}
{activeTab === "upload" && (
@@ -2070,9 +2125,10 @@ const TableEditor = () => {
<div
className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 min-w-0 ${
isTableFullscreen
? "fixed inset-0 z-50 rounded-none border-0 shadow-none overflow-hidden"
: "overflow-x-auto"
? "fixed inset-0 z-[99999] rounded-none border-0 shadow-none overflow-hidden !m-0"
: "overflow-x-auto mt-4 sm:mt-6"
}`}
style={isTableFullscreen ? { marginTop: "0 !important" } : {}}
>
{/* Header */}
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
@@ -2398,7 +2454,7 @@ const TableEditor = () => {
return (
<td
key={column.id}
className={`px-4 py-3 text-sm text-gray-900 dark:text-gray-100 border-r border-gray-200 dark:border-gray-600 break-words ${
className={`px-4 py-3 text-sm text-gray-900 dark:text-gray-100 border-r border-gray-200 dark:border-gray-600 break-words overflow-hidden ${
isFrozen
? "sticky z-10 bg-blue-50 dark:!bg-blue-900"
: ""
@@ -2500,7 +2556,7 @@ const TableEditor = () => {
// For regular text, show normal cell
return (
<div
className="cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 p-2 rounded min-h-[32px] flex items-center"
className="cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 p-2 rounded min-h-[32px] flex items-center overflow-hidden"
onClick={() =>
setEditingCell({
rowId: row.id,
@@ -2511,11 +2567,13 @@ const TableEditor = () => {
isLongValue ? cellValue : undefined
}
>
{cellValue || (
<span className="text-gray-400 dark:text-gray-500 italic text-sm">
Click to edit
</span>
)}
<span className="truncate block w-full">
{cellValue || (
<span className="text-gray-400 dark:text-gray-500 italic text-sm">
Click to edit
</span>
)}
</span>
</div>
);
}
@@ -2603,12 +2661,16 @@ const TableEditor = () => {
{/* Export Section */}
{data.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mt-6">
{/* Export Header */}
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
{/* Export Header - Collapsible */}
<div
onClick={() => setExportExpanded(!exportExpanded)}
className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Download className="h-5 w-5 text-green-600 dark:text-green-400" />
Export Results
{exportExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</h3>
<div className="text-sm text-gray-600 dark:text-gray-400">
{availableTables.length > 1 ? (
@@ -2625,6 +2687,10 @@ const TableEditor = () => {
</div>
</div>
</div>
{/* Export Content - Collapsible */}
{exportExpanded && (
<div>
{/* Export Tabs */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
@@ -3001,15 +3067,25 @@ const TableEditor = () => {
</div>
)}
</div>
</div>
)}
</div>
)}
{/* Usage Tips */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md p-4 mt-6">
<h4 className="text-blue-800 dark:text-blue-200 font-medium mb-3">
Usage Tips
</h4>
<div className="text-blue-700 dark:text-blue-300 text-sm space-y-2">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md overflow-hidden mt-6">
<div
onClick={() => setUsageTipsExpanded(!usageTipsExpanded)}
className="px-4 py-3 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors flex items-center justify-between"
>
<h4 className="text-blue-800 dark:text-blue-200 font-medium flex items-center gap-2">
💡 Usage Tips
</h4>
{usageTipsExpanded ? <ChevronUp className="h-4 w-4 text-blue-600 dark:text-blue-400" /> : <ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
</div>
{usageTipsExpanded && (
<div className="px-4 pb-4 text-blue-700 dark:text-blue-300 text-sm space-y-2">
<div>
<p className="font-medium mb-1">📝 Input Methods:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
@@ -3097,6 +3173,7 @@ const TableEditor = () => {
</ul>
</div>
</div>
)}
</div>
</ToolLayout>
);
@@ -3197,6 +3274,77 @@ const ClearConfirmationModal = ({
// Object Editor Modal Component
const ObjectEditorModal = ({ modal, onClose, onApply }) => {
// Initialize with parsed data immediately
// PHP unserialize function (same as StructuredEditor)
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();
};
const initializeData = () => {
try {
let data = modal.originalValue;
@@ -3207,12 +3355,25 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
}
if (modal.format.type === "php_serialized") {
return {
structuredData: {},
currentValue: modal.originalValue,
isValid: true,
error: "",
};
try {
console.log('Attempting to parse PHP serialized:', modal.originalValue);
const parsed = phpUnserialize(modal.originalValue);
console.log('Parsed result:', parsed);
return {
structuredData: parsed,
currentValue: modal.originalValue,
isValid: true,
error: "",
};
} catch (err) {
console.error('PHP unserialize error:', err);
return {
structuredData: {},
currentValue: modal.originalValue,
isValid: false,
error: err.message,
};
}
}
const parsed = JSON.parse(data);
@@ -3256,13 +3417,56 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
const [error, setError] = useState(initialState.error);
// Debug log to see what we initialized with
console.log('Modal initialized with:', {
structuredData,
isValid,
error,
format: modal.format.type
});
// PHP serialize function
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;';
};
// Update current value when structured data changes
const handleStructuredDataChange = (newData) => {
setStructuredData(newData);
try {
const jsonString = JSON.stringify(newData, null, 2);
setCurrentValue(jsonString);
if (modal.format.type === "php_serialized") {
const serialized = phpSerialize(newData);
setCurrentValue(serialized);
} else {
const jsonString = JSON.stringify(newData, null, 2);
setCurrentValue(jsonString);
}
setIsValid(true);
setError("");
} catch (err) {
@@ -3283,8 +3487,15 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
}
if (modal.format.type === "php_serialized") {
setIsValid(true);
setError("");
try {
const parsed = phpUnserialize(newValue);
setStructuredData(parsed);
setIsValid(true);
setError("");
} catch (err) {
setIsValid(false);
setError(err.message);
}
return;
}
@@ -3331,16 +3542,6 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
);
}
if (modal.format.type === "php_serialized") {
return (
<div className="h-full flex items-center justify-center text-gray-500 dark:text-gray-400 p-6">
<div className="text-center">
<Code className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>PHP Serialized data - use Raw Editor to modify</p>
</div>
</div>
);
}
return (
<div className="h-full bg-white dark:bg-gray-800 p-6">
@@ -3354,8 +3555,8 @@ const ObjectEditorModal = ({ modal, onClose, onApply }) => {
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
style={{ minHeight: "100vh" }}
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[99999] p-4 !m-0"
style={{ minHeight: "100vh", marginTop: "0 !important" }}
>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}