Compare commits

...

2 Commits

Author SHA1 Message Date
Siddharth Ganesan
dcb714d9b1 export 2026-03-16 18:41:21 -07:00
Siddharth Ganesan
b8b939ead9 Csv export 2026-03-16 18:03:03 -07:00
4 changed files with 113 additions and 6 deletions

View File

@@ -26,6 +26,7 @@ import {
ArrowRight,
Calendar as CalendarIcon,
ChevronDown,
Download,
Fingerprint,
Pencil,
Plus,
@@ -37,6 +38,7 @@ import {
TypeNumber,
TypeText,
} from '@/components/emcn/icons'
import { escapeCsvValue } from '@/lib/copilot/orchestrator/sse/handlers/tool-execution'
import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition, Filter, SortDirection, TableRow as TableRowType } from '@/lib/table'
import type { ColumnOption, SortConfig } from '@/app/workspace/[workspaceId]/components'
@@ -1133,6 +1135,26 @@ export function Table({
setShowDeleteTableConfirm(true)
}, [])
const handleExportCsv = useCallback(() => {
if (!tableData || columns.length === 0) return
const header = columns.map((col) => escapeCsvValue(col.name)).join(',')
const dataRows = rows.map((row) =>
columns.map((col) => escapeCsvValue(row.data[col.name])).join(',')
)
const csv = [header, ...dataRows].join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${tableData.name}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}, [tableData, columns, rows])
const hasTableData = !!tableData
const breadcrumbs = useMemo(
@@ -1156,6 +1178,12 @@ export function Table({
disabled: !hasTableData,
onClick: handleStartTableRename,
},
{
label: 'Export CSV',
icon: Download,
disabled: !hasTableData || rows.length === 0,
onClick: handleExportCsv,
},
{
label: 'Delete',
icon: Trash,
@@ -1174,11 +1202,25 @@ export function Table({
tableHeaderRename.submitRename,
tableHeaderRename.cancelRename,
hasTableData,
rows.length,
handleStartTableRename,
handleExportCsv,
handleShowDeleteTableConfirm,
]
)
const headerActions = useMemo(
() => [
{
label: 'Export CSV',
icon: Download,
onClick: handleExportCsv,
disabled: !hasTableData || rows.length === 0,
},
],
[handleExportCsv, hasTableData, rows.length]
)
const createAction = useMemo(
() => ({
label: 'New column',
@@ -1248,7 +1290,12 @@ export function Table({
<div ref={containerRef} className='flex h-full flex-col overflow-hidden'>
{!embedded && (
<>
<ResourceHeader icon={TableIcon} breadcrumbs={breadcrumbs} create={createAction} />
<ResourceHeader
icon={TableIcon}
breadcrumbs={breadcrumbs}
actions={headerActions}
create={createAction}
/>
<ResourceOptionsBar sort={sortConfig} filter={filterElement} />
</>

View File

@@ -7,7 +7,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Copy, Database, Pencil, Trash } from '@/components/emcn/icons'
import { Copy, Database, Download, Pencil, Trash } from '@/components/emcn/icons'
interface TableContextMenuProps {
isOpen: boolean
@@ -17,8 +17,10 @@ interface TableContextMenuProps {
onDelete?: () => void
onViewSchema?: () => void
onRename?: () => void
onExportCsv?: () => void
disableDelete?: boolean
disableRename?: boolean
disableExportCsv?: boolean
menuRef?: React.RefObject<HTMLDivElement | null>
}
@@ -30,8 +32,10 @@ export function TableContextMenu({
onDelete,
onViewSchema,
onRename,
onExportCsv,
disableDelete = false,
disableRename = false,
disableExportCsv = false,
}: TableContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
@@ -67,14 +71,22 @@ export function TableContextMenu({
Rename
</DropdownMenuItem>
)}
{(onViewSchema || onRename) && (onCopyId || onDelete) && <DropdownMenuSeparator />}
{(onViewSchema || onRename) && (onCopyId || onExportCsv || onDelete) && (
<DropdownMenuSeparator />
)}
{onCopyId && (
<DropdownMenuItem onSelect={onCopyId}>
<Copy />
Copy ID
</DropdownMenuItem>
)}
{onCopyId && onDelete && <DropdownMenuSeparator />}
{onExportCsv && (
<DropdownMenuItem disabled={disableExportCsv} onSelect={onExportCsv}>
<Download />
Export CSV
</DropdownMenuItem>
)}
{(onCopyId || onExportCsv) && onDelete && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
<Trash />

View File

@@ -14,6 +14,7 @@ import {
Upload,
} from '@/components/emcn'
import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons'
import { escapeCsvValue } from '@/lib/copilot/orchestrator/sse/handlers/tool-execution'
import type { TableDefinition } from '@/lib/table'
import { generateUniqueTableName } from '@/lib/table/constants'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
@@ -218,6 +219,52 @@ export function Tables() {
[workspaceId, router]
)
const handleExportCsv = useCallback(async () => {
if (!activeTable || !workspaceId) return
closeRowContextMenu()
try {
const tableRes = await fetch(
`/api/table/${activeTable.id}?workspaceId=${encodeURIComponent(workspaceId)}`
)
if (!tableRes.ok) throw new Error('Failed to fetch table')
const tableJson = await tableRes.json()
const table = tableJson.data?.table ?? tableJson.table
const rowsRes = await fetch(
`/api/table/${activeTable.id}/rows?workspaceId=${encodeURIComponent(workspaceId)}&limit=1000&offset=0`
)
if (!rowsRes.ok) throw new Error('Failed to fetch rows')
const rowsJson = await rowsRes.json()
const tableRows = rowsJson.data?.rows ?? rowsJson.rows ?? []
const cols = table?.schema?.columns ?? []
if (cols.length === 0) {
toast.error('Table has no columns to export')
return
}
const header = cols.map((col: { name: string }) => escapeCsvValue(col.name)).join(',')
const dataRows = tableRows.map((row: { data: Record<string, unknown> }) =>
cols.map((col: { name: string }) => escapeCsvValue(row.data[col.name])).join(',')
)
const csv = [header, ...dataRows].join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${activeTable.name}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (err) {
logger.error('Failed to export table as CSV:', err)
toast.error('Failed to export table as CSV')
}
}, [activeTable, workspaceId, closeRowContextMenu])
const handleListUploadCsv = useCallback(() => {
csvInputRef.current?.click()
closeListContextMenu()
@@ -309,6 +356,7 @@ export function Tables() {
onCopyId={() => {
if (activeTable) navigator.clipboard.writeText(activeTable.id)
}}
onExportCsv={handleExportCsv}
onDelete={() => setIsDeleteDialogOpen(true)}
disableDelete={userPermissions.canEdit !== true}
disableRename={userPermissions.canEdit !== true}

View File

@@ -76,7 +76,7 @@ function extractTabularData(output: unknown): Record<string, unknown>[] | null {
return null
}
function escapeCsvValue(value: unknown): string {
export function escapeCsvValue(value: unknown): string {
if (value === null || value === undefined) return ''
const str = typeof value === 'object' ? JSON.stringify(value) : String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
@@ -85,7 +85,7 @@ function escapeCsvValue(value: unknown): string {
return str
}
function convertRowsToCsv(rows: Record<string, unknown>[]): string {
export function convertRowsToCsv(rows: Record<string, unknown>[]): string {
if (rows.length === 0) return ''
const headerSet = new Set<string>()