improvement(tables): consolidation

This commit is contained in:
Emir Karabeg
2026-03-08 17:31:52 -07:00
parent 7d360649e9
commit de5faa5265
21 changed files with 637 additions and 535 deletions

View File

@@ -31,14 +31,14 @@ export function ResourceOptionsBar({
>
<div className='flex items-center justify-between'>
{search && (
<div className='relative flex-1'>
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-0 h-[14px] w-[14px] text-[var(--text-muted)]' />
<div className='flex flex-1 items-center'>
<Search className='pointer-events-none h-[14px] w-[14px] shrink-0 text-[var(--text-muted)]' />
<input
type='text'
value={search.value}
onChange={(e) => search.onChange(e.target.value)}
placeholder={search.placeholder ?? 'Search...'}
className='w-full bg-transparent py-[4px] pl-[24px] font-base text-[12px] text-[var(--text-secondary)] outline-none placeholder:text-[var(--text-subtle)]'
className='w-full bg-transparent py-[4px] pl-[10px] text-[12px] text-[var(--text-secondary)] outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
)}

View File

@@ -1,31 +0,0 @@
'use client'
import { Trash2, X } from 'lucide-react'
import { Button } from '@/components/emcn'
interface ActionBarProps {
selectedCount: number
onDelete: () => void
onClearSelection: () => void
}
export function ActionBar({ selectedCount, onDelete, onClearSelection }: ActionBarProps) {
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

@@ -1 +0,0 @@
export { ActionBar } from './action-bar'

View File

@@ -1,75 +0,0 @@
import { Plus } from 'lucide-react'
import { Button, TableCell, TableRow } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import type { ColumnDefinition } from '@/lib/table'
interface LoadingRowsProps {
columns: ColumnDefinition[]
}
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>
)
})}
</TableRow>
))}
</>
)
}
interface EmptyRowsProps {
columnCount: number
hasFilter: boolean
onAddRow: () => void
}
export function EmptyRows({ columnCount, hasFilter, onAddRow }: EmptyRowsProps) {
return (
<TableRow>
<TableCell colSpan={columnCount + 1} className='h-[160px]'>
<div
className='-translate-x-1/2 fixed'
style={{ left: 'calc(50% + var(--sidebar-width, 0px) / 2)' }}
>
<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>
</div>
</TableCell>
</TableRow>
)
}

View File

@@ -1 +0,0 @@
export { EmptyRows, LoadingRows } from './body-states'

View File

@@ -1,178 +0,0 @@
import type { ColumnDefinition } from '@/lib/table'
import { STRING_TRUNCATE_LENGTH } from '../../constants'
import type { CellViewerData } from '../../types'
import { InlineCellEditor } from '../inline-cell-editor'
interface CellRendererProps {
value: unknown
column: ColumnDefinition
isEditing: boolean
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
onDoubleClick: () => void
onSave: (value: unknown) => void
onCancel: () => void
onBooleanToggle: () => void
}
export function CellRenderer({
value,
column,
isEditing,
onCellClick,
onDoubleClick,
onSave,
onCancel,
onBooleanToggle,
}: CellRendererProps) {
if (isEditing) {
return <InlineCellEditor value={value} column={column} onSave={onSave} onCancel={onCancel} />
}
const isNull = value === null || value === undefined
if (column.type === 'boolean') {
const boolValue = Boolean(value)
return (
<button
type='button'
className='cursor-pointer select-none'
onClick={(e) => {
e.stopPropagation()
onBooleanToggle()
}}
>
<span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
{isNull ? (
<span className='text-[var(--text-muted)] italic'></span>
) : boolValue ? (
'true'
) : (
'false'
)}
</span>
</button>
)
}
if (isNull) {
return (
<span
className='cursor-text text-[var(--text-muted)] italic'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
</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')
}}
onDoubleClick={(e) => {
e.preventDefault()
e.stopPropagation()
onDoubleClick()
}}
title='Click to view, double-click to edit'
>
{jsonStr}
</button>
)
}
if (column.type === 'number') {
return (
<span
className='cursor-text font-mono text-[12px] text-[var(--text-secondary)]'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
{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 (
<span
className='cursor-text text-[12px] text-[var(--text-secondary)]'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
{formatted}
</span>
)
} catch {
return (
<span
className='cursor-text text-[var(--text-primary)]'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
{String(value)}
</span>
)
}
}
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')
}}
onDoubleClick={(e) => {
e.preventDefault()
e.stopPropagation()
onDoubleClick()
}}
title='Click to view, double-click to edit'
>
{strValue}
</button>
)
}
return (
<span
className='cursor-text text-[var(--text-primary)]'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
{strValue}
</span>
)
}

