mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
2 Commits
fix/servic
...
feat/table
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcb714d9b1 | ||
|
|
b8b939ead9 |
@@ -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} />
|
||||
</>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
Reference in New Issue
Block a user