chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,525 @@
'use client'
import { useMemo, useRef, useState } from 'react'
import {
Button,
ButtonGroup,
ButtonGroupItem,
ChipCombobox,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
type ComboboxOption,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
toast,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
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,
cancelTableJob,
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`
* (`/^[a-z_][a-z0-9_]*$/i`), so no real column can share this value.
*/
const SKIP_VALUE = '__ skip __'
/**
* Sentinel for the "Create new column" option. Same whitespace trick as
* `SKIP_VALUE` to avoid colliding with any valid column name.
*/
const CREATE_VALUE = '__ create __'
/**
* Converts the verbose backend error messages into a short, human-friendly
* summary suitable for the modal footer. Specifically collapses repeated
* `Row N: Column "X" must be unique. Value "Y" already exists in row M`
* segments into a single concise summary.
*/
function summarizeImportError(message: string): string {
const uniqueMatches = [
...message.matchAll(/Column\s+"([^"]+)"\s+must be unique\.\s+Value\s+"([^"]+)"/g),
]
if (uniqueMatches.length > 0) {
const column = uniqueMatches[0][1]
const values = Array.from(new Set(uniqueMatches.map((m) => m[2])))
const preview = values
.slice(0, MAX_EXAMPLES_IN_ERROR)
.map((v) => `"${v}"`)
.join(', ')
const extra = values.length - MAX_EXAMPLES_IN_ERROR
const suffix = extra > 0 ? `, +${extra} more` : ''
return `${values.length} row${values.length === 1 ? '' : 's'} conflict on unique column "${column}" (${preview}${suffix})`
}
const requiredMatch = message.match(/missing required columns?:\s*(.+)/i)
if (requiredMatch) {
return `Missing required column(s): ${requiredMatch[1].replace(/[.;]+$/, '')}`
}
const rowLimitMatch = message.match(/row limit[^.;]*/i)
if (rowLimitMatch) {
return rowLimitMatch[0].trim()
}
const trimmed = message.trim()
if (trimmed.length > 180) return truncate(trimmed, 177)
return trimmed
}
interface ImportCsvDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
table: TableDefinition
onImported?: (result: { insertedCount?: number; deletedCount?: number }) => void
}
interface ParsedCsv {
file: File
headers: string[]
sampleRows: Record<string, unknown>[]
}
/** Parses the head of a CSV/TSV for the mapping + sample, dropping any truncated final line. */
async function parseCsvPreview(file: File, delimiter: ',' | '\t') {
const sliced = file.size > CSV_PREVIEW_BYTES
const blob = sliced ? file.slice(0, CSV_PREVIEW_BYTES) : file
let bytes = new Uint8Array(await blob.arrayBuffer())
if (sliced) {
const lastNewline = bytes.lastIndexOf(0x0a)
if (lastNewline > 0) bytes = bytes.subarray(0, lastNewline + 1)
}
return parseCsvBuffer(bytes, delimiter)
}
export function ImportCsvDialog({
open,
onOpenChange,
workspaceId,
table,
onImported,
}: ImportCsvDialogProps) {
const [parsed, setParsed] = useState<ParsedCsv | null>(null)
const [parseError, setParseError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const [parsing, setParsing] = useState(false)
const [mapping, setMapping] = useState<Record<string, string | null>>({})
const [createHeaders, setCreateHeaders] = useState<Set<string>>(new Set())
const [mode, setMode] = useState<CsvImportMode>('append')
const importMutation = useImportCsvIntoTable()
const importAsyncMutation = useImportCsvIntoTableAsync()
function resetState() {
setParsed(null)
setParseError(null)
setSubmitError(null)
setMapping({})
setCreateHeaders(new Set())
setMode('append')
setParsing(false)
}
function handleOpenChange(newOpen: boolean) {
if (!newOpen) resetState()
onOpenChange(newOpen)
}
const prevTableIdRef = useRef(table.id)
if (prevTableIdRef.current !== table.id) {
prevTableIdRef.current = table.id
resetState()
}
const columnOptions: ComboboxOption[] = useMemo(() => {
const options: ComboboxOption[] = [
{ label: 'Do not import', value: SKIP_VALUE },
{ label: '+ Create new column', value: CREATE_VALUE },
]
for (const col of table.schema.columns) {
options.push({
label: col.required ? `${col.name} (required)` : col.name,
value: col.name,
})
}
return options
}, [table.schema.columns])
async function handleFileSelected(file: File) {
const ext = file.name.split('.').pop()?.toLowerCase()
if (ext !== 'csv' && ext !== 'tsv') {
setParseError('Only CSV and TSV files are supported')
return
}
setParsing(true)
setParseError(null)
try {
const delimiter: ',' | '\t' = ext === 'tsv' ? '\t' : ','
const { headers, rows } = await parseCsvPreview(file, delimiter)
const autoMapping = buildAutoMapping(headers, table.schema)
setParsed({
file,
headers,
sampleRows: rows.slice(0, MAX_SAMPLE_ROWS),
})
setMapping(autoMapping)
} catch (err) {
const message = getErrorMessage(err, 'Failed to parse CSV')
logger.error('CSV parse failed', err)
setParseError(message)
} finally {
setParsing(false)
}
}
function handleFilesSelected(files: File[]) {
const file = files[0]
if (file) void handleFileSelected(file)
}
function handleMappingChange(header: string, value: string) {
setSubmitError(null)
if (value === CREATE_VALUE) {
setCreateHeaders((prev) => {
const next = new Set(prev)
next.add(header)
return next
})
setMapping((prev) => ({ ...prev, [header]: null }))
return
}
setCreateHeaders((prev) => {
if (!prev.has(header)) return prev
const next = new Set(prev)
next.delete(header)
return next
})
setMapping((prev) => ({
...prev,
[header]: value === SKIP_VALUE ? null : value,
}))
}
function handleCreateAllUnmapped() {
if (!parsed) return
setSubmitError(null)
setCreateHeaders((prev) => {
const next = new Set(prev)
for (const header of parsed.headers) {
if (!mapping[header] && !next.has(header)) next.add(header)
}
return next
})
}
function handleModeChange(value: string) {
setSubmitError(null)
setMode(value as CsvImportMode)
}
const { missingRequired, duplicateTargets, mappedCount, skipCount, createCount } = useMemo(() => {
const mappedTargets = new Map<string, string[]>()
let mapped = 0
let skipped = 0
let creating = 0
for (const header of parsed?.headers ?? []) {
if (createHeaders.has(header)) {
creating++
continue
}
const target = mapping[header]
if (!target) {
skipped++
continue
}
mapped++
const existing = mappedTargets.get(target) ?? []
existing.push(header)
mappedTargets.set(target, existing)
}
const dupes = [...mappedTargets.entries()]
.filter(([, headers]) => headers.length > 1)
.map(([col]) => col)
const mappedSet = new Set(mappedTargets.keys())
const missing = table.schema.columns
.filter((c) => c.required && !mappedSet.has(c.name))
.map((c) => c.name)
return {
missingRequired: missing,
duplicateTargets: dupes,
mappedCount: mapped,
skipCount: skipped,
createCount: creating,
}
}, [mapping, parsed?.headers, table.schema.columns, createHeaders])
const canSubmit =
parsed !== null &&
!importMutation.isPending &&
!importAsyncMutation.isPending &&
missingRequired.length === 0 &&
duplicateTargets.length === 0 &&
mappedCount + createCount > 0
async function handleSubmit() {
if (!parsed || !canSubmit) return
setSubmitError(null)
const createColumns = createHeaders.size > 0 ? [...createHeaders] : undefined
// Large files can't be POSTed through the server (request-body cap) — upload them
// straight to storage and import in the background instead. Seed the header tray and
// close the dialog immediately so the indicator is visible during the upload, then run
// the upload + kickoff in the background (don't block the dialog on it).
if (parsed.file.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES) {
useImportTrayStore.getState().startUpload({
uploadId: table.id,
workspaceId,
title: parsed.file.name,
})
onOpenChange(false)
toast.success(`Importing "${parsed.file.name}" into "${table.name}" in the background`)
importAsyncMutation.mutate(
{
workspaceId,
tableId: table.id,
file: parsed.file,
mode,
mapping,
createColumns,
onProgress: (percent) => {
useImportTrayStore.getState().setUploadPercent(table.id, percent)
},
},
{
onSuccess: (data) => {
useImportTrayStore.getState().endUpload(table.id)
// The server row drives the tray once the list refetches. If canceled mid-upload, flag
// the id so it's not shown and cancel the worker server-side.
if (useImportTrayStore.getState().consumeCanceled(table.id) && data?.importId) {
useImportTrayStore.getState().cancel(table.id)
void cancelTableJob(workspaceId, table.id, data.importId).catch(() => {})
}
},
onError: () => {
// The hook's onError surfaces the toast; just clear the tray indicator here.
useImportTrayStore.getState().endUpload(table.id)
},
}
)
return
}
try {
const result = await importMutation.mutateAsync({
workspaceId,
tableId: table.id,
file: parsed.file,
mode,
mapping,
createColumns,
})
const data = result.data
if (mode === 'append') {
toast.success(`Imported ${data?.insertedCount ?? 0} rows into "${table.name}"`)
} else {
toast.success(
`Replaced rows in "${table.name}": deleted ${data?.deletedCount ?? 0}, inserted ${data?.insertedCount ?? 0}`
)
}
onImported?.({
insertedCount: data?.insertedCount,
deletedCount: data?.deletedCount,
})
onOpenChange(false)
} catch (err) {
const message = getErrorMessage(err, 'Failed to import CSV')
setSubmitError(summarizeImportError(message))
logger.error('CSV import into existing table failed', err)
}
}
const hasWarning = missingRequired.length > 0 || duplicateTargets.length > 0
return (
<ChipModal
open={open}
onOpenChange={handleOpenChange}
srTitle={`Import CSV into ${table.name}`}
size='lg'
>
<ChipModalHeader onClose={() => handleOpenChange(false)}>
Import CSV into {table.name}
</ChipModalHeader>
<ChipModalBody>
{!parsed ? (
<ChipModalField
type='file'
title='Import CSV'
accept='.csv,.tsv'
disabled={parsing}
onChange={handleFilesSelected}
label={parsing ? 'Parsing...' : 'Drop CSV or TSV here or click to browse'}
description='Map columns to append or replace rows in this table'
error={parseError ?? undefined}
/>
) : (
<>
<ChipModalField type='custom' title='File'>
<div className='flex items-center justify-between gap-3 rounded-sm border border-[var(--border)] p-2'>
<div className='flex min-w-0 flex-col'>
<span className='truncate text-[var(--text-primary)] text-caption'>
{parsed.file.name}
</span>
<span className='text-[var(--text-tertiary)] text-xs'>
{parsed.headers.length} columns
</span>
</div>
<Button variant='ghost' size='sm' onClick={resetState}>
Change file
</Button>
</div>
</ChipModalField>
<ChipModalField type='custom' title='Mode'>
<ButtonGroup value={mode} onValueChange={handleModeChange}>
<ButtonGroupItem value='append'>Append</ButtonGroupItem>
<ButtonGroupItem value='replace'>Replace all rows</ButtonGroupItem>
</ButtonGroup>
</ChipModalField>
<ChipModalField type='custom' title='Column mapping'>
{skipCount > 0 && (
<div className='flex justify-end'>
<Button variant='ghost' size='sm' onClick={handleCreateAllUnmapped}>
Create columns for {skipCount} unmapped
</Button>
</div>
)}
<div className='overflow-hidden rounded-sm border border-[var(--border)]'>
<div className='max-h-[320px] overflow-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead>CSV column</TableHead>
<TableHead>Target column</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{parsed.headers.map((header) => {
const sample = parsed.sampleRows
.map((r) =>
r[header] === '' || r[header] == null ? '' : String(r[header])
)
.filter(Boolean)
.slice(0, 2)
.join(', ')
return (
<TableRow key={header}>
<TableCell>
<div className='flex min-w-0 flex-col'>
<span className='truncate text-[var(--text-primary)]'>
{header}
</span>
{sample && (
<span className='truncate text-[var(--text-tertiary)] text-xs'>
{sample}
</span>
)}
</div>
</TableCell>
<TableCell>
<ChipCombobox
options={columnOptions}
value={
createHeaders.has(header)
? CREATE_VALUE
: (mapping[header] ?? SKIP_VALUE)
}
onChange={(value) => handleMappingChange(header, value)}
className='w-full'
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
<span className='text-[var(--text-tertiary)] text-xs'>
{mappedCount} mapped
{createCount > 0
? ` · ${createCount} new column${createCount === 1 ? '' : 's'}`
: ''}
{' · '}
{skipCount} skipped
</span>
</ChipModalField>
{missingRequired.length > 0 && (
<ChipModalError>
Missing required column(s): {missingRequired.join(', ')}
</ChipModalError>
)}
{duplicateTargets.length > 0 && (
<ChipModalError>
Multiple CSV columns target: {duplicateTargets.join(', ')} (pick one)
</ChipModalError>
)}
{mode === 'replace' && !hasWarning && (
<ChipModalError>
Replace will permanently delete the {table.rowCount.toLocaleString()} existing
row(s) before inserting the new rows.
</ChipModalError>
)}
<ChipModalError title={submitError ?? undefined}>{submitError}</ChipModalError>
</>
)}
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
cancelDisabled={importMutation.isPending}
primaryAction={{
label: importMutation.isPending
? mode === 'replace'
? 'Replacing...'
: 'Importing...'
: mode === 'replace'
? 'Replace rows'
: 'Append rows',
onClick: handleSubmit,
disabled: !canSubmit,
variant: mode === 'replace' ? 'destructive' : 'primary',
}}
/>
</ChipModal>
)
}
@@ -0,0 +1 @@
export * from './import-csv-dialog'
@@ -0,0 +1,117 @@
'use client'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
ProgressItem,
toast,
} from '@sim/emcn'
import { CircleAlert, CircleCheck, Loader } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { cancelTableJob, downloadExportResult } from '@/hooks/queries/tables'
import { useImportTrayStore } from '@/stores/table/import-tray/store'
import { getImportStage } from './import-stage'
import { type ImportRow, useWorkspaceImports } from './use-workspace-imports'
const logger = createLogger('ImportProgressMenu')
interface ImportProgressMenuProps {
workspaceId: string | undefined
/** When mounted inside a specific table's header, the indicator is scoped to that table. */
tableId?: string
}
/**
* Header affordance for background table jobs: a clickable `{done}/{total}` count that opens a
* dropdown of per-job progress rows — CSV imports and exports (a ready export row carries a
* Download action). Renders nothing when there are no jobs. The single job-progress surface for
* both the tables list and the in-table view.
*/
export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuProps) {
const imports = useWorkspaceImports(workspaceId, tableId)
const dismiss = useImportTrayStore((state) => state.dismiss)
const dismissJob = useImportTrayStore((state) => state.dismissJob)
const cancelId = useImportTrayStore((state) => state.cancel)
const menuOpen = useImportTrayStore((state) => state.menuOpen)
const setMenuOpen = useImportTrayStore((state) => state.setMenuOpen)
if (imports.length === 0) return null
const total = imports.length
const done = imports.filter((e) => e.phase === 'ready').length
const anyRunning = imports.some((e) => e.phase === 'importing')
const anyFailed = imports.some((e) => e.phase === 'failed')
const cancel = (row: ImportRow) => {
cancelId(row.id)
// Worker already running — cancel it server-side now. (An upload still mid-flight is canceled by
// the kickoff handler once its jobId is known; see the `consumeCanceled` branches.)
if (row.jobId) {
void cancelTableJob(row.workspaceId, row.tableId, row.jobId).catch(() => {})
}
}
const download = (row: ImportRow) => {
if (!row.jobId) return
void downloadExportResult(row.workspaceId, row.tableId, row.jobId).catch((err) => {
logger.error('Export download failed', { jobId: row.jobId, err })
toast.error('Download failed — the export may have expired')
})
}
return (
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<Button variant='subtle' className='px-2 py-1 text-caption'>
{/* Aggregate state, mirroring the row iconography: spinner while anything runs, then
alert if any job failed, else a check. */}
{anyRunning ? (
<Loader animate className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
) : anyFailed ? (
<CircleAlert className='mr-1.5 size-[14px] text-[var(--text-error)]' />
) : (
<CircleCheck className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
)}
<span className='tabular-nums'>
{done}/{total}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='min-w-[320px] max-w-[420px] gap-0 p-1'>
{imports.map((row) => {
const stage = getImportStage(row)
const isReadyExport = row.jobType === 'export' && row.phase === 'ready' && row.hasResult
return (
<ProgressItem
key={row.id}
status={stage.status}
title={stage.title}
meta={stage.meta}
detail={
isReadyExport ? (
<button
type='button'
className='text-[var(--brand-primary)] hover-hover:underline'
onClick={() => download(row)}
>
Download
</button>
) : (
stage.detail
)
}
onCancel={row.phase === 'importing' ? () => cancel(row) : undefined}
onDismiss={
stage.dismissible
? () => (row.jobType === 'export' ? dismissJob(row.id) : dismiss(row.id))
: undefined
}
/>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,84 @@
import type { ImportRow } from './use-workspace-imports'
type ProgressStatus = 'pending' | 'success' | 'error'
/** Uniform view model for a tray entry — every stage fills the same slots. */
export interface ImportStageView {
status: ProgressStatus
/** Primary line: `{status} {name}`, e.g. `Processing data.csv`. */
title: string
/** Right-aligned on the title row: the percent (when known). */
meta?: string
/** Secondary line: the row count, or the error message on failure. */
detail?: string
dismissible: boolean
}
/**
* Maps a tray entry to the stage shown in the import dropdown. The single place the import
* stages (Uploading → Processing → Imported / Failed) are defined; the row component just
* renders the returned slots, so every stage looks consistent: `{status} {name}`. While
* uploading, the right slot shows the byte-based upload percent (from the client XHR). Once the
* server is processing we only know the committed row count (polled from the table row), so the
* detail line reads `{rows} rows` with no percent.
*/
export function getImportStage(entry: ImportRow): ImportStageView {
const rows = entry.rowsProcessed.toLocaleString()
const name = entry.title
const meta = typeof entry.percent === 'number' ? `${entry.percent}%` : undefined
if (entry.jobType === 'export') {
if (entry.phase === 'failed') {
return {
status: 'error',
title: `Export failed for ${name}`,
detail: entry.error ?? 'Something went wrong',
dismissible: true,
}
}
if (entry.phase === 'ready') {
// The menu replaces `detail` with a Download action for ready exports.
return {
status: 'success',
title: `Exported ${name}`,
detail: `${rows} rows`,
dismissible: true,
}
}
return {
status: 'pending',
title: `Exporting ${name}`,
detail: `${rows} rows`,
dismissible: false,
}
}
if (entry.phase === 'failed') {
return {
status: 'error',
title: `Failed ${name}`,
detail: entry.error ?? 'Something went wrong',
dismissible: true,
}
}
if (entry.phase === 'ready') {
return {
status: 'success',
title: `Imported ${name}`,
detail: `${rows} rows`,
dismissible: true,
}
}
// importing: rows only start arriving once the worker is processing; before that it's the upload.
if (entry.rowsProcessed > 0) {
return {
status: 'pending',
title: `Processing ${name}`,
detail: `${rows} rows`,
dismissible: false,
}
}
return { status: 'pending', title: `Uploading ${name}`, meta, dismissible: false }
}
@@ -0,0 +1 @@
export * from './import-progress-menu'
@@ -0,0 +1,185 @@
'use client'
import { useEffect, useMemo, useRef } from 'react'
import { toast } from '@sim/emcn'
import { useShallow } from 'zustand/react/shallow'
import { useTablesList, useWorkspaceExportJobs } from '@/hooks/queries/tables'
import { useImportTrayStore } from '@/stores/table/import-tray/store'
const READY_AUTO_CLEAR_MS = 6000
const POLL_INTERVAL_MS = 2000
export type ImportPhase = 'importing' | 'ready' | 'failed'
/** A row rendered in the job tray. Import rows come live from the table list (uploads are
* client-only until their server row exists); export rows come from the workspace export-jobs
* query. Delete/backfill jobs are intentionally excluded — the grid reflects them directly. */
export interface ImportRow {
/** Table id for imports/uploads; job id for exports (a table can have several exports). */
id: string
/** The table the job belongs to — cancel and download need it for export rows. */
tableId: string
workspaceId: string
title: string
phase: ImportPhase
jobType: 'import' | 'export'
rowsProcessed: number
/** Upload byte percent (upload phase only). */
percent?: number
error?: string
jobId?: string
/** Export rows: whether the generated file is downloadable. */
hasResult?: boolean
}
/**
* Single source for the import tray. Importing rows are derived live from the table list (polled
* while any job is in flight) rather than mirrored into a store; the store only supplies
* optimistic uploads and which terminal completions to surface this session. Also fires the
* completion toasts on the importing → terminal transition. Delete jobs never render as tray rows
* and only surface a toast on failure (a failed delete restores the optimistically-removed rows).
*/
export function useWorkspaceImports(
workspaceId: string | undefined,
scopeTableId?: string
): ImportRow[] {
const { data: tables } = useTablesList(workspaceId, 'active', {
refetchInterval: (list) =>
list?.some((t) => t.jobStatus === 'running') ? POLL_INTERVAL_MS : false,
})
// Exports are excluded from the table-level job derivation (they run concurrently with other
// jobs), so the tray reads them from their dedicated workspace listing.
const { data: exportJobs } = useWorkspaceExportJobs(workspaceId)
const prevStatusRef = useRef<Map<string, string> | null>(null)
useEffect(() => {
if (!tables) return
const prevStatus = (prevStatusRef.current ??= new Map())
const store = useImportTrayStore.getState()
for (const table of tables) {
const before = prevStatus.get(table.id)
const now = table.jobStatus ?? 'none'
if (before === 'running' && now === 'ready') {
// Success toast only for imports — deletes reflect instantly in the grid and backfills
// live-fill cells; announcing them would be noise.
if (table.jobType === 'import') {
const rows = (table.jobRowsProcessed ?? 0).toLocaleString()
toast.success(`Imported ${rows} rows into "${table.name}"`)
store.notify(table.id)
setTimeout(() => useImportTrayStore.getState().dismiss(table.id), READY_AUTO_CLEAR_MS)
}
} else if (before === 'running' && now === 'failed') {
// Surface every failure — e.g. a failed delete restores the optimistically-removed rows,
// and a failed backfill leaves cells unfilled; the user should know why.
const fallback =
table.jobType === 'delete'
? `Delete failed for "${table.name}"`
: table.jobType === 'backfill'
? `Column backfill failed for "${table.name}"`
: `Import failed for "${table.name}"`
toast.error(table.jobError || fallback)
if (table.jobType === 'import') store.notify(table.id)
}
if (now !== 'running' && store.isCanceled(table.id)) store.consumeCanceled(table.id)
prevStatus.set(table.id, now)
}
}, [tables])
const uploads = useImportTrayStore(useShallow((s) => Object.values(s.uploads)))
const notified = useImportTrayStore((s) => s.notified)
const canceledIds = useImportTrayStore((s) => s.canceledIds)
const dismissedIds = useImportTrayStore((s) => s.dismissedIds)
return useMemo(() => {
const rows: ImportRow[] = []
const seen = new Set<string>()
for (const table of tables ?? []) {
if (scopeTableId && table.id !== scopeTableId) continue
// Of the table-derived jobs, only imports render here: deletes reflect optimistically in
// the grid and backfills live-fill cells via SSE. (Exports merge in below.)
if (table.jobType !== 'import') continue
if (table.jobStatus === 'running') {
if (canceledIds[table.id]) continue
rows.push({
id: table.id,
tableId: table.id,
workspaceId: table.workspaceId,
title: table.name,
phase: 'importing',
jobType: 'import',
rowsProcessed: table.jobRowsProcessed ?? 0,
jobId: table.jobId ?? undefined,
})
seen.add(table.id)
} else if (
(table.jobStatus === 'ready' || table.jobStatus === 'failed') &&
notified[table.id]
) {
rows.push({
id: table.id,
tableId: table.id,
workspaceId: table.workspaceId,
title: table.name,
phase: table.jobStatus,
jobType: 'import',
rowsProcessed: table.jobRowsProcessed ?? 0,
error: table.jobError ?? undefined,
})
seen.add(table.id)
}
}
for (const upload of uploads) {
if (upload.workspaceId !== workspaceId) continue
if (scopeTableId && upload.uploadId !== scopeTableId) continue
if (canceledIds[upload.uploadId] || seen.has(upload.uploadId)) continue
rows.push({
id: upload.uploadId,
tableId: upload.uploadId,
workspaceId: upload.workspaceId,
title: upload.title,
phase: 'importing',
jobType: 'import',
rowsProcessed: 0,
percent: upload.percent,
})
}
// Export rows: running ones always; terminal ready stays listed (re-downloadable) until the
// server's visibility window lapses or the user dismisses it. Keyed by jobId.
for (const job of exportJobs ?? []) {
if (!workspaceId) break
if (scopeTableId && job.tableId !== scopeTableId) continue
if (job.status === 'canceled' || canceledIds[job.jobId] || dismissedIds[job.jobId]) continue
if (job.status === 'running') {
rows.push({
id: job.jobId,
tableId: job.tableId,
workspaceId,
title: job.tableName,
phase: 'importing',
jobType: 'export',
rowsProcessed: job.rowsProcessed,
jobId: job.jobId,
})
} else {
rows.push({
id: job.jobId,
tableId: job.tableId,
workspaceId,
title: job.tableName,
phase: job.status === 'ready' ? 'ready' : 'failed',
jobType: 'export',
rowsProcessed: job.rowsProcessed,
jobId: job.jobId,
hasResult: job.hasResult,
error: job.error ?? undefined,
})
}
}
rows.sort((a, b) => (a.phase === b.phase ? 0 : a.phase === 'importing' ? -1 : 1))
return rows
}, [tables, exportJobs, uploads, notified, canceledIds, dismissedIds, workspaceId, scopeTableId])
}
@@ -0,0 +1,4 @@
export * from './import-csv-dialog'
export * from './import-progress-menu'
export * from './table-context-menu'
export * from './tables-list-context-menu'
@@ -0,0 +1 @@
export { TableContextMenu } from './table-context-menu'
@@ -0,0 +1,110 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Upload,
} from '@sim/emcn'
import { Database, Download, Duplicate, Pencil, Trash } from '@sim/emcn/icons'
interface TableContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onCopyId?: () => void
onDelete?: () => void
onViewSchema?: () => void
onRename?: () => void
onImportCsv?: () => void
onExportCsv?: () => void
disableDelete?: boolean
disableRename?: boolean
disableImport?: boolean
disableExport?: boolean
menuRef?: React.RefObject<HTMLDivElement | null>
}
export function TableContextMenu({
isOpen,
position,
onClose,
onCopyId,
onDelete,
onViewSchema,
onRename,
onImportCsv,
onExportCsv,
disableDelete = false,
disableRename = false,
disableImport = false,
disableExport = false,
}: TableContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{onViewSchema && (
<DropdownMenuItem onSelect={onViewSchema}>
<Database />
View Schema
</DropdownMenuItem>
)}
{onRename && (
<DropdownMenuItem disabled={disableRename} onSelect={onRename}>
<Pencil />
Rename
</DropdownMenuItem>
)}
{onImportCsv && (
<DropdownMenuItem disabled={disableImport} onSelect={onImportCsv}>
<Upload />
Import CSV
</DropdownMenuItem>
)}
{onExportCsv && (
<DropdownMenuItem disabled={disableExport} onSelect={onExportCsv}>
<Download />
Export CSV
</DropdownMenuItem>
)}
{(onViewSchema || onRename || onImportCsv || onExportCsv) && (onCopyId || onDelete) && (
<DropdownMenuSeparator />
)}
{onCopyId && (
<DropdownMenuItem onSelect={onCopyId}>
<Duplicate />
Copy ID
</DropdownMenuItem>
)}
{onCopyId && onDelete && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
<Trash />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1 @@
export { TablesListContextMenu } from './tables-list-context-menu'
@@ -0,0 +1,68 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Upload,
} from '@sim/emcn'
import { Plus } from '@sim/emcn/icons'
interface TablesListContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onCreateTable?: () => void
onUploadCsv?: () => void
disableCreate?: boolean
disableUpload?: boolean
}
export function TablesListContextMenu({
isOpen,
position,
onClose,
onCreateTable,
onUploadCsv,
disableCreate = false,
disableUpload = false,
}: TablesListContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{onCreateTable && (
<DropdownMenuItem disabled={disableCreate} onSelect={onCreateTable}>
<Plus />
Create table
</DropdownMenuItem>
)}
{onUploadCsv && (
<DropdownMenuItem disabled={disableUpload} onSelect={onUploadCsv}>
<Upload />
Import CSV
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}