View File

@@ -1 +0,0 @@
export { CellRenderer } from './cell-renderer'

View File

@@ -1,12 +1,6 @@
export * from './action-bar'
export * from './body-states'
export * from './cell-renderer'
export * from './cell-viewer-modal'
export * from './context-menu'
export * from './inline-cell-editor'
export * from './pagination'
export * from './query-builder'
export * from './row-modal'
export * from './schema-modal'
export * from './table'
export * from './table-row-cells'

View File

@@ -1 +0,0 @@
export { InlineCellEditor } from './inline-cell-editor'

View File

@@ -1,63 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import type { ColumnDefinition } from '@/lib/table'
import { cleanCellValue, formatValueForInput } from '../../utils'
interface InlineCellEditorProps {
value: unknown
column: ColumnDefinition
onSave: (value: unknown) => void
onCancel: () => void
}
export function InlineCellEditor({ value, column, onSave, onCancel }: InlineCellEditorProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [draft, setDraft] = useState(() => formatValueForInput(value, column.type))
const doneRef = useRef(false)
useEffect(() => {
const input = inputRef.current
if (input) {
input.focus()
input.select()
}
}, [])
const handleSave = () => {
if (doneRef.current) return
doneRef.current = true
try {
const cleaned = cleanCellValue(draft, column)
onSave(cleaned)
} catch {
onCancel()
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSave()
} else if (e.key === 'Escape') {
e.preventDefault()
doneRef.current = true
onCancel()
}
}
const inputType = column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'
return (
<input
ref={inputRef}
type={inputType}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
className='h-full w-full rounded-[2px] border-none bg-transparent px-[4px] py-[2px] text-[13px] text-[var(--text-primary)] outline-none ring-1 ring-[var(--accent)] ring-inset'
/>
)
}

View File

@@ -1 +0,0 @@
export { Pagination } from './pagination'

View File

@@ -1,40 +0,0 @@
import { Button } from '@/components/emcn'
interface PaginationProps {
currentPage: number
totalPages: number
totalCount: number
onPreviousPage: () => void
onNextPage: () => void
}
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

@@ -1 +0,0 @@
export { TableRowCells } from './table-row-cells'

View File

@@ -1,63 +0,0 @@
import React from 'react'
import { Checkbox, TableCell, TableRow } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition, TableRow as TableRowType } from '@/lib/table'
import type { CellViewerData } from '../../types'
import { CellRenderer } from '../cell-renderer'
interface TableRowCellsProps {
row: TableRowType
columns: ColumnDefinition[]
isSelected: boolean
editingColumnName: string | null
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
onDoubleClick: (rowId: string, columnName: string) => void
onSave: (rowId: string, columnName: string, value: unknown) => void
onCancel: () => void
onBooleanToggle: (rowId: string, columnName: string, currentValue: boolean) => void
onContextMenu: (e: React.MouseEvent, row: TableRowType) => void
onSelectRow: (rowId: string) => void
}
export const TableRowCells = React.memo(function TableRowCells({
row,
columns,
isSelected,
editingColumnName,
onCellClick,
onDoubleClick,
onSave,
onCancel,
onBooleanToggle,
onContextMenu,
onSelectRow,
}: TableRowCellsProps) {
return (
<TableRow
className={cn('group hover:bg-[var(--surface-4)]', isSelected && 'bg-[var(--surface-5)]')}
onContextMenu={(e) => onContextMenu(e, row)}
>
<TableCell>
<Checkbox size='sm' checked={isSelected} onCheckedChange={() => onSelectRow(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}
isEditing={editingColumnName === column.name}
onCellClick={onCellClick}
onDoubleClick={() => onDoubleClick(row.id, column.name)}
onSave={(value) => onSave(row.id, column.name, value)}
onCancel={onCancel}
onBooleanToggle={() =>
onBooleanToggle(row.id, column.name, Boolean(row.data[column.name]))
}
/>
</div>
</TableCell>
))}
</TableRow>
)
})

View File

