- Enhanced JSON parsing with smart error handling for trailing commas - Fixed StructuredEditor array handling - array indices now non-editable - Improved PostmanTable styling: removed border radius, solid thead background - Enhanced icon spacing and alignment for better visual hierarchy - Added copy feedback system with green check icons (2500ms duration) - Restructured MindmapView node layout with dedicated action column - Added HTML rendering toggle for PostmanTable similar to MindmapView - Implemented consistent copy functionality across components - Removed debug console.log statements for cleaner codebase
468 lines
17 KiB
JavaScript
468 lines
17 KiB
JavaScript
import React, { useState } from 'react';
|
|
import { ChevronLeft, Braces, List, Type, Hash, ToggleLeft, Minus, Eye, Code, Copy, Check } from 'lucide-react';
|
|
|
|
const PostmanTable = ({ data, title = "JSON Data" }) => {
|
|
const [currentPath, setCurrentPath] = useState([]);
|
|
const [renderHtml, setRenderHtml] = useState(true);
|
|
const [copiedItems, setCopiedItems] = useState(new Set());
|
|
|
|
// Get current data based on path
|
|
const getCurrentData = () => {
|
|
let current = data;
|
|
for (const pathSegment of currentPath) {
|
|
if (current && typeof current === 'object') {
|
|
current = current[pathSegment];
|
|
}
|
|
}
|
|
return current;
|
|
};
|
|
|
|
const currentData = getCurrentData();
|
|
|
|
// Check if current data is an array for horizontal table display
|
|
const isArrayView = Array.isArray(currentData);
|
|
const isObjectView = currentData && typeof currentData === 'object' && !Array.isArray(currentData);
|
|
|
|
// Generate table headers for array view
|
|
const getArrayHeaders = () => {
|
|
if (!isArrayView || currentData.length === 0) return [];
|
|
|
|
// Check if array contains objects or primitives
|
|
const firstItem = currentData[0];
|
|
if (firstItem && typeof firstItem === 'object' && !Array.isArray(firstItem)) {
|
|
// Array of objects - get all possible keys
|
|
const headers = new Set();
|
|
currentData.forEach(item => {
|
|
if (item && typeof item === 'object') {
|
|
Object.keys(item).forEach(key => headers.add(key));
|
|
}
|
|
});
|
|
return Array.from(headers);
|
|
} else {
|
|
// Array of primitives - use "Value" as header
|
|
return ['Value'];
|
|
}
|
|
};
|
|
|
|
const headers = getArrayHeaders();
|
|
|
|
// Check if value contains HTML
|
|
const isHtmlContent = (value) => {
|
|
return value && typeof value === 'string' &&
|
|
(value.includes('<') && value.includes('>')) &&
|
|
/<[^>]+>/.test(value);
|
|
};
|
|
|
|
// Copy value to clipboard
|
|
const copyToClipboard = async (value, itemId) => {
|
|
try {
|
|
const textValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
await navigator.clipboard.writeText(textValue);
|
|
|
|
// Show feedback
|
|
setCopiedItems(prev => new Set([...prev, itemId]));
|
|
setTimeout(() => {
|
|
setCopiedItems(prev => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(itemId);
|
|
return newSet;
|
|
});
|
|
}, 2500);
|
|
} catch (err) {
|
|
// Fallback for older browsers
|
|
const textArea = document.createElement('textarea');
|
|
textArea.value = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
document.body.appendChild(textArea);
|
|
textArea.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(textArea);
|
|
|
|
// Show feedback for fallback too
|
|
setCopiedItems(prev => new Set([...prev, itemId]));
|
|
setTimeout(() => {
|
|
setCopiedItems(prev => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(itemId);
|
|
return newSet;
|
|
});
|
|
}, 2500);
|
|
}
|
|
};
|
|
|
|
// Handle row click - navigate to item details
|
|
const handleRowClick = (index, key = null) => {
|
|
if (isArrayView) {
|
|
setCurrentPath([...currentPath, index]);
|
|
} else if (isObjectView && key) {
|
|
// Navigate into object property
|
|
setCurrentPath([...currentPath, key]);
|
|
}
|
|
};
|
|
|
|
// Handle back navigation
|
|
const handleBack = () => {
|
|
if (currentPath.length > 0) {
|
|
const newPath = currentPath.slice(0, -1);
|
|
setCurrentPath(newPath);
|
|
}
|
|
};
|
|
|
|
// Handle breadcrumb navigation
|
|
const handleBreadcrumbClick = (index) => {
|
|
if (index === 0) {
|
|
// Click on "Root" - go to root
|
|
setCurrentPath([]);
|
|
} else {
|
|
// Click on specific breadcrumb - navigate to that level
|
|
const newPath = currentPath.slice(0, index);
|
|
setCurrentPath(newPath);
|
|
}
|
|
};
|
|
|
|
// Generate breadcrumb
|
|
const getBreadcrumb = () => {
|
|
const parts = ['Root'];
|
|
currentPath.forEach((segment, index) => {
|
|
if (typeof segment === 'number') {
|
|
parts.push(segment.toString());
|
|
} else {
|
|
parts.push(segment);
|
|
}
|
|
});
|
|
return parts;
|
|
};
|
|
|
|
// Format value for display in table (truncated)
|
|
const formatValue = (value) => {
|
|
if (value === null) return 'null';
|
|
if (value === undefined) return 'undefined';
|
|
if (typeof value === 'string') {
|
|
// Truncate long strings for table display
|
|
if (value.length > 100) {
|
|
return value.substring(0, 100) + '...';
|
|
}
|
|
// Replace newlines with spaces for single-line display
|
|
return value.replace(/\n/g, ' ').replace(/\s+/g, ' ');
|
|
}
|
|
if (typeof value === 'boolean') return value.toString();
|
|
if (typeof value === 'number') return value.toString();
|
|
if (typeof value === 'object') {
|
|
if (Array.isArray(value)) return `Array(${value.length})`;
|
|
return `Object(${Object.keys(value).length})`;
|
|
}
|
|
return String(value);
|
|
};
|
|
|
|
// Format value for display in details view (full text)
|
|
const formatFullValue = (value) => {
|
|
if (value === null) return 'null';
|
|
if (value === undefined) return 'undefined';
|
|
if (typeof value === 'string') return value; // Show full string without truncation
|
|
if (typeof value === 'boolean') return value.toString();
|
|
if (typeof value === 'number') return value.toString();
|
|
if (typeof value === 'object') {
|
|
if (Array.isArray(value)) return `Array(${value.length})`;
|
|
return `Object(${Object.keys(value).length})`;
|
|
}
|
|
return String(value);
|
|
};
|
|
|
|
// Get type-specific styling and icon
|
|
const getTypeStyle = (value) => {
|
|
if (value === null) {
|
|
return {
|
|
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-300',
|
|
icon: <Minus className="h-3 w-3" />,
|
|
type: 'null'
|
|
};
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return {
|
|
color: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300',
|
|
icon: <List className="h-3 w-3" />,
|
|
type: 'array'
|
|
};
|
|
}
|
|
|
|
switch (typeof value) {
|
|
case 'object':
|
|
return {
|
|
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300',
|
|
icon: <Braces className="h-3 w-3" />,
|
|
type: 'object'
|
|
};
|
|
case 'string':
|
|
return {
|
|
color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-300',
|
|
icon: <Type className="h-3 w-3" />,
|
|
type: 'string'
|
|
};
|
|
case 'number':
|
|
return {
|
|
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-300',
|
|
icon: <Hash className="h-3 w-3" />,
|
|
type: 'number'
|
|
};
|
|
case 'boolean':
|
|
return {
|
|
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300',
|
|
icon: <ToggleLeft className="h-3 w-3" />,
|
|
type: 'boolean'
|
|
};
|
|
default:
|
|
return {
|
|
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-300',
|
|
icon: <Minus className="h-3 w-3" />,
|
|
type: 'unknown'
|
|
};
|
|
}
|
|
};
|
|
|
|
|
|
// Render value with appropriate styling (for table view)
|
|
const renderValue = (value) => {
|
|
const typeStyle = getTypeStyle(value);
|
|
const formattedValue = formatValue(value);
|
|
|
|
return (
|
|
<span className={`inline-flex items-center space-x-1 px-2 py-1 rounded text-xs font-medium ${typeStyle.color}`}>
|
|
{typeStyle.icon}
|
|
<span>{formattedValue}</span>
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// Render value with full text (for detail view)
|
|
const renderFullValue = (value) => {
|
|
const typeStyle = getTypeStyle(value);
|
|
const formattedValue = formatFullValue(value);
|
|
const hasHtml = isHtmlContent(value);
|
|
|
|
return (
|
|
<div className="relative">
|
|
<span className={`inline-flex items-start space-x-2 px-2 py-1 rounded text-xs font-medium ${typeStyle.color}`}>
|
|
<span className="flex-shrink-0 mt-0.5">{typeStyle.icon}</span>
|
|
<span className="whitespace-pre-wrap break-words flex-1">
|
|
{hasHtml && renderHtml ? (
|
|
<div dangerouslySetInnerHTML={{ __html: String(value) }} />
|
|
) : (
|
|
formattedValue
|
|
)}
|
|
</span>
|
|
</span>
|
|
|
|
{/* HTML Toggle Buttons */}
|
|
{hasHtml && (
|
|
<div className="absolute -top-1 -right-1 flex">
|
|
<button
|
|
onClick={() => setRenderHtml(true)}
|
|
className={`px-1.5 py-0.5 text-xs rounded-l ${
|
|
renderHtml
|
|
? 'bg-blue-500 text-white'
|
|
: 'bg-gray-200 text-gray-600 hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-300 dark:hover:bg-gray-500'
|
|
}`}
|
|
title="Render HTML"
|
|
>
|
|
<Eye className="h-3 w-3" />
|
|
</button>
|
|
<button
|
|
onClick={() => setRenderHtml(false)}
|
|
className={`px-1.5 py-0.5 text-xs rounded-r ${
|
|
!renderHtml
|
|
? 'bg-blue-500 text-white'
|
|
: 'bg-gray-200 text-gray-600 hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-300 dark:hover:bg-gray-500'
|
|
}`}
|
|
title="Show Raw HTML"
|
|
>
|
|
<Code className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Get value type
|
|
const getValueType = (value) => {
|
|
if (value === null) return 'null';
|
|
if (Array.isArray(value)) return 'array';
|
|
return typeof value;
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
{/* Header with Breadcrumb */}
|
|
<div className="bg-gray-50 dark:bg-gray-700 px-4 py-3 border-b border-gray-200 dark:border-gray-600">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-2">
|
|
{currentPath.length > 0 && (
|
|
<button
|
|
onClick={handleBack}
|
|
className="flex items-center space-x-1 text-blue-600 hover:text-blue-800 transition-colors"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
<span className="text-sm">Back</span>
|
|
</button>
|
|
)}
|
|
<div className="flex items-center space-x-1 text-sm">
|
|
{getBreadcrumb().map((part, index) => (
|
|
<React.Fragment key={index}>
|
|
{index > 0 && <span className="text-gray-400 dark:text-gray-500">/</span>}
|
|
<button
|
|
onClick={() => handleBreadcrumbClick(index)}
|
|
className={`px-2 py-1 rounded transition-colors ${
|
|
index === getBreadcrumb().length - 1
|
|
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 font-semibold cursor-default'
|
|
: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
|
}`}
|
|
disabled={index === getBreadcrumb().length - 1}
|
|
>
|
|
{part}
|
|
</button>
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
{isArrayView && `${currentData.length} items`}
|
|
{isObjectView && `${Object.keys(currentData).length} properties`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="overflow-auto max-h-96">
|
|
{isArrayView ? (
|
|
// Horizontal table for arrays
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 dark:bg-gray-700 sticky top-0 z-10">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider w-12">
|
|
#
|
|
</th>
|
|
{headers.map(header => (
|
|
<th key={header} className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
{header}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
{currentData.map((item, index) => {
|
|
const isPrimitiveArray = headers.length === 1 && headers[0] === 'Value';
|
|
return (
|
|
<tr
|
|
key={index}
|
|
onClick={() => handleRowClick(index)}
|
|
className="hover:bg-blue-50 dark:hover:bg-blue-900/20 cursor-pointer transition-colors duration-150"
|
|
>
|
|
<td className="px-4 py-3 text-sm font-mono text-gray-500 dark:text-gray-400">
|
|
{index}
|
|
</td>
|
|
{isPrimitiveArray ? (
|
|
<td className="px-4 py-3 text-sm">
|
|
{renderValue(item)}
|
|
</td>
|
|
) : (
|
|
headers.map(header => (
|
|
<td key={header} className="px-4 py-3 text-sm">
|
|
{renderValue(item?.[header])}
|
|
</td>
|
|
))
|
|
)}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
) : isObjectView ? (
|
|
// Vertical key-value table for objects
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 dark:bg-gray-700 sticky top-0 z-10">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider w-1/3">
|
|
Key
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Value
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider w-16">
|
|
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
{Object.entries(currentData).map(([key, value]) => {
|
|
const isClickable = typeof value === 'object' && value !== null;
|
|
return (
|
|
<tr
|
|
key={key}
|
|
onClick={() => isClickable && handleRowClick(null, key)}
|
|
className={`transition-colors duration-150 ${
|
|
isClickable
|
|
? 'hover:bg-blue-50 dark:hover:bg-blue-900/20 cursor-pointer'
|
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
<td className="px-4 py-3 text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
|
|
{key}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm">
|
|
{renderFullValue(value)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation(); // Prevent row click
|
|
copyToClipboard(value, `${currentPath.join('.')}.${key}`);
|
|
}}
|
|
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
|
|
title={copiedItems.has(`${currentPath.join('.')}.${key}`) ? "Copied!" : "Copy value"}
|
|
>
|
|
{copiedItems.has(`${currentPath.join('.')}.${key}`) ? (
|
|
<Check className="h-3 w-3 text-green-500" />
|
|
) : (
|
|
<Copy className="h-3 w-3 text-gray-500 dark:text-gray-400" />
|
|
)}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
) : (
|
|
// Fallback for primitive values
|
|
<div className="p-4">
|
|
<div className="text-center text-gray-500 dark:text-gray-400">
|
|
<div className="text-lg font-mono text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words">
|
|
{formatFullValue(currentData)}
|
|
</div>
|
|
<div className="text-sm mt-2">
|
|
Type: {getValueType(currentData)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="bg-gray-50 dark:bg-gray-700 px-4 py-2 border-t border-gray-200 dark:border-gray-600">
|
|
<div className="flex items-center justify-between text-sm text-gray-600 dark:text-gray-300">
|
|
<span>
|
|
{isArrayView ? `Array with ${currentData.length} items` :
|
|
isObjectView ? `Object with ${Object.keys(currentData).length} properties` :
|
|
`Primitive value (${getValueType(currentData)})`}
|
|
</span>
|
|
<span>
|
|
Path: {currentPath.length === 0 ? 'Root' : currentPath.join(' → ')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PostmanTable;
|