Skip to content

Commit f56fc2f

Browse files
feat(tables): byte-based import progress, cancel support, and a start toast that opens the import view
1 parent 7cec012 commit f56fc2f

18 files changed

Lines changed: 319 additions & 58 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { hybridAuthMockFns } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
import type { TableDefinition } from '@/lib/table'
8+
9+
const { mockCheckAccess, mockMarkImportCanceled, mockAppendTableEvent } = vi.hoisted(() => ({
10+
mockCheckAccess: vi.fn(),
11+
mockMarkImportCanceled: vi.fn(),
12+
mockAppendTableEvent: vi.fn(),
13+
}))
14+
15+
vi.mock('@/lib/table/service', () => ({ markImportCanceled: mockMarkImportCanceled }))
16+
vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent }))
17+
vi.mock('@/app/api/table/utils', async () => {
18+
const { NextResponse } = await import('next/server')
19+
return {
20+
checkAccess: mockCheckAccess,
21+
accessError: (result: { status: number }) =>
22+
NextResponse.json({ error: 'denied' }, { status: result.status }),
23+
}
24+
})
25+
26+
import { POST } from '@/app/api/table/[tableId]/import/cancel/route'
27+
28+
function buildTable(overrides: Partial<TableDefinition> = {}): TableDefinition {
29+
return {
30+
id: 'tbl_1',
31+
name: 'People',
32+
description: null,
33+
schema: { columns: [{ name: 'name', type: 'string' }] },
34+
metadata: null,
35+
rowCount: 0,
36+
maxRows: 1_000_000,
37+
workspaceId: 'workspace-1',
38+
createdBy: 'user-1',
39+
archivedAt: null,
40+
createdAt: new Date(),
41+
updatedAt: new Date(),
42+
...overrides,
43+
}
44+
}
45+
46+
function makeRequest(body: unknown, tableId = 'tbl_1') {
47+
const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/import/cancel`, {
48+
method: 'POST',
49+
headers: { 'content-type': 'application/json' },
50+
body: JSON.stringify(body),
51+
})
52+
return POST(req, { params: Promise.resolve({ tableId }) })
53+
}
54+
55+
const validBody = { workspaceId: 'workspace-1', importId: 'import-id-xyz' }
56+
57+
describe('POST /api/table/[tableId]/import/cancel', () => {
58+
beforeEach(() => {
59+
vi.clearAllMocks()
60+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
61+
success: true,
62+
userId: 'user-1',
63+
authType: 'session',
64+
})
65+
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
66+
mockMarkImportCanceled.mockResolvedValue(true)
67+
})
68+
69+
it('cancels the import and emits a canceled event', async () => {
70+
const response = await makeRequest(validBody)
71+
const data = await response.json()
72+
73+
expect(response.status).toBe(200)
74+
expect(data.data).toEqual({ canceled: true })
75+
expect(mockMarkImportCanceled).toHaveBeenCalledWith('tbl_1', 'import-id-xyz')
76+
expect(mockAppendTableEvent).toHaveBeenCalledWith(
77+
expect.objectContaining({ kind: 'import', status: 'canceled', importId: 'import-id-xyz' })
78+
)
79+
})
80+
81+
it('does not emit an event when nothing was importing', async () => {
82+
mockMarkImportCanceled.mockResolvedValue(false)
83+
const response = await makeRequest(validBody)
84+
const data = await response.json()
85+
86+
expect(response.status).toBe(200)
87+
expect(data.data).toEqual({ canceled: false })
88+
expect(mockAppendTableEvent).not.toHaveBeenCalled()
89+
})
90+
91+
it('returns 401 when unauthenticated', async () => {
92+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false })
93+
const response = await makeRequest(validBody)
94+
expect(response.status).toBe(401)
95+
expect(mockMarkImportCanceled).not.toHaveBeenCalled()
96+
})
97+
98+
it('returns the access error status when access is denied', async () => {
99+
mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
100+
const response = await makeRequest(validBody)
101+
expect(response.status).toBe(403)
102+
})
103+
104+
it('returns 400 on workspace mismatch', async () => {
105+
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable({ workspaceId: 'other-ws' }) })
106+
const response = await makeRequest(validBody)
107+
expect(response.status).toBe(400)
108+
expect(mockMarkImportCanceled).not.toHaveBeenCalled()
109+
})
110+
})
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { cancelTableImportContract } from '@/lib/api/contracts/tables'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { appendTableEvent } from '@/lib/table/events'
9+
import { markImportCanceled } from '@/lib/table/service'
10+
import { accessError, checkAccess } from '@/app/api/table/utils'
11+
12+
const logger = createLogger('TableImportCancelAPI')
13+
14+
export const runtime = 'nodejs'
15+
export const dynamic = 'force-dynamic'
16+
17+
interface RouteParams {
18+
params: Promise<{ tableId: string }>
19+
}
20+
21+
/**
22+
* POST /api/table/[tableId]/import/cancel
23+
*
24+
* Cancels an in-flight async CSV import. Flips the table's import status to `canceled`, which makes
25+
* the detached worker's next ownership check fail so it stops inserting. Committed rows are left in
26+
* place (no rollback) — the user can delete the table. No-op if the import already finished.
27+
*/
28+
export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
29+
const requestId = generateRequestId()
30+
31+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
32+
if (!authResult.success || !authResult.userId) {
33+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
34+
}
35+
36+
const parsed = await parseRequest(cancelTableImportContract, request, { params })
37+
if (!parsed.success) return parsed.response
38+
const { tableId } = parsed.data.params
39+
const { workspaceId, importId } = parsed.data.body
40+
41+
const access = await checkAccess(tableId, authResult.userId, 'write')
42+
if (!access.ok) return accessError(access, requestId, tableId)
43+
if (access.table.workspaceId !== workspaceId) {
44+
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
45+
}
46+
47+
const canceled = await markImportCanceled(tableId, importId)
48+
if (canceled) {
49+
void appendTableEvent({ kind: 'import', tableId, importId, status: 'canceled' })
50+
}
51+
logger.info(`[${requestId}] Import cancel requested`, { tableId, importId, canceled })
52+
53+
return NextResponse.json({ success: true, data: { canceled } })
54+
})

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ export function useTableEventStream({
241241
// Live-fill: rows are real as each batch commits. Coalesce the per-tick row
242242
// refetches via a debounce; on the terminal event refetch rows + the
243243
// definition immediately (the worker may have rewritten the schema).
244-
if (status === 'ready' || status === 'failed') {
244+
if (status === 'ready' || status === 'failed' || status === 'canceled') {
245245
if (importInvalidateTimer !== null) clearTimeout(importInvalidateTimer)
246246
void queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
247247
void queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) })

apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,13 @@ export function ImportCsvDialog({
328328
rowsProcessed: 0,
329329
})
330330
onOpenChange(false)
331+
toast({
332+
message: `Importing "${parsed.file.name}" into "${table.name}"…`,
333+
action: {
334+
label: 'View',
335+
onClick: () => useImportTrayStore.getState().setMenuOpen(true),
336+
},
337+
})
331338
importAsyncMutation.mutate(
332339
{
333340
workspaceId,
@@ -342,7 +349,7 @@ export function ImportCsvDialog({
342349
workspaceId,
343350
title: table.name,
344351
phase: 'importing',
345-
uploadPercent: percent,
352+
percent,
346353
}),
347354
},
348355
{

apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ProgressItem,
1010
} from '@/components/emcn'
1111
import { Upload } from '@/components/emcn/icons'
12+
import { cancelTableImport } from '@/hooks/queries/tables'
1213
import { selectWorkspaceImports, useImportTrayStore } from '@/stores/table/import-tray/store'
1314
import { getImportStage } from './import-stage'
1415
import { useHydrateImportTray } from './use-hydrate-import-tray'
@@ -38,6 +39,8 @@ export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuP
3839
useShallow((state) => selectWorkspaceImports(state, workspaceId))
3940
)
4041
const dismiss = useImportTrayStore((state) => state.dismiss)
42+
const menuOpen = useImportTrayStore((state) => state.menuOpen)
43+
const setMenuOpen = useImportTrayStore((state) => state.setMenuOpen)
4144

4245
// Inside a table, scope the indicator to that table's import only; on the list view show
4346
// every active import in the workspace.
@@ -48,8 +51,16 @@ export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuP
4851
const total = imports.length
4952
const done = imports.filter((e) => e.phase === 'ready').length
5053

54+
const cancel = (entry: (typeof imports)[number]) => {
55+
// Optimistically clear it; the server flips status → the SSE `canceled` event also dismisses.
56+
dismiss(entry.tableId)
57+
if (entry.importId) {
58+
void cancelTableImport(entry.workspaceId, entry.tableId, entry.importId).catch(() => {})
59+
}
60+
}
61+
5162
return (
52-
<DropdownMenu>
63+
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
5364
<DropdownMenuTrigger asChild>
5465
<Button variant='subtle' className='px-2 py-1 text-caption'>
5566
<Upload className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
@@ -68,6 +79,7 @@ export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuP
6879
title={stage.title}
6980
meta={stage.meta}
7081
detail={stage.detail}
82+
onCancel={entry.phase === 'importing' ? () => cancel(entry) : undefined}
7183
onDismiss={stage.dismissible ? () => dismiss(entry.tableId) : undefined}
7284
/>
7385
)

apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-stage.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,22 @@ export interface ImportStageView {
99
title: string
1010
/** Right-aligned on the title row: the percent (when known). */
1111
meta?: string
12-
/** Secondary line: the row progress, or the error message on failure. */
12+
/** Secondary line: the row count, or the error message on failure. */
1313
detail?: string
1414
dismissible: boolean
1515
}
1616

1717
/**
1818
* Maps a tray entry to the stage shown in the import dropdown. The single place the import
1919
* stages (Uploading → Processing → Imported / Failed) are defined; the row component just
20-
* renders the returned slots, so every stage looks consistent: `{status} {name}` with the
21-
* percent on the right and the row count underneath.
20+
* renders the returned slots, so every stage looks consistent: `{status} {name}` with a
21+
* byte-based percent on the right and the row count underneath. The percent comes straight from
22+
* `entry.percent` (exact, monotonic) rather than an estimated row fraction.
2223
*/
2324
export function getImportStage(entry: ImportTrayEntry): ImportStageView {
2425
const rows = entry.rowsProcessed.toLocaleString()
2526
const name = entry.title
27+
const meta = typeof entry.percent === 'number' ? `${entry.percent}%` : undefined
2628

2729
if (entry.phase === 'failed') {
2830
return {
@@ -42,22 +44,15 @@ export function getImportStage(entry: ImportTrayEntry): ImportStageView {
4244
}
4345
}
4446

45-
// importing: processing once the worker reports rows/total, otherwise still uploading.
46-
if (entry.total && entry.total > 0) {
47-
const percent = Math.min(99, Math.round((entry.rowsProcessed / entry.total) * 100))
47+
// importing: rows only start arriving once the worker is processing; before that it's the upload.
48+
if (entry.rowsProcessed > 0) {
4849
return {
4950
status: 'pending',
5051
title: `Processing ${name}`,
51-
meta: `${percent}%`,
52-
detail: `${rows} / ${entry.total.toLocaleString()} rows`,
52+
meta,
53+
detail: `${rows} rows`,
5354
dismissible: false,
5455
}
5556
}
56-
57-
return {
58-
status: 'pending',
59-
title: `Uploading ${name}`,
60-
meta: typeof entry.uploadPercent === 'number' ? `${entry.uploadPercent}%` : undefined,
61-
dismissible: false,
62-
}
57+
return { status: 'pending', title: `Uploading ${name}`, meta, dismissible: false }
6358
}

apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-hydrate-import-tray.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ export function useHydrateImportTray(workspaceId: string | undefined): void {
4444
})
4545
} else if (tray.entries[table.id]?.phase === 'importing') {
4646
// A tracked import finished while we weren't watching (missed SSE terminal event).
47-
// `ready` → clear the spinner; `failed` → surface the failure instead of spinning forever.
48-
if (table.importStatus === 'ready') {
47+
// `ready`/`canceled` → clear the spinner; `failed` → surface the failure.
48+
if (table.importStatus === 'ready' || table.importStatus === 'canceled') {
4949
tray.dismiss(table.id)
5050
} else if (table.importStatus === 'failed') {
5151
tray.upsert({

apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-import-progress-tracker.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,18 @@ export function useImportProgressTracker(): void {
4848
// before we know it (brief optimistic window), don't trust a replayed terminal event.
4949
const lockedId = existing?.importId
5050
if (lockedId && event.importId !== lockedId) return
51-
if (!lockedId && (event.status === 'ready' || event.status === 'failed')) return
51+
const isTerminal =
52+
event.status === 'ready' || event.status === 'failed' || event.status === 'canceled'
53+
if (!lockedId && isTerminal) return
5254

5355
const importId = lockedId ?? event.importId
5456
const title = existing?.title ?? 'table'
5557
const rows = event.progress ?? existing?.rowsProcessed ?? 0
58+
if (event.status === 'canceled') {
59+
// The user stopped it — just clear the tray entry (no toast, they initiated it).
60+
tray.dismiss(tableId)
61+
return
62+
}
5663
if (event.status === 'ready') {
5764
toast.success(`Imported ${rows.toLocaleString()} rows into "${title}"`)
5865
// Keep it briefly so the count reads `1/1`, then clear (if still ready).
@@ -80,7 +87,7 @@ export function useImportProgressTracker(): void {
8087
importId,
8188
phase: event.status,
8289
rowsProcessed: rows,
83-
total: event.total,
90+
percent: event.percent,
8491
error: event.error ?? undefined,
8592
})
8693
} catch (err) {

apps/sim/app/workspace/[workspaceId]/tables/tables.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,13 @@ export function Tables() {
420420
phase: 'importing',
421421
rowsProcessed: 0,
422422
})
423+
toast({
424+
message: `Importing "${file.name}"…`,
425+
action: {
426+
label: 'View',
427+
onClick: () => useImportTrayStore.getState().setMenuOpen(true),
428+
},
429+
})
423430
try {
424431
const result = await importCsvAsync.mutateAsync({
425432
workspaceId,
@@ -430,7 +437,7 @@ export function Tables() {
430437
workspaceId,
431438
title: file.name,
432439
phase: 'importing',
433-
uploadPercent: percent,
440+
percent,
434441
}),
435442
})
436443
useImportTrayStore.getState().dismiss(pendingId)

0 commit comments

Comments
 (0)