@@ -1,30 +1,28 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Button, Checkbox, Skeleton } from '@/components/emcn'
import {
Badge,
Checkbox,
Table as EmcnTable,
TableBody,
TableHead,
TableHeader,
TableRow,
} from '@/components/emcn'
import { Table as TableIcon } from '@/components/emcn/icons'
import type { TableRow as TableRowType } from '@/lib/table'
Calendar as CalendarIcon,
Table as TableIcon,
TypeBoolean,
TypeJson,
TypeNumber,
TypeText,
} from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition, TableRow as TableRowType } from '@/lib/table'
import { ResourceHeader, ResourceOptionsBar } from '@/app/workspace/[workspaceId]/components'
import { useUpdateTableRow } from '@/hooks/queries/tables'
import { useCreateTableRow, useUpdateTableRow } from '@/hooks/queries/tables'
import { STRING_TRUNCATE_LENGTH } from '../../constants'
import { useContextMenu, useRowSelection, useTableData } from '../../hooks'
import type { CellViewerData, EditingCell, QueryOptions } from '../../types'
import { ActionBar } from '../action-bar'
import { EmptyRows, LoadingRows } from '../body-states'
import { cleanCellValue, formatValueForInput } from '../../utils'
import { CellViewerModal } from '../cell-viewer-modal'
import { ContextMenu } from '../context-menu'
import { Pagination } from '../pagination'
import { RowModal } from '../row-modal'
import { SchemaModal } from '../schema-modal'
import { TableRowCells } from '../table-row-cells'
const EMPTY_COLUMNS: never[] = []
@@ -47,6 +45,11 @@ export function Table() {
const [showSchemaModal, setShowSchemaModal] = useState(false)
const [editingCell, setEditingCell] = useState<EditingCell | null>(null)
const [editingEmptyCell, setEditingEmptyCell] = useState<{
rowIndex: number
columnName: string
} | null>(null)
const [cellViewer, setCellViewer] = useState<CellViewerData | null>(null)
const [copied, setCopied] = useState(false)
@@ -66,6 +69,7 @@ export function Table() {
} = useContextMenu()
const updateRowMutation = useUpdateTableRow({ workspaceId, tableId })
const createRowMutation = useCreateTableRow({ workspaceId, tableId })
const columns = useMemo(
() => tableData?.schema?.columns || EMPTY_COLUMNS,
@@ -190,6 +194,27 @@ export function Table() {
[]
)
const handleEmptyRowDoubleClick = useCallback((rowIndex: number, columnName: string) => {
const column = columnsRef.current.find((c) => c.name === columnName)
if (!column || column.type === 'json' || column.type === 'boolean') return
setEditingEmptyCell({ rowIndex, columnName })
}, [])
const createRef = useRef(createRowMutation.mutate)
useEffect(() => {
createRef.current = createRowMutation.mutate
}, [createRowMutation.mutate])
const handleEmptyRowSave = useCallback((columnName: string, value: unknown) => {
setEditingEmptyCell(null)
if (value === null || value === undefined || value === '') return
createRef.current({ [columnName]: value })
}, [])
const handleEmptyRowCancel = useCallback(() => {
setEditingEmptyCell(null)
}, [])
if (isLoadingTable) {
return (
<div className='flex h-full items-center justify-center'>
@@ -215,68 +240,71 @@ export function Table() {
<ResourceOptionsBar onSort={handleSort} onFilter={handleFilter} />
{hasSelection && (
<ActionBar
selectedCount={selectedCount}
onDelete={handleDeleteSelected}
onClearSelection={clearSelection}
/>
)}
<div className='flex-1 overflow-auto'>
<EmcnTable>
<TableHeader className='sticky top-0 z-10 bg-[var(--surface-3)]'>
<TableRow>
<TableHead className='w-[40px]'>
<div className='min-h-0 flex-1 overflow-auto overscroll-none'>
<table className='border-collapse text-[13px]'>
<colgroup>
<col className='w-[40px]' />
{columns.map((col) => (
<col key={col.name} className='w-[160px]' />
))}
</colgroup>
<thead className='sticky top-0 z-10 bg-white shadow-[inset_0_-1px_0_var(--border)] dark:bg-[var(--bg)]'>
<tr>
<th className='border-[var(--border)] border-r py-[10px] pr-[12px] pl-[24px] text-left align-middle'>
<Checkbox size='sm' checked={isAllSelected} onCheckedChange={handleSelectAll} />
</TableHead>
</th>
{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>
)}
<th
key={column.name}
className='border-[var(--border)] border-r px-[24px] py-[10px] text-left align-middle'
>
<div className='flex items-center gap-[8px]'>
<ColumnTypeIcon type={column.type} />
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
{column.name}
</span>
</div>
</TableHead>
</th>
))}
</TableRow>
</TableHeader>
<TableBody>
</tr>
</thead>
<tbody>
{isLoadingRows ? (
<LoadingRows columns={columns} />
) : rows.length === 0 ? (
<EmptyRows
columnCount={columns.length}
hasFilter={!!queryOptions.filter}
onAddRow={handleAddRow}
/>
<TableSkeleton columns={columns} />
) : (
rows.map((row) => (
<TableRowCells
key={row.id}
row={row}
<>
{rows.map((row) => (
<DataRow
key={row.id}
row={row}
columns={columns}
isSelected={selectedRows.has(row.id)}
editingColumnName={
editingCell?.rowId === row.id ? editingCell.columnName : null
}
onCellClick={handleCellClick}
onDoubleClick={handleCellDoubleClick}
onSave={handleInlineSave}
onCancel={handleInlineCancel}
onBooleanToggle={handleBooleanToggle}
onContextMenu={handleRowContextMenu}
onSelectRow={handleSelectRow}
/>
))}
<PlaceholderRows
columns={columns}
isSelected={selectedRows.has(row.id)}
editingColumnName={editingCell?.rowId === row.id ? editingCell.columnName : null}
onCellClick={handleCellClick}
onDoubleClick={handleCellDoubleClick}
onSave={handleInlineSave}
onCancel={handleInlineCancel}
onBooleanToggle={handleBooleanToggle}
onContextMenu={handleRowContextMenu}
onSelectRow={handleSelectRow}
editingEmptyCell={editingEmptyCell}
onDoubleClick={handleEmptyRowDoubleClick}
onSave={handleEmptyRowSave}
onCancel={handleEmptyRowCancel}
/>
))
</>
)}
</TableBody>
</EmcnTable>
</tbody>
</table>
</div>
<Pagination
<TablePagination
currentPage={currentPage}
totalPages={totalPages}
totalCount={totalCount}
@@ -346,3 +374,432 @@ export function Table() {
</div>
)
}
const DataRow = React.memo(function DataRow({
row,
columns,
isSelected,
editingColumnName,
onCellClick,
onDoubleClick,
onSave,
onCancel,
onBooleanToggle,
onContextMenu,
onSelectRow,
}: {
row: TableRowType
columns: ColumnDefinition[]
isSelected: boolean
editingColumnName: string | null
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
onDoubleClick: (rowId: string, columnName: string) => void
onSave: (rowId: string, columnName: string, value: unknown) => void
onCancel: () => void
onBooleanToggle: (rowId: string, columnName: string, currentValue: boolean) => void
onContextMenu: (e: React.MouseEvent, row: TableRowType) => void
onSelectRow: (rowId: string) => void
}) {
return (
<tr
className={cn('group', isSelected && 'bg-[var(--surface-5)]')}
onContextMenu={(e) => onContextMenu(e, row)}
>
<td className='border-[var(--border)] border-r border-b py-[10px] pr-[12px] pl-[24px] align-middle'>
<Checkbox size='sm' checked={isSelected} onCheckedChange={() => onSelectRow(row.id)} />
</td>
{columns.map((column) => (
<td
key={column.name}
className='border-[var(--border)] border-r border-b px-[24px] py-[10px] align-middle'
>
<div className='max-w-[300px] truncate text-[13px]'>
<CellContent
value={row.data[column.name]}
column={column}
isEditing={editingColumnName === column.name}
onCellClick={onCellClick}
onDoubleClick={() => onDoubleClick(row.id, column.name)}
onSave={(value) => onSave(row.id, column.name, value)}
onCancel={onCancel}
onBooleanToggle={() =>
onBooleanToggle(row.id, column.name, Boolean(row.data[column.name]))
}
/>
</div>
</td>
))}
</tr>
)
})
function CellContent({
value,
column,
isEditing,
onCellClick,
onDoubleClick,
onSave,
onCancel,
onBooleanToggle,
}: {
value: unknown
column: ColumnDefinition
isEditing: boolean
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
onDoubleClick: () => void
onSave: (value: unknown) => void
onCancel: () => void
onBooleanToggle: () => void
}) {
if (isEditing) {
return <InlineEditor value={value} column={column} onSave={onSave} onCancel={onCancel} />
}
const isNull = value === null || value === undefined
if (column.type === 'boolean') {
const boolValue = Boolean(value)
return (
<button
type='button'
className='cursor-pointer select-none'
onClick={(e) => {
e.stopPropagation()
onBooleanToggle()
}}
>
<span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
{isNull ? (
<span className='text-[var(--text-muted)] italic'></span>
) : boolValue ? (
'true'
) : (
'false'
)}
</span>
</button>
)
}
if (isNull) {
return (
<span
className='cursor-text text-[var(--text-muted)] italic'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
</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')
}}
onDoubleClick={(e) => {
e.preventDefault()
e.stopPropagation()
onDoubleClick()
}}
title='Click to view, double-click to edit'
>
{jsonStr}
</button>
)
}
if (column.type === 'number') {
return (
<span
className='cursor-text font-mono text-[12px] text-[var(--text-secondary)]'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
{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 (
<span
className='cursor-text text-[12px] text-[var(--text-secondary)]'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
{formatted}
</span>
)
} catch {
return (
<span
className='cursor-text text-[var(--text-primary)]'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
{String(value)}
</span>
)
}
}
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')
}}
onDoubleClick={(e) => {
e.preventDefault()
e.stopPropagation()
onDoubleClick()
}}
title='Click to view, double-click to edit'
>
{strValue}
</button>
)
}
return (
<span
className='cursor-text text-[var(--text-primary)]'
onDoubleClick={(e) => {
e.stopPropagation()
onDoubleClick()
}}
>
{strValue}
</span>
)
}
function InlineEditor({
value,
column,
onSave,
onCancel,
}: {
value: unknown
column: ColumnDefinition
onSave: (value: unknown) => void
onCancel: () => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
const [draft, setDraft] = useState(() => formatValueForInput(value, column.type))
const doneRef = useRef(false)
useEffect(() => {
const input = inputRef.current
if (input) {
input.focus()
input.select()
}
}, [])
const handleSave = () => {
if (doneRef.current) return
doneRef.current = true
try {
const cleaned = cleanCellValue(draft, column)
onSave(cleaned)
} catch {
onCancel()
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSave()
} else if (e.key === 'Escape') {
e.preventDefault()
doneRef.current = true
onCancel()
}
}
const inputType = column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'
return (
<input
ref={inputRef}
type={inputType}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
className='h-full w-full rounded-[2px] border-none bg-transparent px-[4px] py-[2px] text-[13px] text-[var(--text-primary)] outline-none ring-1 ring-[var(--accent)] ring-inset'
/>
)
}
function TablePagination({
currentPage,
totalPages,
totalCount,
onPreviousPage,
onNextPage,
}: {
currentPage: number
totalPages: number
totalCount: number
onPreviousPage: () => void
onNextPage: () => void
}) {
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>
)
}
function TableSkeleton({ columns }: { columns: ColumnDefinition[] }) {
return (
<>
{Array.from({ length: 25 }).map((_, rowIndex) => (
<tr key={rowIndex}>
<td className='border-[var(--border)] border-r border-b py-[10px] pr-[12px] pl-[24px] align-middle'>
<Skeleton className='h-[14px] w-[14px]' />
</td>
{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 (
<td
key={col.name}
className='border-[var(--border)] border-r border-b px-[24px] py-[10px] align-middle'
>
<Skeleton className='h-[16px]' style={{ width: `${width}px` }} />
</td>
)
})}
</tr>
))}
</>
)
}
const PLACEHOLDER_ROW_COUNT = 50
function PlaceholderRows({
columns,
editingEmptyCell,
onDoubleClick,
onSave,
onCancel,
}: {
columns: ColumnDefinition[]
editingEmptyCell: { rowIndex: number; columnName: string } | null
onDoubleClick: (rowIndex: number, columnName: string) => void
onSave: (columnName: string, value: unknown) => void
onCancel: () => void
}) {
return (
<>
{Array.from({ length: PLACEHOLDER_ROW_COUNT }).map((_, i) => (
<tr key={`placeholder-${i}`}>
<td className='border-[var(--border)] border-r border-b py-[10px] pr-[12px] pl-[24px] align-middle' />
{columns.map((col) => {
const isEditing =
editingEmptyCell?.rowIndex === i && editingEmptyCell.columnName === col.name
return (
<td
key={col.name}
className='border-[var(--border)] border-r border-b px-[24px] py-[10px] align-middle'
onDoubleClick={() => onDoubleClick(i, col.name)}
>
{isEditing ? (
<InlineEditor
value={null}
column={col}
onSave={(value) => onSave(col.name, value)}
onCancel={onCancel}
/>
) : (
<div className='min-h-[20px]' />
)}
</td>
)
})}
</tr>
))}
</>
)
}
const COLUMN_TYPE_ICONS: Record<string, React.ElementType> = {
string: TypeText,
number: TypeNumber,
boolean: TypeBoolean,
date: CalendarIcon,
json: TypeJson,
}
function ColumnTypeIcon({ type }: { type: string }) {
const Icon = COLUMN_TYPE_ICONS[type] ?? TypeText
return <Icon className='h-[14px] w-[14px] shrink-0 text-[var(--text-muted)]' />
}

