mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
trashy table viewer
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './add-row-modal'
|
||||
export * from './delete-row-modal'
|
||||
export * from './edit-row-modal'
|
||||
export * from './table-action-bar'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TableDataViewer } from './table-data-viewer'
|
||||
|
||||
export default function TablePage() {
|
||||
return <TableDataViewer />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user