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
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:
+525
@@ -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'
|
||||
+117
@@ -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>
|
||||
)
|
||||
}
|
||||
+84
@@ -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'
|
||||
+185
@@ -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'
|
||||
+110
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { TablesListContextMenu } from './tables-list-context-menu'
|
||||
+68
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user