View File

@@ -24,11 +24,11 @@ import { cn } from '@/lib/core/utils/cn'
*/
const checkboxVariants = cva(
[
'peer shrink-0 cursor-pointer rounded-[4px] border transition-colors',
'peer flex shrink-0 cursor-pointer items-center justify-center rounded-[4px] border transition-colors',
'border-[var(--border-1)] bg-transparent',
'focus-visible:outline-none',
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
'data-[state=checked]:border-[var(--brand-tertiary-2)] data-[state=checked]:bg-[var(--brand-tertiary-2)]',
'data-[state=checked]:border-[var(--text-primary)] data-[state=checked]:bg-[var(--text-primary)]',
].join(' '),
{
variants: {
@@ -89,7 +89,7 @@ const Checkbox = React.forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root
className={cn(checkboxVariants({ size }), className)}
{...props}
>
<CheckboxPrimitive.Indicator className='flex items-center justify-center text-[var(--white)]'>
<CheckboxPrimitive.Indicator className='flex items-center justify-center text-[var(--surface-2)]'>
<Check className={cn(checkboxIconVariants({ size }))} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>

View File

@@ -39,6 +39,10 @@ export { Table } from './table'
export { TerminalWindow } from './terminal-window'
export { Trash } from './trash'
export { Trash2 } from './trash2'
export { TypeBoolean } from './type-boolean'
export { TypeJson } from './type-json'
export { TypeNumber } from './type-number'
export { TypeText } from './type-text'
export { Undo } from './undo'
export { Wrap } from './wrap'
export { ZoomIn } from './zoom-in'

View File

@@ -0,0 +1,25 @@
import type { SVGProps } from 'react'
/**
* Type boolean icon component - toggle switch for boolean columns
* @param props - SVG properties including className, fill, etc.
*/
export function TypeBoolean(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<rect x='1.25' y='5.25' width='18' height='10.5' rx='5.25' />
<circle cx='6.5' cy='10.5' r='3' />
</svg>
)
}

View File

@@ -0,0 +1,25 @@
import type { SVGProps } from 'react'
/**
* Type JSON icon component - curly braces for JSON columns
* @param props - SVG properties including className, fill, etc.
*/
export function TypeJson(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M5.75 1.25C3.54086 1.25 1.75 3.04086 1.75 5.25V7.75C1.75 9.13071 0.630712 10.25 -0.75 10.25C0.630712 10.25 1.75 11.3693 1.75 12.75V15.25C1.75 17.4591 3.54086 19.25 5.75 19.25' />
<path d='M14.75 1.25C16.9591 1.25 18.75 3.04086 18.75 5.25V7.75C18.75 9.13071 19.8693 10.25 21.25 10.25C19.8693 10.25 18.75 11.3693 18.75 12.75V15.25C18.75 17.4591 16.9591 19.25 14.75 19.25' />
</svg>
)
}

View File

@@ -0,0 +1,27 @@
import type { SVGProps } from 'react'
/**
* Type number icon component - hash symbol for number columns
* @param props - SVG properties including className, fill, etc.
*/
export function TypeNumber(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M3.25 7.25H17.75' />
<path d='M2.75 13.75H17.25' />
<path d='M8.25 1.25L6.25 19.75' />
<path d='M14.25 1.25L12.25 19.75' />
</svg>
)
}

View File

@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* Type text icon component - letter T for string columns
* @param props - SVG properties including className, fill, etc.
*/
export function TypeText(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M3.25 2.25H17.25' />
<path d='M10.25 2.25V18.75' />
<path d='M7.25 18.75H13.25' />
</svg>
)
}