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,735 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ComboboxOption } from '@sim/emcn'
import { ChipCombobox, ChipConfirmModal, Plus, toast, Upload } from '@sim/emcn'
import { Columns3, Rows3, Table as TableIcon } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { useParams, useRouter } from 'next/navigation'
import { debounce, useQueryStates } from 'nuqs'
import type { TableDefinition } from '@/lib/table'
import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants'
import type {
FilterTag,
ResourceAction,
ResourceColumn,
ResourceRow,
SearchConfig,
SortConfig,
} from '@/app/workspace/[workspaceId]/components'
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
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 {
DEFAULT_TABLE_SORT_COLUMN,
DEFAULT_TABLE_SORT_DIRECTION,
TABLE_SORT_COLUMNS,
type TableSortColumn,
tablesParsers,
tablesUrlKeys,
} from '@/app/workspace/[workspaceId]/tables/search-params'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import {
cancelTableJob,
downloadTableExport,
useCreateTable,
useDeleteTable,
useImportCsvAsync,
useRenameTable,
useTablesList,
useUploadCsvToTable,
} from '@/hooks/queries/tables'
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')
/** Debounce window for `search` URL writes; the input itself stays instant. */
const SEARCH_DEBOUNCE_MS = 300 as const
const COLUMNS: ResourceColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'columns', header: 'Columns' },
{ id: 'rows', header: 'Rows' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
export function Tables() {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const { config: permissionConfig } = usePermissionConfig()
useEffect(() => {
if (permissionConfig.hideTablesTab) {
router.replace(`/workspace/${workspaceId}`)
}
}, [permissionConfig.hideTablesTab, router, workspaceId])
const userPermissions = useUserPermissionsContext()
const { data: tables = [], error } = useTablesList(workspaceId)
const { data: members } = useWorkspaceMembersQuery(workspaceId)
if (error) {
logger.error('Failed to load tables:', error)
}
const deleteTable = useDeleteTable(workspaceId)
const renameTable = useRenameTable(workspaceId)
const createTable = useCreateTable(workspaceId)
const uploadCsv = useUploadCsvToTable()
const importCsvAsync = useImportCsvAsync()
const tableRename = useInlineRename({
onSave: (tableId, name) => renameTable.mutateAsync({ tableId, name }),
})
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
const [activeTable, setActiveTable] = useState<TableDefinition | null>(null)
const [
{
search: urlSearchTerm,
sort: sortColumn,
dir: sortDirection,
rows: rowCountFilter,
owner: ownerFilter,
},
setTableFilters,
] = useQueryStates(tablesParsers, tablesUrlKeys)
/**
* The input is controlled directly by the instant nuqs value; only the URL
* write is debounced. The in-memory filter below still reads a debounced value
* so it doesn't recompute on every keystroke.
*/
const setSearchTerm = useCallback(
(value: string) => {
const trimmed = value.trim()
const next = trimmed.length > 0 ? trimmed : null
setTableFilters(
{ search: next },
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
)
},
[setTableFilters]
)
const debouncedSearchTerm = useDebounce(urlSearchTerm, 300)
/**
* The resolved sort is exposed to the sort menu only when it differs from the
* default, mirroring the prior `null`-means-default semantics.
*/
const activeSort = useMemo(
() =>
sortColumn === DEFAULT_TABLE_SORT_COLUMN && sortDirection === DEFAULT_TABLE_SORT_DIRECTION
? null
: { column: sortColumn, direction: sortDirection },
[sortColumn, sortDirection]
)
const setRowCountFilter = useCallback(
(next: string[]) => setTableFilters({ rows: next }),
[setTableFilters]
)
const setOwnerFilter = useCallback(
(next: string[]) => setTableFilters({ owner: next }),
[setTableFilters]
)
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
const uploading = uploadProgress.total > 0
const csvInputRef = useRef<HTMLInputElement>(null)
const {
isOpen: isListContextMenuOpen,
position: listContextMenuPosition,
handleContextMenu: handleListContextMenu,
closeMenu: closeListContextMenu,
} = useContextMenu()
const {
isOpen: isRowContextMenuOpen,
position: rowContextMenuPosition,
handleContextMenu: handleRowCtxMenu,
closeMenu: closeRowContextMenu,
} = useContextMenu()
const processedTables = useMemo(() => {
let result = debouncedSearchTerm
? tables.filter((t) => t.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()))
: tables
if (rowCountFilter.length > 0) {
result = result.filter((t) => {
if (rowCountFilter.includes('empty') && t.rowCount === 0) return true
if (rowCountFilter.includes('small') && t.rowCount >= 1 && t.rowCount <= 100) return true
if (rowCountFilter.includes('large') && t.rowCount > 100) return true
return false
})
}
if (ownerFilter.length > 0) {
result = result.filter((t) => ownerFilter.includes(t.createdBy))
}
const col = activeSort?.column ?? 'updated'
const dir = activeSort?.direction ?? 'desc'
return [...result].sort((a, b) => {
let cmp = 0
switch (col) {
case 'name':
cmp = a.name.localeCompare(b.name)
break
case 'columns':
cmp = a.schema.columns.length - b.schema.columns.length
break
case 'rows':
cmp = a.rowCount - b.rowCount
break
case 'created':
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
break
case 'updated':
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
break
case 'owner': {
const aName = members?.find((m) => m.userId === a.createdBy)?.name ?? ''
const bName = members?.find((m) => m.userId === b.createdBy)?.name ?? ''
cmp = aName.localeCompare(bName)
break
}
}
return dir === 'asc' ? cmp : -cmp
})
}, [tables, debouncedSearchTerm, rowCountFilter, ownerFilter, activeSort, members])
const rows: ResourceRow[] = useMemo(
() =>
processedTables.map((table) => ({
id: table.id,
cells: {
name: {
icon: <TableIcon className='size-[14px]' />,
label: table.name,
editing:
tableRename.editingId === table.id
? {
value: tableRename.editValue,
onChange: tableRename.setEditValue,
onSubmit: tableRename.submitRename,
onCancel: tableRename.cancelRename,
}
: undefined,
},
columns: {
icon: <Columns3 className='size-[14px]' />,
label: String(table.schema.columns.length),
},
rows: {
icon: <Rows3 className='size-[14px]' />,
label: String(table.rowCount),
},
created: timeCell(table.createdAt),
owner: ownerCell(table.createdBy, members),
updated: timeCell(table.updatedAt),
},
})),
[
processedTables,
members,
tableRename.editingId,
tableRename.editValue,
tableRename.setEditValue,
tableRename.submitRename,
tableRename.cancelRename,
]
)
const searchConfig: SearchConfig = useMemo(
() => ({
value: urlSearchTerm,
onChange: setSearchTerm,
onClearAll: () => setSearchTerm(''),
placeholder: 'Search tables...',
}),
[urlSearchTerm, setSearchTerm]
)
const sortConfig: SortConfig = useMemo(
() => ({
options: [
{ id: 'name', label: 'Name' },
{ id: 'columns', label: 'Columns' },
{ id: 'rows', label: 'Rows' },
{ id: 'created', label: 'Created' },
{ id: 'owner', label: 'Owner' },
{ id: 'updated', label: 'Last Updated' },
],
active: activeSort,
onSort: (column, direction) => {
const sort = (TABLE_SORT_COLUMNS as readonly string[]).includes(column)
? (column as TableSortColumn)
: DEFAULT_TABLE_SORT_COLUMN
setTableFilters({ sort, dir: direction })
},
onClear: () =>
setTableFilters({
sort: DEFAULT_TABLE_SORT_COLUMN,
dir: DEFAULT_TABLE_SORT_DIRECTION,
}),
}),
[activeSort, setTableFilters]
)
const rowCountDisplayLabel = useMemo(() => {
if (rowCountFilter.length === 0) return 'All'
if (rowCountFilter.length === 1) {
const labels: Record<string, string> = {
empty: 'Empty',
small: 'Small (1100)',
large: 'Large (101+)',
}
return labels[rowCountFilter[0]] ?? rowCountFilter[0]
}
return `${rowCountFilter.length} selected`
}, [rowCountFilter])
const ownerDisplayLabel = useMemo(() => {
if (ownerFilter.length === 0) return 'All'
if (ownerFilter.length === 1)
return members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'
return `${ownerFilter.length} members`
}, [ownerFilter, members])
const memberOptions: ComboboxOption[] = useMemo(
() =>
(members ?? []).map((m) => ({
value: m.userId,
label: m.name,
iconElement: m.image ? (
<img
src={m.image}
alt={m.name}
referrerPolicy='no-referrer'
className='size-[14px] rounded-full border border-[var(--border)] object-cover'
/>
) : (
<span className='flex size-[14px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
{m.name.charAt(0).toUpperCase()}
</span>
),
})),
[members]
)
const hasActiveFilters = rowCountFilter.length > 0 || ownerFilter.length > 0
const filterContent = useMemo(
() => (
<div className='flex w-[240px] flex-col gap-3 p-3'>
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Row Count</span>
<ChipCombobox
options={[
{ value: 'empty', label: 'Empty' },
{ value: 'small', label: 'Small (1100 rows)' },
{ value: 'large', label: 'Large (101+ rows)' },
]}
multiSelect
multiSelectValues={rowCountFilter}
onMultiSelectChange={setRowCountFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{rowCountDisplayLabel}</span>
}
showAllOption
allOptionLabel='All'
className='w-full'
/>
</div>
{memberOptions.length > 0 && (
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Owner</span>
<ChipCombobox
options={memberOptions}
multiSelect
multiSelectValues={ownerFilter}
onMultiSelectChange={setOwnerFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{ownerDisplayLabel}</span>
}
searchable
searchPlaceholder='Search members...'
showAllOption
allOptionLabel='All'
className='w-full'
/>
</div>
)}
{hasActiveFilters && (
<button
type='button'
onClick={() => {
setRowCountFilter([])
setOwnerFilter([])
}}
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
>
Clear all filters
</button>
)}
</div>
),
[
rowCountFilter,
ownerFilter,
memberOptions,
rowCountDisplayLabel,
ownerDisplayLabel,
hasActiveFilters,
]
)
const filterTags: FilterTag[] = useMemo(() => {
const tags: FilterTag[] = []
if (rowCountFilter.length > 0) {
const rowLabels: Record<string, string> = { empty: 'Empty', small: 'Small', large: 'Large' }
const label =
rowCountFilter.length === 1
? `Rows: ${rowLabels[rowCountFilter[0]]}`
: `Rows: ${rowCountFilter.length} selected`
tags.push({ label, onRemove: () => setRowCountFilter([]) })
}
if (ownerFilter.length > 0) {
const label =
ownerFilter.length === 1
? `Owner: ${members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'}`
: `Owner: ${ownerFilter.length} members`
tags.push({ label, onRemove: () => setOwnerFilter([]) })
}
return tags
}, [rowCountFilter, ownerFilter, members])
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
if (
target.closest('[data-resource-row]') ||
target.closest('button, input, a, [role="button"]')
) {
return
}
handleListContextMenu(e)
},
[handleListContextMenu]
)
const handleRowClick = useCallback(
(rowId: string) => {
if (!isRowContextMenuOpen) {
router.push(`/workspace/${workspaceId}/tables/${rowId}`)
}
},
[isRowContextMenuOpen, router, workspaceId]
)
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
const table = tables.find((t) => t.id === rowId) ?? null
setActiveTable(table)
handleRowCtxMenu(e)
},
[tables, handleRowCtxMenu]
)
const handleDelete = async () => {
if (!activeTable) return
try {
await deleteTable.mutateAsync(activeTable.id)
setIsDeleteDialogOpen(false)
setActiveTable(null)
} catch (err) {
logger.error('Failed to delete table:', err)
}
}
const handleCsvChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const list = e.target.files
if (!list || list.length === 0 || !workspaceId) return
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
}
// 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)
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 cancelTableJob(workspaceId, result.tableId, result.importId).catch(() => {})
}
} catch {
// The hook's onError surfaces the toast; just clear the tray indicator here.
useImportTrayStore.getState().endUpload(pendingId)
}
}
if (syncFiles.length === 0) return
setUploadProgress({ completed: 0, total: syncFiles.length })
const failed: string[] = []
for (let i = 0; i < syncFiles.length; i++) {
const file = syncFiles[i]
try {
const result = await uploadCsv.mutateAsync({ workspaceId, file })
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(file.name)
logger.error('Error uploading CSV:', err)
} finally {
setUploadProgress({ completed: i + 1, total: syncFiles.length })
}
}
if (failed.length > 0) {
toast.error(
failed.length === 1
? `Failed to import ${failed[0]}`
: `Failed to import ${failed.length} file${failed.length > 1 ? 's' : ''}: ${failed.join(', ')}`
)
}
} catch (err) {
logger.error('Error uploading CSV:', err)
toast.error('Failed to import CSV')
} finally {
setUploadProgress({ completed: 0, total: 0 })
if (csvInputRef.current) {
csvInputRef.current.value = ''
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5
[workspaceId, router]
)
const handleListUploadCsv = useCallback(() => {
csvInputRef.current?.click()
closeListContextMenu()
}, [closeListContextMenu])
const uploadButtonLabel = uploading
? `${uploadProgress.completed}/${uploadProgress.total}`
: 'Import CSV'
// `mutateAsync` is stable in TanStack Query v5 — extract it so the callback
// can list it as a dep instead of the unstable mutation object.
const createTableAsync = createTable.mutateAsync
const handleCreateTable = useCallback(async () => {
const existingNames = tables.map((t) => t.name)
const name = generateUniqueTableName(existingNames)
try {
const result = await createTableAsync({
name,
schema: {
columns: [{ name: 'name', type: 'string' }],
},
initialRowCount: 1,
})
const tableId = result?.data?.table?.id
if (tableId) {
router.push(`/workspace/${workspaceId}/tables/${tableId}`)
}
} catch (err) {
logger.error('Failed to create table:', err)
}
}, [tables, router, workspaceId, createTableAsync])
const headerActions: ResourceAction[] = useMemo(
() => [
{
text: uploadButtonLabel,
icon: Upload,
onSelect: () => csvInputRef.current?.click(),
disabled: uploading || userPermissions.canEdit !== true,
},
{
text: 'New table',
icon: Plus,
onSelect: handleCreateTable,
disabled: uploading || userPermissions.canEdit !== true || createTable.isPending,
variant: 'primary',
},
],
[
uploadButtonLabel,
uploading,
userPermissions.canEdit,
handleCreateTable,
createTable.isPending,
]
)
// Stable identities so the memoized Resource.Header / Resource.Options can
// actually bail — inline object/element props would defeat their memo.
const headerAside = useMemo(() => <ImportProgressMenu workspaceId={workspaceId} />, [workspaceId])
const filterConfig = useMemo(() => ({ content: filterContent }), [filterContent])
return (
<>
<Resource onContextMenu={handleContentContextMenu}>
<Resource.Header
icon={TableIcon}
title='Tables'
actions={headerActions}
aside={headerAside}
/>
<Resource.Options
search={searchConfig}
sort={sortConfig}
filterTags={filterTags}
filter={filterConfig}
/>
<Resource.Table
columns={COLUMNS}
rows={rows}
onRowClick={handleRowClick}
onRowContextMenu={handleRowContextMenu}
/>
</Resource>
<input
ref={csvInputRef}
type='file'
className='hidden'
onChange={handleCsvChange}
disabled={uploading}
accept='.csv,.tsv'
multiple
/>
<TablesListContextMenu
isOpen={isListContextMenuOpen}
position={listContextMenuPosition}
onClose={closeListContextMenu}
onCreateTable={handleCreateTable}
onUploadCsv={handleListUploadCsv}
disableCreate={userPermissions.canEdit !== true || createTable.isPending}
disableUpload={uploading || userPermissions.canEdit !== true}
/>
<TableContextMenu
isOpen={isRowContextMenuOpen}
position={rowContextMenuPosition}
onClose={closeRowContextMenu}
onCopyId={() => {
if (activeTable) navigator.clipboard.writeText(activeTable.id)
}}
onDelete={() => setIsDeleteDialogOpen(true)}
onRename={() => {
if (activeTable) tableRename.startRename(activeTable.id, activeTable.name)
}}
onImportCsv={() => setIsImportDialogOpen(true)}
onExportCsv={async () => {
if (!activeTable) return
try {
await downloadTableExport(activeTable.id, activeTable.name)
} catch (err) {
logger.error('Failed to export table:', err)
toast.error('Failed to export table')
}
}}
disableDelete={userPermissions.canEdit !== true}
disableRename={userPermissions.canEdit !== true}
disableImport={userPermissions.canEdit !== true}
/>
{activeTable && (
<ImportCsvDialog
open={isImportDialogOpen}
onOpenChange={(open) => {
setIsImportDialogOpen(open)
if (!open) setActiveTable(null)
}}
workspaceId={workspaceId}
table={activeTable}
/>
)}
<ChipConfirmModal
open={isDeleteDialogOpen}
onOpenChange={(open) => {
setIsDeleteDialogOpen(open)
if (!open) setActiveTable(null)
}}
srTitle='Delete Table'
title='Delete Table'
text={[
'Are you sure you want to delete ',
{ text: activeTable?.name ?? 'this table', bold: true },
'? ',
{ text: `All ${activeTable?.rowCount ?? 0} rows will be removed.`, error: true },
' You can restore it from Recently Deleted in Settings.',
]}
confirm={{
label: 'Delete',
onClick: handleDelete,
pending: deleteTable.isPending,
pendingLabel: 'Deleting...',
}}
/>
</>
)
}