diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
index 8c4fa72c92..987f7db6e9 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
@@ -23,14 +23,26 @@ import {
TableRow,
toast,
} from '@/components/emcn'
+import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES } from '@/lib/table/constants'
import { buildAutoMapping, parseCsvBuffer } from '@/lib/table/import'
import type { TableDefinition } from '@/lib/table/types'
-import { type CsvImportMode, useImportCsvIntoTable } from '@/hooks/queries/tables'
+import {
+ type CsvImportMode,
+ cancelTableImport,
+ useImportCsvIntoTable,
+ useImportCsvIntoTableAsync,
+} from '@/hooks/queries/tables'
+import { useImportTrayStore } from '@/stores/table/import-tray/store'
const logger = createLogger('ImportCsvDialog')
const MAX_SAMPLE_ROWS = 5
const MAX_EXAMPLES_IN_ERROR = 3
+/**
+ * Bytes read for the preview/mapping. We never parse the whole file client-side — the importer
+ * streams it server-side and the DB row-count trigger enforces the row limit.
+ */
+const CSV_PREVIEW_BYTES = 512 * 1024
/**
* Sentinel value for the "Do not import" option in the mapping combobox. The
* whitespace is intentional: valid column names must match `NAME_PATTERN`
@@ -92,7 +104,18 @@ interface ParsedCsv {
file: File
headers: string[]
sampleRows: Record
- Append would exceed the row limit ({table.maxRows.toLocaleString()}) by{' '} - {appendCapacityDeficit.toLocaleString()} row(s). Remove rows or switch to - Replace. -
- )} - {replaceCapacityDeficit > 0 && ( -- CSV has {parsed.totalRows.toLocaleString()} rows, which exceeds the table limit - of {table.maxRows.toLocaleString()} by {replaceCapacityDeficit.toLocaleString()} - . -
- )} )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index bbef067020..19523e94a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -2,12 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' import { useParams, useRouter } from 'next/navigation' import type { ComboboxOption } from '@/components/emcn' import { ChipCombobox, ChipConfirmModal, toast, Upload } from '@/components/emcn' import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons' import type { TableDefinition } from '@/lib/table' -import { generateUniqueTableName } from '@/lib/table/constants' +import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants' import type { FilterTag, ResourceColumn, @@ -24,14 +25,17 @@ import { import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ImportCsvDialog, + ImportProgressMenu, TablesListContextMenu, } from '@/app/workspace/[workspaceId]/tables/components' import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { + cancelTableImport, downloadTableExport, useCreateTable, useDeleteTable, + useImportCsvAsync, useRenameTable, useTablesList, useUploadCsvToTable, @@ -40,6 +44,7 @@ import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' import { useInlineRename } from '@/hooks/use-inline-rename' import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useImportTrayStore } from '@/stores/table/import-tray/store' const logger = createLogger('Tables') @@ -76,6 +81,7 @@ export function Tables() { const renameTable = useRenameTable(workspaceId) const createTable = useCreateTable(workspaceId) const uploadCsv = useUploadCsvToTable() + const importCsvAsync = useImportCsvAsync() const tableRename = useInlineRename({ onSave: (tableId, name) => renameTable.mutate({ tableId, name }), @@ -407,37 +413,80 @@ export function Tables() { const list = e.target.files if (!list || list.length === 0 || !workspaceId) return - try { - setUploading(true) + const csvFiles = Array.from(list).filter((f) => { + const ext = f.name.split('.').pop()?.toLowerCase() + return ext === 'csv' || ext === 'tsv' + }) + + if (csvFiles.length === 0) { + toast.error('No CSV or TSV files selected') + if (csvInputRef.current) csvInputRef.current.value = '' + return + } - const csvFiles = Array.from(list).filter((f) => { - const ext = f.name.split('.').pop()?.toLowerCase() - return ext === 'csv' || ext === 'tsv' - }) + // Large files can't be POSTed through the server (request-body cap) — upload them + // straight to storage and import in the background. These are tracked by the import + // tray, never the header upload button, so don't touch uploading/uploadProgress here. + const asyncFiles = csvFiles.filter((f) => f.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES) + const syncFiles = csvFiles.filter((f) => f.size < CSV_ASYNC_IMPORT_THRESHOLD_BYTES) - if (csvFiles.length === 0) { - toast.error('No CSV or TSV files selected') - return + try { + for (const file of asyncFiles) { + // Show the indicator immediately under a temporary id (the real table id doesn't + // exist until kickoff returns), then let the tray track it. Don't redirect — the + // table is still empty/importing, so stay on the list. + const pendingId = `pending_${generateId()}` + useImportTrayStore + .getState() + .startUpload({ uploadId: pendingId, workspaceId, title: file.name }) + toast.success(`Importing "${file.name}" in the background`) + try { + const result = await importCsvAsync.mutateAsync({ + workspaceId, + file, + onProgress: (percent) => { + useImportTrayStore.getState().setUploadPercent(pendingId, percent) + }, + }) + useImportTrayStore.getState().endUpload(pendingId) + // The server row drives the tray once the list refetches (mutation invalidates it). + // If canceled mid-upload, flag the real id so it's not shown and cancel server-side. + if ( + result?.tableId && + result.importId && + useImportTrayStore.getState().consumeCanceled(pendingId) + ) { + useImportTrayStore.getState().cancel(result.tableId) + void cancelTableImport(workspaceId, result.tableId, result.importId).catch(() => {}) + } + } catch { + // The hook's onError surfaces the toast; just clear the tray indicator here. + useImportTrayStore.getState().endUpload(pendingId) + } } - setUploadProgress({ completed: 0, total: csvFiles.length }) + if (syncFiles.length === 0) return + + setUploading(true) + setUploadProgress({ completed: 0, total: syncFiles.length }) const failed: string[] = [] - for (let i = 0; i < csvFiles.length; i++) { + for (let i = 0; i < syncFiles.length; i++) { + const file = syncFiles[i] try { - const result = await uploadCsv.mutateAsync({ workspaceId, file: csvFiles[i] }) + const result = await uploadCsv.mutateAsync({ workspaceId, file }) - if (csvFiles.length === 1) { + if (syncFiles.length === 1 && asyncFiles.length === 0) { const tableId = result?.data?.table?.id if (tableId) { router.push(`/workspace/${workspaceId}/tables/${tableId}`) } } } catch (err) { - failed.push(csvFiles[i].name) + failed.push(file.name) logger.error('Error uploading CSV:', err) } finally { - setUploadProgress({ completed: i + 1, total: csvFiles.length }) + setUploadProgress({ completed: i + 1, total: syncFiles.length }) } } @@ -459,7 +508,8 @@ export function Tables() { } } }, - [workspaceId, router, uploadCsv] + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 + [workspaceId, router] ) const handleListUploadCsv = useCallback(() => { @@ -508,6 +558,7 @@ export function Tables() { sort={sortConfig} filter={filterContent} filterTags={filterTags} + leadingActions={