mirror of https://github.com/kortix-ai/suna.git
fix: csv improvements
This commit is contained in:
parent
c18e674f6a
commit
9ae810683d
|
@ -4,14 +4,12 @@ import React, { useState, useMemo } from 'react';
|
|||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CsvTable } from '@/components/ui/csv-table';
|
||||
import Papa from 'papaparse';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Search,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
import {
|
||||
Search,
|
||||
FileSpreadsheet,
|
||||
ArrowUpDown,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
|
@ -49,8 +47,8 @@ function parseCSV(content: string) {
|
|||
headers = results.meta.fields || [];
|
||||
}
|
||||
|
||||
return {
|
||||
headers,
|
||||
return {
|
||||
headers,
|
||||
data: results.data,
|
||||
meta: results.meta
|
||||
};
|
||||
|
@ -68,7 +66,7 @@ export function CsvRenderer({
|
|||
const [sortConfig, setSortConfig] = useState<SortConfig>({ column: '', direction: null });
|
||||
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [rowsPerPage] = useState(50);
|
||||
const [rowsPerPage] = useState(50);
|
||||
|
||||
const parsedData = parseCSV(content);
|
||||
const isEmpty = parsedData.data.length === 0;
|
||||
|
@ -88,18 +86,18 @@ export function CsvRenderer({
|
|||
filtered = [...filtered].sort((a: any, b: any) => {
|
||||
const aVal = a[sortConfig.column];
|
||||
const bVal = b[sortConfig.column];
|
||||
|
||||
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (bVal == null) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
|
||||
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return sortConfig.direction === 'asc' ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
|
||||
|
||||
const aStr = String(aVal).toLowerCase();
|
||||
const bStr = String(bVal).toLowerCase();
|
||||
|
||||
|
||||
if (aStr < bStr) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aStr > bStr) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
|
@ -139,36 +137,6 @@ export function CsvRenderer({
|
|||
});
|
||||
};
|
||||
|
||||
const getSortIcon = (column: string) => {
|
||||
if (sortConfig.column !== column) {
|
||||
return <ArrowUpDown className="h-3 w-3 text-muted-foreground" />;
|
||||
}
|
||||
return sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="h-3 w-3 text-primary" /> :
|
||||
<ChevronDown className="h-3 w-3 text-primary" />;
|
||||
};
|
||||
|
||||
const formatCellValue = (value: any) => {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'number') {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Yes' : 'No';
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const getCellClassName = (value: any) => {
|
||||
if (typeof value === 'number') {
|
||||
return 'text-right font-mono';
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className={cn('w-full h-full flex items-center justify-center', className)}>
|
||||
|
@ -199,12 +167,12 @@ export function CsvRenderer({
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Page {currentPage} of {totalPages}
|
||||
</Badge>
|
||||
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
|
@ -245,73 +213,14 @@ export function CsvRenderer({
|
|||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="w-full h-full overflow-auto scrollbar-thin scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-700 scrollbar-track-transparent">
|
||||
<table className="w-full border-collapse table-fixed" style={{ minWidth: `${visibleHeaders.length * 150}px` }}>
|
||||
<thead className="bg-muted/50 sticky top-0 z-10">
|
||||
<tr>
|
||||
{visibleHeaders.map((header, index) => (
|
||||
<th
|
||||
key={header}
|
||||
className="px-4 py-3 text-left font-medium border-b border-border bg-muted/50 backdrop-blur-sm"
|
||||
style={{ width: '150px', minWidth: '150px' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleSort(header)}
|
||||
className="flex items-center gap-2 hover:text-primary transition-colors group w-full text-left"
|
||||
>
|
||||
<span className="truncate">{header}</span>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
{getSortIcon(header)}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedData.map((row: any, rowIndex) => (
|
||||
<tr
|
||||
key={startIndex + rowIndex}
|
||||
className="border-b border-border hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
{visibleHeaders.map((header, cellIndex) => {
|
||||
const value = row[header];
|
||||
return (
|
||||
<td
|
||||
key={`${startIndex + rowIndex}-${cellIndex}`}
|
||||
className={cn(
|
||||
"px-4 py-3 text-sm border-r border-border last:border-r-0",
|
||||
getCellClassName(value)
|
||||
)}
|
||||
style={{ width: '150px', minWidth: '150px' }}
|
||||
>
|
||||
<div className="truncate" title={String(value || '')}>
|
||||
{formatCellValue(value)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* Empty state for current page */}
|
||||
{paginatedData.length === 0 && searchTerm && (
|
||||
<tr>
|
||||
<td colSpan={visibleHeaders.length} className="py-8 text-center text-muted-foreground">
|
||||
<div className="space-y-2">
|
||||
<p>No results found for "{searchTerm}"</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSearchTerm('')}
|
||||
>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<CsvTable
|
||||
headers={visibleHeaders}
|
||||
data={paginatedData}
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
searchTerm={searchTerm}
|
||||
onClearSearch={() => setSearchTerm('')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -322,7 +231,7 @@ export function CsvRenderer({
|
|||
<div className="text-sm text-muted-foreground">
|
||||
Showing {startIndex + 1} to {Math.min(startIndex + rowsPerPage, processedData.length)} of {processedData.length.toLocaleString()} rows
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
@ -332,7 +241,7 @@ export function CsvRenderer({
|
|||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let pageNum;
|
||||
|
@ -345,7 +254,7 @@ export function CsvRenderer({
|
|||
} else {
|
||||
pageNum = currentPage - 2 + i;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
|
@ -359,7 +268,7 @@ export function CsvRenderer({
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
|
@ -553,16 +553,16 @@ export function FileAttachment({
|
|||
</div>
|
||||
|
||||
{/* Header with filename */}
|
||||
<div className="absolute top-0 left-0 right-0 bg-accent p-2 z-10 flex items-center justify-between">
|
||||
<div className="absolute top-0 left-0 right-0 bg-accent p-2 h-[40px] z-10 flex items-center justify-between">
|
||||
<div className="text-sm font-medium truncate">{filename}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
{/* <button
|
||||
onClick={handleDownload}
|
||||
className="cursor-pointer p-1 rounded-full hover:bg-black/10 dark:hover:bg-white/10"
|
||||
title="Download file"
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</button> */}
|
||||
{onClick && (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import React, { useState } from 'react';
|
||||
import { CsvTable } from '@/components/ui/csv-table';
|
||||
import Papa from 'papaparse';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
@ -35,49 +35,75 @@ function parseCSV(content: string) {
|
|||
}
|
||||
|
||||
/**
|
||||
* CSV/TSV renderer that presents data in a table format
|
||||
* Minimal CSV renderer for thread previews using the CsvTable component
|
||||
*/
|
||||
export function CsvRenderer({
|
||||
content,
|
||||
className
|
||||
}: CsvRendererProps) {
|
||||
const [sortConfig, setSortConfig] = useState<{ column: string; direction: 'asc' | 'desc' | null }>({
|
||||
column: '',
|
||||
direction: null
|
||||
});
|
||||
|
||||
const parsedData = parseCSV(content);
|
||||
const isEmpty = parsedData.data.length === 0;
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
setSortConfig(prev => {
|
||||
if (prev.column === column) {
|
||||
const newDirection = prev.direction === 'asc' ? 'desc' : prev.direction === 'desc' ? null : 'asc';
|
||||
return { column: newDirection ? column : '', direction: newDirection };
|
||||
} else {
|
||||
return { column, direction: 'asc' };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Sort the data based on sortConfig
|
||||
const sortedData = React.useMemo(() => {
|
||||
if (!sortConfig.column || !sortConfig.direction) {
|
||||
return parsedData.data;
|
||||
}
|
||||
|
||||
return [...parsedData.data].sort((a: any, b: any) => {
|
||||
const aVal = a[sortConfig.column];
|
||||
const bVal = b[sortConfig.column];
|
||||
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (bVal == null) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return sortConfig.direction === 'asc' ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
|
||||
const aStr = String(aVal).toLowerCase();
|
||||
const bStr = String(bVal).toLowerCase();
|
||||
|
||||
if (aStr < bStr) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aStr > bStr) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [parsedData.data, sortConfig]);
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className={cn('w-full h-full flex items-center justify-center', className)}>
|
||||
<div className="text-muted-foreground text-sm">No data</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full h-full overflow-hidden', className)}>
|
||||
<ScrollArea className="w-full h-full">
|
||||
<div className="p-0">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead className="bg-muted sticky top-0">
|
||||
<tr>
|
||||
{parsedData.headers.map((header, index) => (
|
||||
<th key={index} className="px-3 py-2 text-left font-medium border border-border">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!isEmpty ? parsedData.data.map((row: any, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-b border-border hover:bg-muted/50">
|
||||
{parsedData.headers.map((header, cellIndex) => (
|
||||
<td key={cellIndex} className="px-3 py-2 border border-border">
|
||||
{row[header] || ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)) : (
|
||||
<tr>
|
||||
<td colSpan={parsedData.headers.length || 1} className="py-4 text-center text-muted-foreground">
|
||||
No data available
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className={cn('w-full h-full', className)}>
|
||||
<CsvTable
|
||||
headers={parsedData.headers}
|
||||
data={sortedData}
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
containerHeight={300} // Fixed height for thread preview
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ArrowUpDown,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface CsvTableProps {
|
||||
headers: string[];
|
||||
data: any[];
|
||||
sortConfig: { column: string; direction: 'asc' | 'desc' | null };
|
||||
onSort: (column: string) => void;
|
||||
searchTerm?: string;
|
||||
onClearSearch?: () => void;
|
||||
className?: string;
|
||||
containerHeight?: number; // Add container height prop
|
||||
}
|
||||
|
||||
export function CsvTable({
|
||||
headers,
|
||||
data,
|
||||
sortConfig,
|
||||
onSort,
|
||||
searchTerm,
|
||||
onClearSearch,
|
||||
className,
|
||||
containerHeight = 500
|
||||
}: CsvTableProps) {
|
||||
const ROW_HEIGHT = 48; // Height of each row in pixels
|
||||
const HEADER_HEIGHT = 48; // Height of header row
|
||||
const COL_WIDTH = 150; // Fixed column width in pixels
|
||||
|
||||
// Calculate offset to handle parent containers with fractional heights like calc(100vh-17rem)
|
||||
// 17rem = 272px (16px base * 17), so we need to offset the grid to align properly
|
||||
const parentOffset = 272; // 17rem in pixels
|
||||
const gridOffsetY = parentOffset % ROW_HEIGHT; // Get remainder to align to 48px grid
|
||||
const getSortIcon = (column: string) => {
|
||||
if (sortConfig.column !== column) {
|
||||
return <ArrowUpDown className="h-3 w-3 text-muted-foreground" />;
|
||||
}
|
||||
return sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="h-3 w-3 text-primary" /> :
|
||||
<ChevronDown className="h-3 w-3 text-primary" />;
|
||||
};
|
||||
|
||||
const formatCellValue = (value: any) => {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'number') {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Yes' : 'No';
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const getCellClassName = (value: any) => {
|
||||
if (typeof value === 'number') {
|
||||
return 'text-right font-mono';
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("w-full relative h-full !bg-card", className)}>
|
||||
{/* Use CSS Grid for perfect alignment */}
|
||||
<div
|
||||
className="w-full h-full overflow-auto relative"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${headers.length}, 150px)`,
|
||||
gridAutoRows: '48px',
|
||||
backgroundColor: '#f9fafb',
|
||||
backgroundImage: `
|
||||
repeating-linear-gradient(
|
||||
to right,
|
||||
transparent 0px,
|
||||
transparent 149px,
|
||||
#e5e7eb 149px,
|
||||
#e5e7eb 150px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
transparent 0px,
|
||||
transparent 47px,
|
||||
#d1d5db 47px,
|
||||
#d1d5db 48px
|
||||
)
|
||||
`,
|
||||
backgroundSize: '150px 48px',
|
||||
backgroundPosition: '0 0',
|
||||
backgroundAttachment: 'local'
|
||||
}}
|
||||
>
|
||||
{/* Header background extension for infinite grid */}
|
||||
<div
|
||||
className="sticky top-0 z-10 bg-background pointer-events-none"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${headers.length * 150}px`,
|
||||
right: 0,
|
||||
height: '48px',
|
||||
top: 0
|
||||
}}
|
||||
/>
|
||||
{/* Header row */}
|
||||
{headers.map((header, index) => (
|
||||
<div
|
||||
key={`header-${index}`}
|
||||
className="sticky top-0 z-20 bg-background flex items-center px-4 font-medium"
|
||||
style={{
|
||||
gridColumn: index + 1,
|
||||
gridRow: 1,
|
||||
height: '48px'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => onSort(header)}
|
||||
className="flex items-center gap-2 hover:text-primary transition-colors group w-full text-left"
|
||||
>
|
||||
<span className="truncate">{header}</span>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
{getSortIcon(header)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Data rows */}
|
||||
{data.map((row: any, rowIndex) => (
|
||||
headers.map((header, cellIndex) => {
|
||||
const value = row[header];
|
||||
return (
|
||||
<div
|
||||
key={`${rowIndex}-${cellIndex}`}
|
||||
className={cn(
|
||||
"flex items-center px-4 text-sm hover:bg-muted/30 transition-colors",
|
||||
getCellClassName(value)
|
||||
)}
|
||||
style={{
|
||||
gridColumn: cellIndex + 1,
|
||||
gridRow: rowIndex + 2,
|
||||
height: '48px'
|
||||
}}
|
||||
>
|
||||
<div className="truncate w-full" title={String(value || '')}>
|
||||
{formatCellValue(value)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
))}
|
||||
|
||||
{/* Empty state for search */}
|
||||
{data.length === 0 && searchTerm && (
|
||||
<div
|
||||
className="col-span-full py-8 text-center text-muted-foreground"
|
||||
style={{
|
||||
gridColumn: `1 / ${headers.length + 1}`,
|
||||
gridRow: 2
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p>No results found for "{searchTerm}"</p>
|
||||
{onClearSearch && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClearSearch}
|
||||
>
|
||||
Clear search
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue