trashy table viewer

This commit is contained in:
Lakee Sivaraya
2026-01-13 12:45:02 -08:00
parent 5dddb03eac
commit e80660f218
7 changed files with 1201 additions and 0 deletions

View File

@@ -0,0 +1,217 @@
'use client'
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import {
Button,
Checkbox,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import { Input } from '@/components/ui/input'
import type { TableSchema } from '@/lib/table'
const logger = createLogger('AddRowModal')
interface AddRowModalProps {
isOpen: boolean
onClose: () => void
table: any
onSuccess: () => void
}
export function AddRowModal({ isOpen, onClose, table, onSuccess }: AddRowModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const schema = table?.schema as TableSchema | undefined
const columns = schema?.columns || []
const [rowData, setRowData] = useState<Record<string, any>>({})
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
if (isOpen && columns.length > 0) {
const initial: Record<string, any> = {}
columns.forEach((col) => {
if (col.type === 'boolean') {
initial[col.name] = false
} else {
initial[col.name] = ''
}
})
setRowData(initial)
}
}, [isOpen, columns])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
// Clean up data - remove empty optional fields
const cleanData: Record<string, any> = {}
columns.forEach((col) => {
const value = rowData[col.name]
if (col.required || (value !== '' && value !== null && value !== undefined)) {
if (col.type === 'number') {
cleanData[col.name] = value === '' ? null : Number(value)
} else if (col.type === 'json') {
try {
cleanData[col.name] = value === '' ? null : JSON.parse(value)
} catch {
throw new Error(`Invalid JSON for field: ${col.name}`)
}
} else if (col.type === 'boolean') {
cleanData[col.name] = Boolean(value)
} else {
cleanData[col.name] = value || null
}
}
})
const res = await fetch(`/api/table/${table?.id}/rows`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspaceId,
data: cleanData,
}),
})
const result = await res.json()
if (!res.ok) {
throw new Error(result.error || 'Failed to add row')
}
onSuccess()
} catch (err) {
logger.error('Failed to add row:', err)
setError(err instanceof Error ? err.message : 'Failed to add row')
} finally {
setIsSubmitting(false)
}
}
const handleClose = () => {
setRowData({})
setError(null)
onClose()
}
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent className='w-[600px]'>
<ModalHeader>
<div className='flex flex-col gap-[4px]'>
<h2 className='font-semibold text-[16px]'>Add New Row</h2>
<p className='font-normal text-[13px] text-[var(--text-tertiary)]'>
Fill in the values for {table?.name ?? 'table'}
</p>
</div>
</ModalHeader>
<ModalBody className='max-h-[60vh] overflow-y-auto'>
<form onSubmit={handleSubmit} className='flex flex-col gap-[16px]'>
{error && (
<div className='rounded-[8px] border border-[var(--status-error-border)] bg-[var(--status-error-bg)] px-[14px] py-[12px] text-[13px] text-[var(--status-error-text)]'>
{error}
</div>
)}
{columns.map((column) => (
<div key={column.name} className='flex flex-col gap-[8px]'>
<Label htmlFor={column.name} className='font-medium text-[13px]'>
{column.name}
{column.required && <span className='text-[var(--text-error)]'> *</span>}
{column.unique && (
<span className='ml-[6px] font-normal text-[11px] text-[var(--text-tertiary)]'>
(unique)
</span>
)}
</Label>
{column.type === 'boolean' ? (
<div className='flex items-center gap-[8px]'>
<Checkbox
id={column.name}
checked={rowData[column.name] ?? false}
onCheckedChange={(checked) =>
setRowData((prev) => ({ ...prev, [column.name]: checked === true }))
}
/>
<Label
htmlFor={column.name}
className='font-normal text-[13px] text-[var(--text-tertiary)]'
>
{rowData[column.name] ? 'True' : 'False'}
</Label>
</div>
) : column.type === 'json' ? (
<Textarea
id={column.name}
value={rowData[column.name] ?? ''}
onChange={(e) =>
setRowData((prev) => ({ ...prev, [column.name]: e.target.value }))
}
placeholder='{"key": "value"}'
rows={4}
className='font-mono text-[12px]'
required={column.required}
/>
) : (
<Input
id={column.name}
type={
column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'
}
value={rowData[column.name] ?? ''}
onChange={(e) =>
setRowData((prev) => ({ ...prev, [column.name]: e.target.value }))
}
placeholder={`Enter ${column.name}`}
className='h-[38px]'
required={column.required}
/>
)}
<div className='text-[12px] text-[var(--text-tertiary)]'>
Type: {column.type}
{!column.required && ' (optional)'}
</div>
</div>
))}
</form>
</ModalBody>
<ModalFooter className='gap-[10px]'>
<Button
type='button'
variant='default'
onClick={handleClose}
className='min-w-[90px]'
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type='button'
variant='tertiary'
onClick={handleSubmit}
disabled={isSubmitting}
className='min-w-[120px]'
>
{isSubmitting ? 'Adding...' : 'Add Row'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,126 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { AlertCircle } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
const logger = createLogger('DeleteRowModal')
interface DeleteRowModalProps {
isOpen: boolean
onClose: () => void
tableId: string
rowIds: string[]
onSuccess: () => void
}
export function DeleteRowModal({
isOpen,
onClose,
tableId,
rowIds,
onSuccess,
}: DeleteRowModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [error, setError] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const handleDelete = async () => {
setError(null)
setIsDeleting(true)
try {
// Delete rows one by one or in batch
if (rowIds.length === 1) {
const res = await fetch(`/api/table/${tableId}/rows/${rowIds[0]}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (!res.ok) {
const result = await res.json()
throw new Error(result.error || 'Failed to delete row')
}
} else {
// Batch delete - you might want to implement a batch delete endpoint
await Promise.all(
rowIds.map((rowId) =>
fetch(`/api/table/${tableId}/rows/${rowId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
)
)
}
onSuccess()
} catch (err) {
logger.error('Failed to delete row(s):', err)
setError(err instanceof Error ? err.message : 'Failed to delete row(s)')
} finally {
setIsDeleting(false)
}
}
const handleClose = () => {
setError(null)
onClose()
}
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent className='w-[480px]'>
<ModalHeader>
<div className='flex items-center gap-[10px]'>
<div className='flex h-[36px] w-[36px] items-center justify-center rounded-[8px] bg-[var(--bg-error)] text-[var(--text-error)]'>
<AlertCircle className='h-[18px] w-[18px]' />
</div>
<h2 className='font-semibold text-[16px]'>
Delete {rowIds.length === 1 ? 'Row' : `${rowIds.length} Rows`}
</h2>
</div>
</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[16px]'>
{error && (
<div className='rounded-[8px] border border-[var(--status-error-border)] bg-[var(--status-error-bg)] px-[14px] py-[12px] text-[13px] text-[var(--status-error-text)]'>
{error}
</div>
)}
<p className='text-[14px] text-[var(--text-secondary)]'>
Are you sure you want to delete {rowIds.length === 1 ? 'this row' : 'these rows'}?
This action cannot be undone.
</p>
</div>
</ModalBody>
<ModalFooter className='gap-[10px]'>
<Button
type='button'
variant='default'
onClick={handleClose}
className='min-w-[90px]'
disabled={isDeleting}
>
Cancel
</Button>
<Button
type='button'
variant='error'
onClick={handleDelete}
disabled={isDeleting}
className='min-w-[120px]'
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,228 @@
'use client'
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import {
Button,
Checkbox,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import { Input } from '@/components/ui/input'
import type { TableSchema } from '@/lib/table'
const logger = createLogger('EditRowModal')
interface TableRowData {
id: string
data: Record<string, any>
createdAt: string
updatedAt: string
}
interface EditRowModalProps {
isOpen: boolean
onClose: () => void
table: any
row: TableRowData
onSuccess: () => void
}
export function EditRowModal({ isOpen, onClose, table, row, onSuccess }: EditRowModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const schema = table?.schema as TableSchema | undefined
const columns = schema?.columns || []
const [rowData, setRowData] = useState<Record<string, any>>(row.data)
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
setRowData(row.data)
}, [row.data])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
// Clean up data
const cleanData: Record<string, any> = {}
columns.forEach((col) => {
const value = rowData[col.name]
if (col.type === 'number') {
cleanData[col.name] = value === '' ? null : Number(value)
} else if (col.type === 'json') {
try {
cleanData[col.name] = typeof value === 'string' ? JSON.parse(value) : value
} catch {
throw new Error(`Invalid JSON for field: ${col.name}`)
}
} else if (col.type === 'boolean') {
cleanData[col.name] = Boolean(value)
} else {
cleanData[col.name] = value || null
}
})
const res = await fetch(`/api/table/${table?.id}/rows/${row.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspaceId,
data: cleanData,
}),
})
const result = await res.json()
if (!res.ok) {
throw new Error(result.error || 'Failed to update row')
}
onSuccess()
} catch (err) {
logger.error('Failed to update row:', err)
setError(err instanceof Error ? err.message : 'Failed to update row')
} finally {
setIsSubmitting(false)
}
}
const handleClose = () => {
setError(null)
onClose()
}
const formatValueForInput = (value: any, type: string): string => {
if (value === null || value === undefined) return ''
if (type === 'json') {
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
if (type === 'date' && value) {
try {
const date = new Date(value)
return date.toISOString().split('T')[0]
} catch {
return String(value)
}
}
return String(value)
}
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent className='w-[600px]'>
<ModalHeader>
<div className='flex flex-col gap-[4px]'>
<h2 className='font-semibold text-[16px]'>Edit Row</h2>
<p className='font-normal text-[13px] text-[var(--text-tertiary)]'>
Update values for {table?.name ?? 'table'}
</p>
</div>
</ModalHeader>
<ModalBody className='max-h-[60vh] overflow-y-auto'>
<form onSubmit={handleSubmit} className='flex flex-col gap-[16px]'>
{error && (
<div className='rounded-[8px] border border-[var(--status-error-border)] bg-[var(--status-error-bg)] px-[14px] py-[12px] text-[13px] text-[var(--status-error-text)]'>
{error}
</div>
)}
{columns.map((column) => (
<div key={column.name} className='flex flex-col gap-[8px]'>
<Label htmlFor={column.name} className='font-medium text-[13px]'>
{column.name}
{column.required && <span className='text-[var(--text-error)]'> *</span>}
{column.unique && (
<span className='ml-[6px] font-normal text-[11px] text-[var(--text-tertiary)]'>
(unique)
</span>
)}
</Label>
{column.type === 'boolean' ? (
<div className='flex items-center gap-[8px]'>
<Checkbox
id={column.name}
checked={Boolean(rowData[column.name])}
onCheckedChange={(checked) =>
setRowData((prev) => ({ ...prev, [column.name]: checked === true }))
}
/>
<Label
htmlFor={column.name}
className='font-normal text-[13px] text-[var(--text-tertiary)]'
>
{rowData[column.name] ? 'True' : 'False'}
</Label>
</div>
) : column.type === 'json' ? (
<Textarea
id={column.name}
value={formatValueForInput(rowData[column.name], column.type)}
onChange={(e) =>
setRowData((prev) => ({ ...prev, [column.name]: e.target.value }))
}
placeholder='{"key": "value"}'
rows={4}
className='font-mono text-[12px]'
required={column.required}
/>
) : (
<Input
id={column.name}
type={
column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'
}
value={formatValueForInput(rowData[column.name], column.type)}
onChange={(e) =>
setRowData((prev) => ({ ...prev, [column.name]: e.target.value }))
}
placeholder={`Enter ${column.name}`}
className='h-[38px]'
required={column.required}
/>
)}
<div className='text-[12px] text-[var(--text-tertiary)]'>
Type: {column.type}
{!column.required && ' (optional)'}
</div>
</div>
))}
</form>
</ModalBody>
<ModalFooter className='gap-[10px]'>
<Button
type='button'
variant='default'
onClick={handleClose}
className='min-w-[90px]'
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type='button'
variant='tertiary'
onClick={handleSubmit}
disabled={isSubmitting}
className='min-w-[120px]'
>
{isSubmitting ? 'Updating...' : 'Update Row'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,4 @@
export * from './add-row-modal'
export * from './delete-row-modal'
export * from './edit-row-modal'
export * from './table-action-bar'

View File

@@ -0,0 +1,31 @@
'use client'
import { Trash2, X } from 'lucide-react'
import { Button } from '@/components/emcn'
interface TableActionBarProps {
selectedCount: number
onDelete: () => void
onClearSelection: () => void
}
export function TableActionBar({ selectedCount, onDelete, onClearSelection }: TableActionBarProps) {
return (
<div className='flex h-[36px] shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-4)] px-[16px]'>
<div className='flex items-center gap-[12px]'>
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
{selectedCount} {selectedCount === 1 ? 'row' : 'rows'} selected
</span>
<Button variant='ghost' size='sm' onClick={onClearSelection}>
<X className='mr-[4px] h-[10px] w-[10px]' />
Clear
</Button>
</div>
<Button variant='destructive' size='sm' onClick={onDelete}>
<Trash2 className='mr-[4px] h-[10px] w-[10px]' />
Delete
</Button>
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { TableDataViewer } from './table-data-viewer'
export default function TablePage() {
return <TableDataViewer />
}

View File

@@ -0,0 +1,590 @@
'use client'
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQuery } from '@tanstack/react-query'
import {
ChevronLeft,
ChevronRight,
Edit,
Filter,
HelpCircle,
Plus,
RefreshCw,
Trash2,
X,
} from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
Badge,
Button,
Checkbox,
Input,
Popover,
PopoverContent,
PopoverTrigger,
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 { 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 { TableActionBar } from './components/table-action-bar'
const logger = createLogger('TableDataViewer')
const ROWS_PER_PAGE = 100
interface TableRowData {
id: string
data: Record<string, any>
createdAt: string
updatedAt: string
}
interface TableData {
id: string
name: string
description?: string
schema: TableSchema
rowCount: number
maxRows: number
createdAt: string
updatedAt: string
}
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 [filterInput, setFilterInput] = useState('')
const [appliedFilter, setAppliedFilter] = useState<Record<string, any> | null>(null)
const [filterError, setFilterError] = useState<string | null>(null)
const [currentPage, setCurrentPage] = useState(0)
const [showAddModal, setShowAddModal] = useState(false)
const [editingRow, setEditingRow] = useState<TableRowData | null>(null)
const [deletingRows, setDeletingRows] = useState<string[]>([])
// 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 = await res.json()
return json.table as TableData
},
})
// Fetch table rows with filter
const {
data: rowsData,
isLoading: isLoadingRows,
refetch: refetchRows,
} = useQuery({
queryKey: ['table-rows', tableId, currentPage, appliedFilter],
queryFn: async () => {
const queryParams = new URLSearchParams({
workspaceId,
limit: String(ROWS_PER_PAGE),
offset: String(currentPage * ROWS_PER_PAGE),
})
if (appliedFilter) {
queryParams.set('filter', JSON.stringify(appliedFilter))
}
const res = await fetch(`/api/table/${tableId}/rows?${queryParams}`)
if (!res.ok) throw new Error('Failed to fetch rows')
return res.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)
const handleApplyFilter = useCallback(() => {
setFilterError(null)
if (!filterInput.trim()) {
setAppliedFilter(null)
setCurrentPage(0)
return
}
try {
const parsed = JSON.parse(filterInput)
setAppliedFilter(parsed)
setCurrentPage(0)
} catch (err) {
setFilterError('Invalid JSON. Use format: {"column": {"$eq": "value"}}')
}
}, [filterInput])
const handleClearFilter = useCallback(() => {
setFilterInput('')
setAppliedFilter(null)
setFilterError(null)
setCurrentPage(0)
}, [])
const handleSelectAll = useCallback(() => {
if (selectedRows.size === rows.length) {
setSelectedRows(new Set())
} else {
setSelectedRows(new Set(rows.map((r) => r.id)))
}
}, [rows, selectedRows.size])
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
})
}, [])
const handleRefresh = useCallback(() => {
refetchRows()
}, [refetchRows])
const handleDeleteSelected = useCallback(() => {
setDeletingRows(Array.from(selectedRows))
}, [selectedRows])
const formatValue = (value: any, type: string): string => {
if (value === null || value === undefined) return '—'
switch (type) {
case 'boolean':
return value ? 'true' : 'false'
case 'date':
try {
return new Date(value).toLocaleDateString()
} catch {
return String(value)
}
case 'json':
return JSON.stringify(value)
case 'number':
return String(value)
default:
return String(value)
}
}
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>
<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={handleRefresh}>
<RefreshCw className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Refresh</Tooltip.Content>
</Tooltip.Root>
<Button variant='default' size='sm' onClick={() => setShowAddModal(true)}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add Row
</Button>
</div>
</div>
{/* Filter Bar */}
<div className='flex shrink-0 flex-col gap-[8px] border-[var(--border)] border-b px-[16px] py-[10px]'>
<div className='flex items-center gap-[8px]'>
<Filter className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
<Input
value={filterInput}
onChange={(e) => setFilterInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleApplyFilter()
}
}}
placeholder='{"column": {"$eq": "value"}}'
className={cn(
'h-[32px] flex-1 font-mono text-[11px]',
filterError && 'border-[var(--text-error)]'
)}
/>
<Button variant='default' size='sm' onClick={handleApplyFilter}>
Apply
</Button>
{appliedFilter && (
<Button variant='ghost' size='sm' onClick={handleClearFilter}>
<X className='h-[12px] w-[12px]' />
</Button>
)}
<Popover>
<PopoverTrigger asChild>
<Button variant='ghost' size='sm'>
<HelpCircle className='h-[14px] w-[14px]' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[360px] p-[12px]' align='end'>
<div className='flex flex-col gap-[12px]'>
<div>
<h4 className='font-medium text-[12px] text-[var(--text-primary)]'>
Filter Operators
</h4>
<p className='text-[11px] text-[var(--text-tertiary)]'>
Use MongoDB-style operators to filter rows
</p>
</div>
<div className='flex flex-col gap-[6px] font-mono text-[10px]'>
<div className='flex items-start gap-[8px]'>
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
$eq
</code>
<span className='text-[var(--text-secondary)]'>
Equals: {`{"status": "active"}`}
</span>
</div>
<div className='flex items-start gap-[8px]'>
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
$ne
</code>
<span className='text-[var(--text-secondary)]'>
Not equals: {`{"status": {"$ne": "deleted"}}`}
</span>
</div>
<div className='flex items-start gap-[8px]'>
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
$gt
</code>
<span className='text-[var(--text-secondary)]'>
Greater than: {`{"age": {"$gt": 18}}`}
</span>
</div>
<div className='flex items-start gap-[8px]'>
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
$gte
</code>
<span className='text-[var(--text-secondary)]'>
Greater or equal: {`{"age": {"$gte": 21}}`}
</span>
</div>
<div className='flex items-start gap-[8px]'>
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
$lt
</code>
<span className='text-[var(--text-secondary)]'>
Less than: {`{"price": {"$lt": 100}}`}
</span>
</div>
<div className='flex items-start gap-[8px]'>
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
$lte
</code>
<span className='text-[var(--text-secondary)]'>
Less or equal: {`{"qty": {"$lte": 10}}`}
</span>
</div>
<div className='flex items-start gap-[8px]'>
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
$in
</code>
<span className='text-[var(--text-secondary)]'>
In array: {`{"status": {"$in": ["a", "b"]}}`}
</span>
</div>
<div className='flex items-start gap-[8px]'>
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
$contains
</code>
<span className='text-[var(--text-secondary)]'>
String contains: {`{"email": {"$contains": "@"}}`}
</span>
</div>
</div>
<div className='border-[var(--border)] border-t pt-[8px]'>
<p className='text-[10px] text-[var(--text-tertiary)]'>
Combine multiple conditions:{' '}
<code className='text-[var(--text-secondary)]'>
{`{"age": {"$gte": 18}, "active": true}`}
</code>
</p>
</div>
</div>
</PopoverContent>
</Popover>
</div>
{filterError && <span className='text-[11px] text-[var(--text-error)]'>{filterError}</span>}
{appliedFilter && (
<div className='flex items-center gap-[6px]'>
<Badge variant='blue' size='sm'>
Filter active
</Badge>
<span className='font-mono text-[10px] text-[var(--text-tertiary)]'>
{JSON.stringify(appliedFilter)}
</span>
</div>
)}
{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 ? (
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className='h-[14px] w-[14px]' />
</TableCell>
{columns.map((col) => (
<TableCell key={col.name}>
<Skeleton className='h-[16px] w-[80px]' />
</TableCell>
))}
<TableCell>
<Skeleton className='h-[16px] w-[48px]' />
</TableCell>
</TableRow>
))
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length + 2} className='h-[160px] text-center'>
<div className='flex flex-col items-center gap-[12px]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
{appliedFilter ? 'No rows match your filter' : 'No data'}
</span>
{!appliedFilter && (
<Button variant='default' size='sm' onClick={() => setShowAddModal(true)}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add first row
</Button>
)}
</div>
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow
key={row.id}
className={cn(
'group hover:bg-[var(--surface-4)]',
selectedRows.has(row.id) && 'bg-[var(--surface-5)]'
)}
>
<TableCell>
<Checkbox
size='sm'
checked={selectedRows.has(row.id)}
onCheckedChange={() => handleSelectRow(row.id)}
/>
</TableCell>
{columns.map((column) => (
<TableCell key={column.name}>
<span
className={cn(
'block max-w-[300px] truncate text-[13px]',
row.data[column.name] === null || row.data[column.name] === undefined
? 'text-[var(--text-muted)] italic'
: column.type === 'boolean'
? row.data[column.name]
? 'text-green-500'
: 'text-[var(--text-tertiary)]'
: column.type === 'number'
? 'font-mono text-[var(--brand-secondary)]'
: 'text-[var(--text-primary)]'
)}
>
{formatValue(row.data[column.name], column.type)}
</span>
</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}
</span>
<div className='flex items-center gap-[4px]'>
<Button
variant='ghost'
size='sm'
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0}
>
<ChevronLeft className='h-[14px] w-[14px]' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={currentPage === totalPages - 1}
>
<ChevronRight className='h-[14px] w-[14px]' />
</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())
}}
/>
)}
</div>
)
}