breaking down file

This commit is contained in:
Lakee Sivaraya
2026-01-14 12:13:29 -08:00
parent c155d8ac6c
commit 4d176c0717
19 changed files with 1346 additions and 961 deletions

View File

@@ -1,961 +0,0 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQuery } from '@tanstack/react-query'
import { Copy, Edit, Info, Plus, RefreshCw, Trash2, X } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
Badge,
Button,
Checkbox,
Modal,
ModalBody,
ModalContent,
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition, TableSchema } from '@/lib/table'
import { AddRowModal } from './components/add-row-modal'
import { DeleteRowModal } from './components/delete-row-modal'
import { EditRowModal } from './components/edit-row-modal'
import { FilterBuilder, type QueryOptions } from './components/filter-builder'
import { TableActionBar } from './components/table-action-bar'
const logger = createLogger('TableDataViewer')
/** Number of rows to fetch per page */
const ROWS_PER_PAGE = 100
/** Maximum length for string display before truncation */
const STRING_TRUNCATE_LENGTH = 50
/**
* Represents row data stored in a table.
*/
interface TableRowData {
/** Unique identifier for the row */
id: string
/** Row field values keyed by column name */
data: Record<string, unknown>
/** ISO timestamp when the row was created */
createdAt: string
/** ISO timestamp when the row was last updated */
updatedAt: string
}
/**
* Represents table metadata.
*/
interface TableData {
/** Unique identifier for the table */
id: string
/** Table name */
name: string
/** Optional description */
description?: string
/** Schema defining columns */
schema: TableSchema
/** Current number of rows */
rowCount: number
/** Maximum allowed rows */
maxRows: number
/** ISO timestamp when created */
createdAt: string
/** ISO timestamp when last updated */
updatedAt: string
}
/**
* Data for the cell viewer modal.
*/
interface CellViewerData {
/** Name of the column being viewed */
columnName: string
/** Value being displayed */
value: unknown
/** Display type for formatting */
type: 'json' | 'text' | 'date'
}
/**
* State for the right-click context menu.
*/
interface ContextMenuState {
/** Whether the menu is visible */
isOpen: boolean
/** Screen position of the menu */
position: { x: number; y: number }
/** Row the menu was opened on */
row: TableRowData | null
}
/**
* Gets the badge variant for a column type.
*
* @param type - The column type
* @returns Badge variant name
*/
function getTypeBadgeVariant(
type: string
): 'green' | 'blue' | 'purple' | 'orange' | 'teal' | 'gray' {
switch (type) {
case 'string':
return 'green'
case 'number':
return 'blue'
case 'boolean':
return 'purple'
case 'json':
return 'orange'
case 'date':
return 'teal'
default:
return 'gray'
}
}
/**
* Main component for viewing and managing table data.
*
* @remarks
* Provides functionality for:
* - Viewing rows with pagination
* - Filtering and sorting
* - Adding, editing, and deleting rows
* - Viewing cell details for long/complex values
*
* @example
* ```tsx
* <TableDataViewer />
* ```
*/
export function TableDataViewer() {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const tableId = params.tableId as string
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
const [queryOptions, setQueryOptions] = useState<QueryOptions>({
filter: null,
sort: null,
})
const [currentPage, setCurrentPage] = useState(0)
const [showAddModal, setShowAddModal] = useState(false)
const [editingRow, setEditingRow] = useState<TableRowData | null>(null)
const [deletingRows, setDeletingRows] = useState<string[]>([])
const [cellViewer, setCellViewer] = useState<CellViewerData | null>(null)
const [showSchemaModal, setShowSchemaModal] = useState(false)
const [copied, setCopied] = useState(false)
// Context menu state
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
isOpen: false,
position: { x: 0, y: 0 },
row: null,
})
const contextMenuRef = useRef<HTMLDivElement>(null)
// Fetch table metadata
const { data: tableData, isLoading: isLoadingTable } = useQuery({
queryKey: ['table', tableId],
queryFn: async () => {
const res = await fetch(`/api/table/${tableId}?workspaceId=${workspaceId}`)
if (!res.ok) throw new Error('Failed to fetch table')
const json: { data?: { table: TableData }; table?: TableData } = await res.json()
const data = json.data || json
return (data as { table: TableData }).table
},
})
// Fetch table rows with filter and sort
const {
data: rowsData,
isLoading: isLoadingRows,
refetch: refetchRows,
} = useQuery({
queryKey: ['table-rows', tableId, queryOptions, currentPage],
queryFn: async () => {
const searchParams = new URLSearchParams({
workspaceId,
limit: String(ROWS_PER_PAGE),
offset: String(currentPage * ROWS_PER_PAGE),
})
if (queryOptions.filter) {
searchParams.set('filter', JSON.stringify(queryOptions.filter))
}
if (queryOptions.sort) {
// Convert from {column, direction} to {column: direction} format expected by API
const sortParam = { [queryOptions.sort.column]: queryOptions.sort.direction }
searchParams.set('sort', JSON.stringify(sortParam))
}
const res = await fetch(`/api/table/${tableId}/rows?${searchParams}`)
if (!res.ok) throw new Error('Failed to fetch rows')
const json: {
data?: { rows: TableRowData[]; totalCount: number }
rows?: TableRowData[]
totalCount?: number
} = await res.json()
return json.data || json
},
enabled: !!tableData,
})
const columns = tableData?.schema?.columns || []
const rows = (rowsData?.rows || []) as TableRowData[]
const totalCount = rowsData?.totalCount || 0
const totalPages = Math.ceil(totalCount / ROWS_PER_PAGE)
/**
* Applies new query options and resets pagination.
*/
const handleApplyQueryOptions = useCallback((options: QueryOptions) => {
setQueryOptions(options)
setCurrentPage(0)
}, [])
/**
* Toggles selection of all visible rows.
*/
const handleSelectAll = useCallback(() => {
if (selectedRows.size === rows.length) {
setSelectedRows(new Set())
} else {
setSelectedRows(new Set(rows.map((r) => r.id)))
}
}, [rows, selectedRows.size])
/**
* Toggles selection of a single row.
*/
const handleSelectRow = useCallback((rowId: string) => {
setSelectedRows((prev) => {
const newSet = new Set(prev)
if (newSet.has(rowId)) {
newSet.delete(rowId)
} else {
newSet.add(rowId)
}
return newSet
})
}, [])
/**
* Refreshes the rows data.
*/
const handleRefresh = useCallback(() => {
refetchRows()
}, [refetchRows])
/**
* Opens the delete modal for selected rows.
*/
const handleDeleteSelected = useCallback(() => {
setDeletingRows(Array.from(selectedRows))
}, [selectedRows])
/**
* Opens the context menu for a row.
*/
const handleRowContextMenu = useCallback((e: React.MouseEvent, row: TableRowData) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({
isOpen: true,
position: { x: e.clientX, y: e.clientY },
row,
})
}, [])
/**
* Closes the context menu.
*/
const closeContextMenu = useCallback(() => {
setContextMenu((prev) => ({ ...prev, isOpen: false }))
}, [])
/**
* Handles edit action from context menu.
*/
const handleContextMenuEdit = useCallback(() => {
if (contextMenu.row) {
setEditingRow(contextMenu.row)
}
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
/**
* Handles delete action from context menu.
*/
const handleContextMenuDelete = useCallback(() => {
if (contextMenu.row) {
setDeletingRows([contextMenu.row.id])
}
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
/**
* Copies the current cell value to clipboard.
*/
const handleCopyCellValue = useCallback(async () => {
if (cellViewer) {
let text: string
if (cellViewer.type === 'json') {
text = JSON.stringify(cellViewer.value, null, 2)
} else if (cellViewer.type === 'date') {
text = String(cellViewer.value)
} else {
text = String(cellViewer.value)
}
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}, [cellViewer])
/**
* Opens the cell viewer modal.
*/
const handleCellClick = useCallback(
(e: React.MouseEvent, columnName: string, value: unknown, type: 'json' | 'text' | 'date') => {
e.preventDefault()
e.stopPropagation()
setCellViewer({ columnName, value, type })
},
[]
)
/**
* Renders a cell value with appropriate formatting.
*/
const renderCellValue = (value: unknown, column: ColumnDefinition) => {
const isNull = value === null || value === undefined
if (isNull) {
return <span className='text-[var(--text-muted)] italic'></span>
}
if (column.type === 'json') {
const jsonStr = JSON.stringify(value)
return (
<button
type='button'
className='block max-w-[300px] cursor-pointer select-none truncate rounded-[4px] border border-[var(--border-1)] px-[6px] py-[2px] text-left font-mono text-[11px] text-[var(--text-secondary)] transition-colors hover:border-[var(--text-muted)] hover:text-[var(--text-primary)]'
onClick={(e) => handleCellClick(e, column.name, value, 'json')}
title='Click to view full JSON'
>
{jsonStr}
</button>
)
}
if (column.type === 'boolean') {
const boolValue = Boolean(value)
return (
<span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
{boolValue ? 'true' : 'false'}
</span>
)
}
if (column.type === 'number') {
return (
<span className='font-mono text-[12px] text-[var(--text-secondary)]'>{String(value)}</span>
)
}
if (column.type === 'date') {
try {
const date = new Date(String(value))
const formatted = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
return (
<button
type='button'
className='cursor-pointer select-none text-left text-[12px] text-[var(--text-secondary)] underline decoration-[var(--border-1)] decoration-dotted underline-offset-2 transition-colors hover:text-[var(--text-primary)] hover:decoration-[var(--text-muted)]'
onClick={(e) => handleCellClick(e, column.name, value, 'date')}
title='Click to view ISO format'
>
{formatted}
</button>
)
} catch {
return <span className='text-[var(--text-primary)]'>{String(value)}</span>
}
}
// Handle long strings
const strValue = String(value)
if (strValue.length > STRING_TRUNCATE_LENGTH) {
return (
<button
type='button'
className='block max-w-[300px] cursor-pointer select-none truncate text-left text-[var(--text-primary)] underline decoration-[var(--border-1)] decoration-dotted underline-offset-2 transition-colors hover:decoration-[var(--text-muted)]'
onClick={(e) => handleCellClick(e, column.name, value, 'text')}
title='Click to view full text'
>
{strValue}
</button>
)
}
return <span className='text-[var(--text-primary)]'>{strValue}</span>
}
if (isLoadingTable) {
return (
<div className='flex h-full items-center justify-center'>
<span className='text-[13px] text-[var(--text-tertiary)]'>Loading table...</span>
</div>
)
}
if (!tableData) {
return (
<div className='flex h-full items-center justify-center'>
<span className='text-[13px] text-[var(--text-error)]'>Table not found</span>
</div>
)
}
return (
<div className='flex h-full flex-col'>
{/* Header */}
<div className='flex h-[48px] shrink-0 items-center justify-between border-[var(--border)] border-b px-[16px]'>
<div className='flex items-center gap-[8px]'>
<button
onClick={() => router.push(`/workspace/${workspaceId}/tables`)}
className='text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
Tables
</button>
<span className='text-[var(--text-muted)]'>/</span>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
{tableData.name}
</span>
{isLoadingRows ? (
<Skeleton className='h-[18px] w-[60px] rounded-full' />
) : (
<Badge variant='gray-secondary' size='sm'>
{totalCount} {totalCount === 1 ? 'row' : 'rows'}
</Badge>
)}
</div>
<div className='flex items-center gap-[8px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' size='sm' onClick={() => setShowSchemaModal(true)}>
<Info className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>View Schema</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' size='sm' onClick={handleRefresh}>
<RefreshCw className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Refresh</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
{/* Filter Bar */}
<div className='flex shrink-0 flex-col gap-[8px] border-[var(--border)] border-b px-[16px] py-[10px]'>
<FilterBuilder
columns={columns}
onApply={handleApplyQueryOptions}
onAddRow={() => setShowAddModal(true)}
/>
{selectedRows.size > 0 && (
<span className='text-[11px] text-[var(--text-tertiary)]'>
{selectedRows.size} selected
</span>
)}
</div>
{/* Action Bar */}
{selectedRows.size > 0 && (
<TableActionBar
selectedCount={selectedRows.size}
onDelete={handleDeleteSelected}
onClearSelection={() => setSelectedRows(new Set())}
/>
)}
{/* Table */}
<div className='flex-1 overflow-auto'>
<Table>
<TableHeader className='sticky top-0 z-10 bg-[var(--surface-3)]'>
<TableRow>
<TableHead className='w-[40px]'>
<Checkbox
size='sm'
checked={selectedRows.size === rows.length && rows.length > 0}
onCheckedChange={handleSelectAll}
/>
</TableHead>
{columns.map((column) => (
<TableHead key={column.name}>
<div className='flex items-center gap-[6px]'>
<span className='text-[12px]'>{column.name}</span>
<Badge variant='outline' size='sm'>
{column.type}
</Badge>
{column.required && (
<span className='text-[10px] text-[var(--text-error)]'>*</span>
)}
</div>
</TableHead>
))}
<TableHead className='w-[80px]'>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoadingRows ? (
<LoadingRows columns={columns} />
) : rows.length === 0 ? (
<EmptyRows
columnCount={columns.length}
hasFilter={!!queryOptions.filter}
onAddRow={() => setShowAddModal(true)}
/>
) : (
rows.map((row) => (
<TableRow
key={row.id}
className={cn(
'group hover:bg-[var(--surface-4)]',
selectedRows.has(row.id) && 'bg-[var(--surface-5)]'
)}
onContextMenu={(e) => handleRowContextMenu(e, row)}
>
<TableCell>
<Checkbox
size='sm'
checked={selectedRows.has(row.id)}
onCheckedChange={() => handleSelectRow(row.id)}
/>
</TableCell>
{columns.map((column) => (
<TableCell key={column.name}>
<div className='max-w-[300px] truncate text-[13px]'>
{renderCellValue(row.data[column.name], column)}
</div>
</TableCell>
))}
<TableCell>
<div className='flex items-center gap-[2px] opacity-0 transition-opacity group-hover:opacity-100'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' size='sm' onClick={() => setEditingRow(row)}>
<Edit className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Edit</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={() => setDeletingRows([row.id])}
className='text-[var(--text-tertiary)] hover:text-[var(--text-error)]'
>
<Trash2 className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Delete</Tooltip.Content>
</Tooltip.Root>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className='flex h-[40px] shrink-0 items-center justify-between border-[var(--border)] border-t px-[16px]'>
<span className='text-[11px] text-[var(--text-tertiary)]'>
Page {currentPage + 1} of {totalPages} ({totalCount} rows)
</span>
<div className='flex items-center gap-[4px]'>
<Button
variant='ghost'
size='sm'
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0}
>
Previous
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={currentPage === totalPages - 1}
>
Next
</Button>
</div>
</div>
)}
{/* Modals */}
<AddRowModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
table={tableData}
onSuccess={() => {
refetchRows()
setShowAddModal(false)
}}
/>
{editingRow && (
<EditRowModal
isOpen={true}
onClose={() => setEditingRow(null)}
table={tableData}
row={editingRow}
onSuccess={() => {
refetchRows()
setEditingRow(null)
}}
/>
)}
{deletingRows.length > 0 && (
<DeleteRowModal
isOpen={true}
onClose={() => setDeletingRows([])}
tableId={tableId}
rowIds={deletingRows}
onSuccess={() => {
refetchRows()
setDeletingRows([])
setSelectedRows(new Set())
}}
/>
)}
{/* Schema Viewer Modal */}
<SchemaViewerModal
isOpen={showSchemaModal}
onClose={() => setShowSchemaModal(false)}
columns={columns}
/>
{/* Cell Viewer Modal */}
<CellViewerModal
cellViewer={cellViewer}
onClose={() => setCellViewer(null)}
onCopy={handleCopyCellValue}
copied={copied}
/>
{/* Row Context Menu */}
<Popover
open={contextMenu.isOpen}
onOpenChange={(open) => !open && closeContextMenu()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenu.position.x}px`,
top: `${contextMenu.position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent
ref={contextMenuRef}
align='start'
side='bottom'
sideOffset={4}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<PopoverItem onClick={handleContextMenuEdit}>
<Edit className='mr-[8px] h-[12px] w-[12px]' />
Edit row
</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleContextMenuDelete} className='text-[var(--text-error)]'>
<Trash2 className='mr-[8px] h-[12px] w-[12px]' />
Delete row
</PopoverItem>
</PopoverContent>
</Popover>
</div>
)
}
/**
* Loading skeleton for table rows.
*/
function LoadingRows({ columns }: { columns: ColumnDefinition[] }) {
return (
<>
{Array.from({ length: 25 }).map((_, rowIndex) => (
<TableRow key={rowIndex}>
<TableCell>
<Skeleton className='h-[14px] w-[14px]' />
</TableCell>
{columns.map((col, colIndex) => {
const baseWidth =
col.type === 'json'
? 200
: col.type === 'string'
? 160
: col.type === 'number'
? 80
: col.type === 'boolean'
? 50
: col.type === 'date'
? 100
: 120
const variation = ((rowIndex + colIndex) % 3) * 20
const width = baseWidth + variation
return (
<TableCell key={col.name}>
<Skeleton className='h-[16px]' style={{ width: `${width}px` }} />
</TableCell>
)
})}
<TableCell>
<div className='flex gap-[4px]'>
<Skeleton className='h-[24px] w-[24px]' />
<Skeleton className='h-[24px] w-[24px]' />
</div>
</TableCell>
</TableRow>
))}
</>
)
}
/**
* Empty state for table rows.
*/
function EmptyRows({
columnCount,
hasFilter,
onAddRow,
}: {
columnCount: number
hasFilter: boolean
onAddRow: () => void
}) {
return (
<TableRow>
<TableCell colSpan={columnCount + 2} className='h-[160px] text-center'>
<div className='flex flex-col items-center gap-[12px]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
{hasFilter ? 'No rows match your filter' : 'No data'}
</span>
{!hasFilter && (
<Button variant='default' size='sm' onClick={onAddRow}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add first row
</Button>
)}
</div>
</TableCell>
</TableRow>
)
}
/**
* Modal for viewing table schema.
*/
function SchemaViewerModal({
isOpen,
onClose,
columns,
}: {
isOpen: boolean
onClose: () => void
columns: ColumnDefinition[]
}) {
return (
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent className='w-[500px] duration-100'>
<div className='flex items-center justify-between gap-[8px] px-[16px] py-[10px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
<Info className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
<span className='font-medium text-[14px] text-[var(--text-primary)]'>Table Schema</span>
<Badge variant='gray' size='sm'>
{columns.length} columns
</Badge>
</div>
<Button variant='ghost' size='sm' onClick={onClose}>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
<ModalBody className='p-0'>
<div className='max-h-[400px] overflow-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-[180px]'>Column</TableHead>
<TableHead className='w-[100px]'>Type</TableHead>
<TableHead>Constraints</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{columns.map((column) => (
<TableRow key={column.name}>
<TableCell className='font-mono text-[12px] text-[var(--text-primary)]'>
{column.name}
</TableCell>
<TableCell>
<Badge variant={getTypeBadgeVariant(column.type)} size='sm'>
{column.type}
</Badge>
</TableCell>
<TableCell className='text-[12px]'>
<div className='flex gap-[6px]'>
{column.required && (
<Badge variant='red' size='sm'>
required
</Badge>
)}
{column.unique && (
<Badge variant='purple' size='sm'>
unique
</Badge>
)}
{!column.required && !column.unique && (
<span className='text-[var(--text-muted)]'></span>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</ModalBody>
</ModalContent>
</Modal>
)
}
/**
* Modal for viewing cell details.
*/
function CellViewerModal({
cellViewer,
onClose,
onCopy,
copied,
}: {
cellViewer: CellViewerData | null
onClose: () => void
onCopy: () => void
copied: boolean
}) {
if (!cellViewer) return null
return (
<Modal open={!!cellViewer} onOpenChange={(open) => !open && onClose()}>
<ModalContent className='w-[640px] duration-100'>
<div className='flex items-center justify-between gap-[8px] px-[16px] py-[10px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
<span className='truncate font-medium text-[14px] text-[var(--text-primary)]'>
{cellViewer.columnName}
</span>
<Badge
variant={
cellViewer.type === 'json' ? 'blue' : cellViewer.type === 'date' ? 'purple' : 'gray'
}
size='sm'
>
{cellViewer.type === 'json' ? 'JSON' : cellViewer.type === 'date' ? 'Date' : 'Text'}
</Badge>
</div>
<div className='flex shrink-0 items-center gap-[8px]'>
<Button variant={copied ? 'tertiary' : 'default'} size='sm' onClick={onCopy}>
<Copy className='mr-[4px] h-[12px] w-[12px]' />
{copied ? 'Copied!' : 'Copy'}
</Button>
<Button variant='ghost' size='sm' onClick={onClose}>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
</div>
<ModalBody className='p-0'>
{cellViewer.type === 'json' ? (
<pre className='m-[16px] max-h-[450px] overflow-auto rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] font-mono text-[12px] text-[var(--text-primary)] leading-[1.6]'>
{JSON.stringify(cellViewer.value, null, 2)}
</pre>
) : cellViewer.type === 'date' ? (
<div className='m-[16px] space-y-[12px]'>
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
<div className='mb-[6px] font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
Formatted
</div>
<div className='text-[14px] text-[var(--text-primary)]'>
{new Date(String(cellViewer.value)).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
})}
</div>
</div>
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
<div className='mb-[6px] font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
ISO Format
</div>
<div className='font-mono text-[13px] text-[var(--text-secondary)]'>
{String(cellViewer.value)}
</div>
</div>
</div>
) : (
<div className='m-[16px] max-h-[450px] overflow-auto whitespace-pre-wrap break-words rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] text-[13px] text-[var(--text-primary)] leading-[1.7]'>
{String(cellViewer.value)}
</div>
)}
</ModalBody>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,112 @@
/**
* Cell value renderer for different column types.
*
* @module tables/[tableId]/table-data-viewer/components/cell-renderer
*/
import type { ColumnDefinition } from '@/lib/table'
import { STRING_TRUNCATE_LENGTH } from '../constants'
import type { CellViewerData } from '../types'
interface CellRendererProps {
value: unknown
column: ColumnDefinition
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
}
/**
* Renders a cell value with appropriate formatting based on column type.
*
* @param props - Component props
* @returns Formatted cell content
*/
export function CellRenderer({ value, column, onCellClick }: CellRendererProps) {
const isNull = value === null || value === undefined
if (isNull) {
return <span className='text-[var(--text-muted)] italic'></span>
}
if (column.type === 'json') {
const jsonStr = JSON.stringify(value)
return (
<button
type='button'
className='block max-w-[300px] cursor-pointer select-none truncate rounded-[4px] border border-[var(--border-1)] px-[6px] py-[2px] text-left font-mono text-[11px] text-[var(--text-secondary)] transition-colors hover:border-[var(--text-muted)] hover:text-[var(--text-primary)]'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onCellClick(column.name, value, 'json')
}}
title='Click to view full JSON'
>
{jsonStr}
</button>
)
}
if (column.type === 'boolean') {
const boolValue = Boolean(value)
return (
<span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
{boolValue ? 'true' : 'false'}
</span>
)
}
if (column.type === 'number') {
return (
<span className='font-mono text-[12px] text-[var(--text-secondary)]'>{String(value)}</span>
)
}
if (column.type === 'date') {
try {
const date = new Date(String(value))
const formatted = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
return (
<button
type='button'
className='cursor-pointer select-none text-left text-[12px] text-[var(--text-secondary)] underline decoration-[var(--border-1)] decoration-dotted underline-offset-2 transition-colors hover:text-[var(--text-primary)] hover:decoration-[var(--text-muted)]'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onCellClick(column.name, value, 'date')
}}
title='Click to view ISO format'
>
{formatted}
</button>
)
} catch {
return <span className='text-[var(--text-primary)]'>{String(value)}</span>
}
}
// Handle long strings
const strValue = String(value)
if (strValue.length > STRING_TRUNCATE_LENGTH) {
return (
<button
type='button'
className='block max-w-[300px] cursor-pointer select-none truncate text-left text-[var(--text-primary)] underline decoration-[var(--border-1)] decoration-dotted underline-offset-2 transition-colors hover:decoration-[var(--text-muted)]'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onCellClick(column.name, value, 'text')
}}
title='Click to view full text'
>
{strValue}
</button>
)
}
return <span className='text-[var(--text-primary)]'>{strValue}</span>
}

View File

@@ -0,0 +1,96 @@
/**
* Modal for viewing cell details.
*
* @module tables/[tableId]/table-data-viewer/components/cell-viewer-modal
*/
import { Copy, X } from 'lucide-react'
import { Badge, Button, Modal, ModalBody, ModalContent } from '@/components/emcn'
import type { CellViewerData } from '../types'
interface CellViewerModalProps {
cellViewer: CellViewerData | null
onClose: () => void
onCopy: () => void
copied: boolean
}
/**
* Displays cell value details in a modal.
*
* @param props - Component props
* @returns Cell viewer modal or null if no cell is selected
*/
export function CellViewerModal({ cellViewer, onClose, onCopy, copied }: CellViewerModalProps) {
if (!cellViewer) return null
return (
<Modal open={!!cellViewer} onOpenChange={(open) => !open && onClose()}>
<ModalContent className='w-[640px] duration-100'>
<div className='flex items-center justify-between gap-[8px] px-[16px] py-[10px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
<span className='truncate font-medium text-[14px] text-[var(--text-primary)]'>
{cellViewer.columnName}
</span>
<Badge
variant={
cellViewer.type === 'json' ? 'blue' : cellViewer.type === 'date' ? 'purple' : 'gray'
}
size='sm'
>
{cellViewer.type === 'json' ? 'JSON' : cellViewer.type === 'date' ? 'Date' : 'Text'}
</Badge>
</div>
<div className='flex shrink-0 items-center gap-[8px]'>
<Button variant={copied ? 'tertiary' : 'default'} size='sm' onClick={onCopy}>
<Copy className='mr-[4px] h-[12px] w-[12px]' />
{copied ? 'Copied!' : 'Copy'}
</Button>
<Button variant='ghost' size='sm' onClick={onClose}>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
</div>
<ModalBody className='p-0'>
{cellViewer.type === 'json' ? (
<pre className='m-[16px] max-h-[450px] overflow-auto rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] font-mono text-[12px] text-[var(--text-primary)] leading-[1.6]'>
{JSON.stringify(cellViewer.value, null, 2)}
</pre>
) : cellViewer.type === 'date' ? (
<div className='m-[16px] space-y-[12px]'>
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
<div className='mb-[6px] font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
Formatted
</div>
<div className='text-[14px] text-[var(--text-primary)]'>
{new Date(String(cellViewer.value)).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
})}
</div>
</div>
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
<div className='mb-[6px] font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
ISO Format
</div>
<div className='font-mono text-[13px] text-[var(--text-secondary)]'>
{String(cellViewer.value)}
</div>
</div>
</div>
) : (
<div className='m-[16px] max-h-[450px] overflow-auto whitespace-pre-wrap break-words rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] text-[13px] text-[var(--text-primary)] leading-[1.7]'>
{String(cellViewer.value)}
</div>
)}
</ModalBody>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,40 @@
/**
* Empty state component for table rows.
*
* @module tables/[tableId]/table-data-viewer/components/empty-rows
*/
import { Plus } from 'lucide-react'
import { Button, TableCell, TableRow } from '@/components/emcn'
interface EmptyRowsProps {
columnCount: number
hasFilter: boolean
onAddRow: () => void
}
/**
* Renders an empty state when no rows are present.
*
* @param props - Component props
* @returns Empty state row
*/
export function EmptyRows({ columnCount, hasFilter, onAddRow }: EmptyRowsProps) {
return (
<TableRow>
<TableCell colSpan={columnCount + 2} className='h-[160px] text-center'>
<div className='flex flex-col items-center gap-[12px]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
{hasFilter ? 'No rows match your filter' : 'No data'}
</span>
{!hasFilter && (
<Button variant='default' size='sm' onClick={onAddRow}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add first row
</Button>
)}
</div>
</TableCell>
</TableRow>
)
}

View File

@@ -0,0 +1,14 @@
/**
* Table data viewer sub-components.
*
* @module tables/[tableId]/table-data-viewer/components
*/
export * from './cell-renderer'
export * from './cell-viewer-modal'
export * from './empty-rows'
export * from './loading-rows'
export * from './pagination'
export * from './row-context-menu'
export * from './schema-viewer-modal'
export * from './table-header-bar'

View File

@@ -0,0 +1,61 @@
/**
* Loading skeleton for table rows.
*
* @module tables/[tableId]/table-data-viewer/components/loading-rows
*/
import { TableCell, TableRow } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import type { ColumnDefinition } from '@/lib/table'
interface LoadingRowsProps {
columns: ColumnDefinition[]
}
/**
* Renders skeleton rows while table data is loading.
*
* @param props - Component props
* @returns Loading skeleton rows
*/
export function LoadingRows({ columns }: LoadingRowsProps) {
return (
<>
{Array.from({ length: 25 }).map((_, rowIndex) => (
<TableRow key={rowIndex}>
<TableCell>
<Skeleton className='h-[14px] w-[14px]' />
</TableCell>
{columns.map((col, colIndex) => {
const baseWidth =
col.type === 'json'
? 200
: col.type === 'string'
? 160
: col.type === 'number'
? 80
: col.type === 'boolean'
? 50
: col.type === 'date'
? 100
: 120
const variation = ((rowIndex + colIndex) % 3) * 20
const width = baseWidth + variation
return (
<TableCell key={col.name}>
<Skeleton className='h-[16px]' style={{ width: `${width}px` }} />
</TableCell>
)
})}
<TableCell>
<div className='flex gap-[4px]'>
<Skeleton className='h-[24px] w-[24px]' />
<Skeleton className='h-[24px] w-[24px]' />
</div>
</TableCell>
</TableRow>
))}
</>
)
}

View File

@@ -0,0 +1,52 @@
/**
* Pagination controls for the table.
*
* @module tables/[tableId]/table-data-viewer/components/pagination
*/
import { Button } from '@/components/emcn'
interface PaginationProps {
currentPage: number
totalPages: number
totalCount: number
onPreviousPage: () => void
onNextPage: () => void
}
/**
* Renders pagination controls for navigating table pages.
*
* @param props - Component props
* @returns Pagination controls or null if only one page
*/
export function Pagination({
currentPage,
totalPages,
totalCount,
onPreviousPage,
onNextPage,
}: PaginationProps) {
if (totalPages <= 1) return null
return (
<div className='flex h-[40px] shrink-0 items-center justify-between border-[var(--border)] border-t px-[16px]'>
<span className='text-[11px] text-[var(--text-tertiary)]'>
Page {currentPage + 1} of {totalPages} ({totalCount} rows)
</span>
<div className='flex items-center gap-[4px]'>
<Button variant='ghost' size='sm' onClick={onPreviousPage} disabled={currentPage === 0}>
Previous
</Button>
<Button
variant='ghost'
size='sm'
onClick={onNextPage}
disabled={currentPage === totalPages - 1}
>
Next
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,67 @@
/**
* Context menu for row actions.
*
* @module tables/[tableId]/table-data-viewer/components/row-context-menu
*/
import { Edit, Trash2 } from 'lucide-react'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { ContextMenuState } from '../types'
interface RowContextMenuProps {
contextMenu: ContextMenuState
onClose: () => void
onEdit: () => void
onDelete: () => void
}
/**
* Renders a context menu for row actions.
*
* @param props - Component props
* @returns Row context menu popover
*/
export function RowContextMenu({ contextMenu, onClose, onEdit, onDelete }: RowContextMenuProps) {
return (
<Popover
open={contextMenu.isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenu.position.x}px`,
top: `${contextMenu.position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent
align='start'
side='bottom'
sideOffset={4}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<PopoverItem onClick={onEdit}>
<Edit className='mr-[8px] h-[12px] w-[12px]' />
Edit row
</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={onDelete} className='text-[var(--text-error)]'>
<Trash2 className='mr-[8px] h-[12px] w-[12px]' />
Delete row
</PopoverItem>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,99 @@
/**
* Modal for viewing table schema.
*
* @module tables/[tableId]/table-data-viewer/components/schema-viewer-modal
*/
import { Info, X } from 'lucide-react'
import {
Badge,
Button,
Modal,
ModalBody,
ModalContent,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/emcn'
import type { ColumnDefinition } from '@/lib/table'
import { getTypeBadgeVariant } from '../utils'
interface SchemaViewerModalProps {
isOpen: boolean
onClose: () => void
columns: ColumnDefinition[]
}
/**
* Displays the table schema in a modal.
*
* @param props - Component props
* @returns Schema viewer modal
*/
export function SchemaViewerModal({ isOpen, onClose, columns }: SchemaViewerModalProps) {
return (
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent className='w-[500px] duration-100'>
<div className='flex items-center justify-between gap-[8px] px-[16px] py-[10px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
<Info className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
<span className='font-medium text-[14px] text-[var(--text-primary)]'>Table Schema</span>
<Badge variant='gray' size='sm'>
{columns.length} columns
</Badge>
</div>
<Button variant='ghost' size='sm' onClick={onClose}>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
<ModalBody className='p-0'>
<div className='max-h-[400px] overflow-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-[180px]'>Column</TableHead>
<TableHead className='w-[100px]'>Type</TableHead>
<TableHead>Constraints</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{columns.map((column) => (
<TableRow key={column.name}>
<TableCell className='font-mono text-[12px] text-[var(--text-primary)]'>
{column.name}
</TableCell>
<TableCell>
<Badge variant={getTypeBadgeVariant(column.type)} size='sm'>
{column.type}
</Badge>
</TableCell>
<TableCell className='text-[12px]'>
<div className='flex gap-[6px]'>
{column.required && (
<Badge variant='red' size='sm'>
required
</Badge>
)}
{column.unique && (
<Badge variant='purple' size='sm'>
unique
</Badge>
)}
{!column.required && !column.unique && (
<span className='text-[var(--text-muted)]'></span>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</ModalBody>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,75 @@
/**
* Header bar for the table data viewer.
*
* @module tables/[tableId]/table-data-viewer/components/table-header-bar
*/
import { Info, RefreshCw } from 'lucide-react'
import { Badge, Button, Tooltip } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
interface TableHeaderBarProps {
tableName: string
totalCount: number
isLoading: boolean
onNavigateBack: () => void
onShowSchema: () => void
onRefresh: () => void
}
/**
* Renders the header bar with navigation, title, and actions.
*
* @param props - Component props
* @returns Table header bar
*/
export function TableHeaderBar({
tableName,
totalCount,
isLoading,
onNavigateBack,
onShowSchema,
onRefresh,
}: TableHeaderBarProps) {
return (
<div className='flex h-[48px] shrink-0 items-center justify-between border-[var(--border)] border-b px-[16px]'>
<div className='flex items-center gap-[8px]'>
<button
onClick={onNavigateBack}
className='text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
Tables
</button>
<span className='text-[var(--text-muted)]'>/</span>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>{tableName}</span>
{isLoading ? (
<Skeleton className='h-[18px] w-[60px] rounded-full' />
) : (
<Badge variant='gray-secondary' size='sm'>
{totalCount} {totalCount === 1 ? 'row' : 'rows'}
</Badge>
)}
</div>
<div className='flex items-center gap-[8px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' size='sm' onClick={onShowSchema}>
<Info className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>View Schema</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' size='sm' onClick={onRefresh}>
<RefreshCw className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Refresh</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
/**
* Constants for the table data viewer.
*
* @module tables/[tableId]/table-data-viewer/constants
*/
/** Number of rows to fetch per page */
export const ROWS_PER_PAGE = 100
/** Maximum length for string display before truncation */
export const STRING_TRUNCATE_LENGTH = 50

View File

@@ -0,0 +1,9 @@
/**
* Custom hooks for the table data viewer.
*
* @module tables/[tableId]/table-data-viewer/hooks
*/
export * from './use-context-menu'
export * from './use-row-selection'
export * from './use-table-data'

View File

@@ -0,0 +1,53 @@
/**
* Hook for managing context menu state.
*
* @module tables/[tableId]/table-data-viewer/hooks/use-context-menu
*/
import { useCallback, useState } from 'react'
import type { ContextMenuState, TableRowData } from '../types'
interface UseContextMenuReturn {
contextMenu: ContextMenuState
handleRowContextMenu: (e: React.MouseEvent, row: TableRowData) => void
closeContextMenu: () => void
}
/**
* Manages context menu state for row interactions.
*
* @returns Context menu state and handlers
*/
export function useContextMenu(): UseContextMenuReturn {
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
isOpen: false,
position: { x: 0, y: 0 },
row: null,
})
/**
* Opens the context menu for a row.
*/
const handleRowContextMenu = useCallback((e: React.MouseEvent, row: TableRowData) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({
isOpen: true,
position: { x: e.clientX, y: e.clientY },
row,
})
}, [])
/**
* Closes the context menu.
*/
const closeContextMenu = useCallback(() => {
setContextMenu((prev) => ({ ...prev, isOpen: false }))
}, [])
return {
contextMenu,
handleRowContextMenu,
closeContextMenu,
}
}

View File

@@ -0,0 +1,65 @@
/**
* Hook for managing row selection state.
*
* @module tables/[tableId]/table-data-viewer/hooks/use-row-selection
*/
import { useCallback, useState } from 'react'
import type { TableRowData } from '../types'
interface UseRowSelectionReturn {
selectedRows: Set<string>
handleSelectAll: () => void
handleSelectRow: (rowId: string) => void
clearSelection: () => void
}
/**
* Manages row selection state and provides selection handlers.
*
* @param rows - The current rows to select from
* @returns Selection state and handlers
*/
export function useRowSelection(rows: TableRowData[]): UseRowSelectionReturn {
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
/**
* Toggles selection of all visible rows.
*/
const handleSelectAll = useCallback(() => {
if (selectedRows.size === rows.length) {
setSelectedRows(new Set())
} else {
setSelectedRows(new Set(rows.map((r) => r.id)))
}
}, [rows, selectedRows.size])
/**
* Toggles selection of a single row.
*/
const handleSelectRow = useCallback((rowId: string) => {
setSelectedRows((prev) => {
const newSet = new Set(prev)
if (newSet.has(rowId)) {
newSet.delete(rowId)
} else {
newSet.add(rowId)
}
return newSet
})
}, [])
/**
* Clears all selections.
*/
const clearSelection = useCallback(() => {
setSelectedRows(new Set())
}, [])
return {
selectedRows,
handleSelectAll,
handleSelectRow,
clearSelection,
}
}

View File

@@ -0,0 +1,102 @@
/**
* Hook for fetching table data and rows.
*
* @module tables/[tableId]/table-data-viewer/hooks/use-table-data
*/
import { useQuery } from '@tanstack/react-query'
import type { QueryOptions } from '../../components/filter-builder'
import { ROWS_PER_PAGE } from '../constants'
import type { TableData, TableRowData } from '../types'
interface UseTableDataParams {
workspaceId: string
tableId: string
queryOptions: QueryOptions
currentPage: number
}
interface UseTableDataReturn {
tableData: TableData | undefined
isLoadingTable: boolean
rows: TableRowData[]
totalCount: number
totalPages: number
isLoadingRows: boolean
refetchRows: () => void
}
/**
* Fetches table metadata and rows with filtering/sorting support.
*
* @param params - The parameters for fetching table data
* @returns Table data, rows, and loading states
*/
export function useTableData({
workspaceId,
tableId,
queryOptions,
currentPage,
}: UseTableDataParams): UseTableDataReturn {
// Fetch table metadata
const { data: tableData, isLoading: isLoadingTable } = useQuery({
queryKey: ['table', tableId],
queryFn: async () => {
const res = await fetch(`/api/table/${tableId}?workspaceId=${workspaceId}`)
if (!res.ok) throw new Error('Failed to fetch table')
const json: { data?: { table: TableData }; table?: TableData } = await res.json()
const data = json.data || json
return (data as { table: TableData }).table
},
})
// Fetch table rows with filter and sort
const {
data: rowsData,
isLoading: isLoadingRows,
refetch: refetchRows,
} = useQuery({
queryKey: ['table-rows', tableId, queryOptions, currentPage],
queryFn: async () => {
const searchParams = new URLSearchParams({
workspaceId,
limit: String(ROWS_PER_PAGE),
offset: String(currentPage * ROWS_PER_PAGE),
})
if (queryOptions.filter) {
searchParams.set('filter', JSON.stringify(queryOptions.filter))
}
if (queryOptions.sort) {
// Convert from {column, direction} to {column: direction} format expected by API
const sortParam = { [queryOptions.sort.column]: queryOptions.sort.direction }
searchParams.set('sort', JSON.stringify(sortParam))
}
const res = await fetch(`/api/table/${tableId}/rows?${searchParams}`)
if (!res.ok) throw new Error('Failed to fetch rows')
const json: {
data?: { rows: TableRowData[]; totalCount: number }
rows?: TableRowData[]
totalCount?: number
} = await res.json()
return json.data || json
},
enabled: !!tableData,
})
const rows = (rowsData?.rows || []) as TableRowData[]
const totalCount = rowsData?.totalCount || 0
const totalPages = Math.ceil(totalCount / ROWS_PER_PAGE)
return {
tableData,
isLoadingTable,
rows,
totalCount,
totalPages,
isLoadingRows,
refetchRows,
}
}

View File

@@ -0,0 +1,8 @@
/**
* Table data viewer module.
*
* @module tables/[tableId]/table-data-viewer
*/
export * from './table-data-viewer'
export * from './types'

View File

@@ -0,0 +1,385 @@
'use client'
/**
* Main table data viewer component.
*
* @module tables/[tableId]/table-data-viewer/table-data-viewer
*/
import { useCallback, useState } from 'react'
import { Edit, Trash2 } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
Badge,
Button,
Checkbox,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tooltip,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import {
AddRowModal,
DeleteRowModal,
EditRowModal,
FilterBuilder,
type QueryOptions,
TableActionBar,
} from '../components'
import {
CellRenderer,
CellViewerModal,
EmptyRows,
LoadingRows,
Pagination,
RowContextMenu,
SchemaViewerModal,
TableHeaderBar,
} from './components'
import { useContextMenu, useRowSelection, useTableData } from './hooks'
import type { CellViewerData, TableRowData } from './types'
/**
* Main component for viewing and managing table data.
*
* @remarks
* Provides functionality for:
* - Viewing rows with pagination
* - Filtering and sorting
* - Adding, editing, and deleting rows
* - Viewing cell details for long/complex values
*
* @example
* ```tsx
* <TableDataViewer />
* ```
*/
export function TableDataViewer() {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const tableId = params.tableId as string
// Query state
const [queryOptions, setQueryOptions] = useState<QueryOptions>({
filter: null,
sort: null,
})
const [currentPage, setCurrentPage] = useState(0)
// Modal state
const [showAddModal, setShowAddModal] = useState(false)
const [editingRow, setEditingRow] = useState<TableRowData | null>(null)
const [deletingRows, setDeletingRows] = useState<string[]>([])
const [showSchemaModal, setShowSchemaModal] = useState(false)
// Cell viewer state
const [cellViewer, setCellViewer] = useState<CellViewerData | null>(null)
const [copied, setCopied] = useState(false)
// Fetch table data
const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows, refetchRows } =
useTableData({
workspaceId,
tableId,
queryOptions,
currentPage,
})
// Row selection
const { selectedRows, handleSelectAll, handleSelectRow, clearSelection } = useRowSelection(rows)
// Context menu
const { contextMenu, handleRowContextMenu, closeContextMenu } = useContextMenu()
const columns = tableData?.schema?.columns || []
/**
* Applies new query options and resets pagination.
*/
const handleApplyQueryOptions = useCallback((options: QueryOptions) => {
setQueryOptions(options)
setCurrentPage(0)
}, [])
/**
* Opens the delete modal for selected rows.
*/
const handleDeleteSelected = useCallback(() => {
setDeletingRows(Array.from(selectedRows))
}, [selectedRows])
/**
* Handles edit action from context menu.
*/
const handleContextMenuEdit = useCallback(() => {
if (contextMenu.row) {
setEditingRow(contextMenu.row)
}
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
/**
* Handles delete action from context menu.
*/
const handleContextMenuDelete = useCallback(() => {
if (contextMenu.row) {
setDeletingRows([contextMenu.row.id])
}
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
/**
* Copies the current cell value to clipboard.
*/
const handleCopyCellValue = useCallback(async () => {
if (cellViewer) {
let text: string
if (cellViewer.type === 'json') {
text = JSON.stringify(cellViewer.value, null, 2)
} else if (cellViewer.type === 'date') {
text = String(cellViewer.value)
} else {
text = String(cellViewer.value)
}
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}, [cellViewer])
/**
* Opens the cell viewer modal.
*/
const handleCellClick = useCallback(
(columnName: string, value: unknown, type: CellViewerData['type']) => {
setCellViewer({ columnName, value, type })
},
[]
)
if (isLoadingTable) {
return (
<div className='flex h-full items-center justify-center'>
<span className='text-[13px] text-[var(--text-tertiary)]'>Loading table...</span>
</div>
)
}
if (!tableData) {
return (
<div className='flex h-full items-center justify-center'>
<span className='text-[13px] text-[var(--text-error)]'>Table not found</span>
</div>
)
}
return (
<div className='flex h-full flex-col'>
{/* Header */}
<TableHeaderBar
tableName={tableData.name}
totalCount={totalCount}
isLoading={isLoadingRows}
onNavigateBack={() => router.push(`/workspace/${workspaceId}/tables`)}
onShowSchema={() => setShowSchemaModal(true)}
onRefresh={refetchRows}
/>
{/* Filter Bar */}
<div className='flex shrink-0 flex-col gap-[8px] border-[var(--border)] border-b px-[16px] py-[10px]'>
<FilterBuilder
columns={columns}
onApply={handleApplyQueryOptions}
onAddRow={() => setShowAddModal(true)}
/>
{selectedRows.size > 0 && (
<span className='text-[11px] text-[var(--text-tertiary)]'>
{selectedRows.size} selected
</span>
)}
</div>
{/* Action Bar */}
{selectedRows.size > 0 && (
<TableActionBar
selectedCount={selectedRows.size}
onDelete={handleDeleteSelected}
onClearSelection={clearSelection}
/>
)}
{/* Table */}
<div className='flex-1 overflow-auto'>
<Table>
<TableHeader className='sticky top-0 z-10 bg-[var(--surface-3)]'>
<TableRow>
<TableHead className='w-[40px]'>
<Checkbox
size='sm'
checked={selectedRows.size === rows.length && rows.length > 0}
onCheckedChange={handleSelectAll}
/>
</TableHead>
{columns.map((column) => (
<TableHead key={column.name}>
<div className='flex items-center gap-[6px]'>
<span className='text-[12px]'>{column.name}</span>
<Badge variant='outline' size='sm'>
{column.type}
</Badge>
{column.required && (
<span className='text-[10px] text-[var(--text-error)]'>*</span>
)}
</div>
</TableHead>
))}
<TableHead className='w-[80px]'>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoadingRows ? (
<LoadingRows columns={columns} />
) : rows.length === 0 ? (
<EmptyRows
columnCount={columns.length}
hasFilter={!!queryOptions.filter}
onAddRow={() => setShowAddModal(true)}
/>
) : (
rows.map((row) => (
<TableRow
key={row.id}
className={cn(
'group hover:bg-[var(--surface-4)]',
selectedRows.has(row.id) && 'bg-[var(--surface-5)]'
)}
onContextMenu={(e) => handleRowContextMenu(e, row)}
>
<TableCell>
<Checkbox
size='sm'
checked={selectedRows.has(row.id)}
onCheckedChange={() => handleSelectRow(row.id)}
/>
</TableCell>
{columns.map((column) => (
<TableCell key={column.name}>
<div className='max-w-[300px] truncate text-[13px]'>
<CellRenderer
value={row.data[column.name]}
column={column}
onCellClick={handleCellClick}
/>
</div>
</TableCell>
))}
<TableCell>
<div className='flex items-center gap-[2px] opacity-0 transition-opacity group-hover:opacity-100'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' size='sm' onClick={() => setEditingRow(row)}>
<Edit className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Edit</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={() => setDeletingRows([row.id])}
className='text-[var(--text-tertiary)] hover:text-[var(--text-error)]'
>
<Trash2 className='h-[12px] w-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Delete</Tooltip.Content>
</Tooltip.Root>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalCount={totalCount}
onPreviousPage={() => setCurrentPage((p) => Math.max(0, p - 1))}
onNextPage={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
/>
{/* Modals */}
<AddRowModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
table={tableData}
onSuccess={() => {
refetchRows()
setShowAddModal(false)
}}
/>
{editingRow && (
<EditRowModal
isOpen={true}
onClose={() => setEditingRow(null)}
table={tableData}
row={editingRow}
onSuccess={() => {
refetchRows()
setEditingRow(null)
}}
/>
)}
{deletingRows.length > 0 && (
<DeleteRowModal
isOpen={true}
onClose={() => setDeletingRows([])}
tableId={tableId}
rowIds={deletingRows}
onSuccess={() => {
refetchRows()
setDeletingRows([])
clearSelection()
}}
/>
)}
{/* Schema Viewer Modal */}
<SchemaViewerModal
isOpen={showSchemaModal}
onClose={() => setShowSchemaModal(false)}
columns={columns}
/>
{/* Cell Viewer Modal */}
<CellViewerModal
cellViewer={cellViewer}
onClose={() => setCellViewer(null)}
onCopy={handleCopyCellValue}
copied={copied}
/>
{/* Row Context Menu */}
<RowContextMenu
contextMenu={contextMenu}
onClose={closeContextMenu}
onEdit={handleContextMenuEdit}
onDelete={handleContextMenuDelete}
/>
</div>
)
}

View File

@@ -0,0 +1,67 @@
/**
* Type definitions for the table data viewer.
*
* @module tables/[tableId]/table-data-viewer/types
*/
import type { TableSchema } from '@/lib/table'
/**
* Represents row data stored in a table.
*/
export interface TableRowData {
/** Unique identifier for the row */
id: string
/** Row field values keyed by column name */
data: Record<string, unknown>
/** ISO timestamp when the row was created */
createdAt: string
/** ISO timestamp when the row was last updated */
updatedAt: string
}
/**
* Represents table metadata.
*/
export interface TableData {
/** Unique identifier for the table */
id: string
/** Table name */
name: string
/** Optional description */
description?: string
/** Schema defining columns */
schema: TableSchema
/** Current number of rows */
rowCount: number
/** Maximum allowed rows */
maxRows: number
/** ISO timestamp when created */
createdAt: string
/** ISO timestamp when last updated */
updatedAt: string
}
/**
* Data for the cell viewer modal.
*/
export interface CellViewerData {
/** Name of the column being viewed */
columnName: string
/** Value being displayed */
value: unknown
/** Display type for formatting */
type: 'json' | 'text' | 'date'
}
/**
* State for the right-click context menu.
*/
export interface ContextMenuState {
/** Whether the menu is visible */
isOpen: boolean
/** Screen position of the menu */
position: { x: number; y: number }
/** Row the menu was opened on */
row: TableRowData | null
}

View File

@@ -0,0 +1,30 @@
/**
* Utility functions for the table data viewer.
*
* @module tables/[tableId]/table-data-viewer/utils
*/
/**
* Gets the badge variant for a column type.
*
* @param type - The column type
* @returns Badge variant name
*/
export function getTypeBadgeVariant(
type: string
): 'green' | 'blue' | 'purple' | 'orange' | 'teal' | 'gray' {
switch (type) {
case 'string':
return 'green'
case 'number':
return 'blue'
case 'boolean':
return 'purple'
case 'json':
return 'orange'
case 'date':
return 'teal'
default:
return 'gray'
}
}