🎯 Complete Postman-Style Table View with Consistent Design
✨ New Features: - Authentic Postman table experience with horizontal arrays & vertical objects - Click rows to drill down with breadcrumb navigation - Smart data detection (arrays → tables, objects → key-value, primitives → display) - Clickable breadcrumb buttons for easy navigation - Back button for seamless exploration 🎨 Consistent Visual Design: - Unified type notation across Visual Editor, Mindmap, and Table views - Color-coded pills with icons for all data types: 🔵 Objects (blue) 🟢 Arrays (green) 🟣 Strings (purple) 🟠 Numbers (orange) 🟡 Booleans (yellow) ⚫ Null (gray) - Button-style breadcrumb vs circular data pills for clear distinction - Full dark mode support throughout 🔧 UX Improvements: - Output formats hidden by default with 'See Data Outputs' toggle - Left-aligned toggle button matching existing UI - Preserved PostmanTreeTable as asset for hierarchical view - Enhanced sample data with realistic array/object structure - Professional styling matching Postman's interface 🚀 Technical Implementation: - Dynamic column generation from array data - Path-based navigation with state management - Type-aware rendering with consistent icons - Responsive design for all screen sizes - Clean component architecture
This commit is contained in:
273
src/components/PostmanTreeTable.js
Normal file
273
src/components/PostmanTreeTable.js
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ChevronRight, ChevronDown, Copy, Search, Filter } from 'lucide-react';
|
||||
import CopyButton from './CopyButton';
|
||||
|
||||
const PostmanTreeTable = ({ data, title = "JSON Data" }) => {
|
||||
const [expandedPaths, setExpandedPaths] = useState(new Set());
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterType, setFilterType] = useState('all');
|
||||
|
||||
// Flatten the data structure for table display
|
||||
const flattenData = (obj, path = '', level = 0) => {
|
||||
const result = [];
|
||||
|
||||
if (obj === null || obj === undefined) {
|
||||
return [{
|
||||
key: path || 'root',
|
||||
value: obj,
|
||||
type: obj === null ? 'null' : 'undefined',
|
||||
level,
|
||||
path,
|
||||
hasChildren: false
|
||||
}];
|
||||
}
|
||||
|
||||
if (typeof obj !== 'object') {
|
||||
return [{
|
||||
key: path || 'root',
|
||||
value: obj,
|
||||
type: typeof obj,
|
||||
level,
|
||||
path,
|
||||
hasChildren: false
|
||||
}];
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
const arrayItem = {
|
||||
key: path || 'root',
|
||||
value: `Array(${obj.length})`,
|
||||
type: 'array',
|
||||
level,
|
||||
path,
|
||||
hasChildren: obj.length > 0,
|
||||
isExpandable: true
|
||||
};
|
||||
result.push(arrayItem);
|
||||
|
||||
if (expandedPaths.has(path)) {
|
||||
obj.forEach((item, index) => {
|
||||
const itemPath = path ? `${path}[${index}]` : `[${index}]`;
|
||||
result.push(...flattenData(item, itemPath, level + 1));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const keys = Object.keys(obj);
|
||||
const objectItem = {
|
||||
key: path || 'root',
|
||||
value: `Object(${keys.length})`,
|
||||
type: 'object',
|
||||
level,
|
||||
path,
|
||||
hasChildren: keys.length > 0,
|
||||
isExpandable: true
|
||||
};
|
||||
|
||||
if (path) result.push(objectItem);
|
||||
|
||||
if (!path || expandedPaths.has(path)) {
|
||||
keys.forEach(key => {
|
||||
const keyPath = path ? `${path}.${key}` : key;
|
||||
result.push(...flattenData(obj[key], keyPath, path ? level + 1 : level));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const toggleExpanded = (path) => {
|
||||
const newExpanded = new Set(expandedPaths);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedPaths(newExpanded);
|
||||
};
|
||||
|
||||
const flatData = useMemo(() => flattenData(data), [data, expandedPaths]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return flatData.filter(item => {
|
||||
// Search filter
|
||||
if (searchTerm) {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const keyMatch = item.key.toLowerCase().includes(searchLower);
|
||||
const valueMatch = String(item.value).toLowerCase().includes(searchLower);
|
||||
if (!keyMatch && !valueMatch) return false;
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (filterType !== 'all' && item.type !== filterType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [flatData, searchTerm, filterType]);
|
||||
|
||||
const getTypeColor = (type) => {
|
||||
const colors = {
|
||||
string: 'bg-green-100 text-green-800',
|
||||
number: 'bg-blue-100 text-blue-800',
|
||||
boolean: 'bg-purple-100 text-purple-800',
|
||||
object: 'bg-orange-100 text-orange-800',
|
||||
array: 'bg-red-100 text-red-800',
|
||||
null: 'bg-gray-100 text-gray-800',
|
||||
undefined: 'bg-gray-100 text-gray-800'
|
||||
};
|
||||
return colors[type] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const formatValue = (value, type) => {
|
||||
if (type === 'string') return `"${value}"`;
|
||||
if (type === 'null') return 'null';
|
||||
if (type === 'undefined') return 'undefined';
|
||||
if (type === 'boolean') return value ? 'true' : 'false';
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const getKeyDisplay = (key, level) => {
|
||||
const parts = key.split(/[.\[\]]+/).filter(Boolean);
|
||||
return parts[parts.length - 1] || key;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search keys or values..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type Filter */}
|
||||
<div className="relative">
|
||||
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
className="pl-10 pr-8 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent appearance-none bg-white"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="string">String</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Boolean</option>
|
||||
<option value="object">Object</option>
|
||||
<option value="array">Array</option>
|
||||
<option value="null">Null</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-auto max-h-96">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Key
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Value
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-16">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredData.map((item, index) => (
|
||||
<tr
|
||||
key={`${item.path}-${index}`}
|
||||
className="hover:bg-gray-50 transition-colors duration-150"
|
||||
>
|
||||
{/* Key Column */}
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div
|
||||
className="flex items-center"
|
||||
style={{ paddingLeft: `${item.level * 20}px` }}
|
||||
>
|
||||
{item.isExpandable ? (
|
||||
<button
|
||||
onClick={() => toggleExpanded(item.path)}
|
||||
className="mr-2 p-1 hover:bg-gray-200 rounded transition-colors"
|
||||
>
|
||||
{expandedPaths.has(item.path) ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-600" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6 mr-2" />
|
||||
)}
|
||||
<span className="font-mono text-gray-900">
|
||||
{getKeyDisplay(item.key, item.level)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Type Column */}
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getTypeColor(item.type)}`}>
|
||||
{item.type}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Value Column */}
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div className="font-mono text-gray-900 max-w-xs truncate">
|
||||
{formatValue(item.value, item.type)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Actions Column */}
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{!item.isExpandable && (
|
||||
<CopyButton
|
||||
text={String(item.value)}
|
||||
className="p-1 hover:bg-gray-200 rounded transition-colors"
|
||||
>
|
||||
<Copy className="w-4 h-4 text-gray-600" />
|
||||
</CopyButton>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-gray-50 px-4 py-2 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between text-sm text-gray-600">
|
||||
<span>
|
||||
Showing {filteredData.length} of {flatData.length} items
|
||||
</span>
|
||||
<span>
|
||||
{Object.keys(data || {}).length} root properties
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostmanTreeTable;
|
||||
Reference in New Issue
Block a user