This commit is contained in:
waleed
2026-01-21 20:14:30 -08:00
parent be757a4f1e
commit b03d1f5d58
38 changed files with 1819 additions and 1086 deletions

View File

@@ -555,7 +555,7 @@ export function DocumentTagsModal({
Cancel
</Button>
<Button
variant='tertiary'
variant={canSaveTag ? 'tertiary' : 'default'}
onClick={saveDocumentTag}
className='flex-1'
disabled={!canSaveTag}

View File

@@ -300,7 +300,7 @@ export function EditChunkModal({
</Button>
{userPermissions.canEdit && (
<Button
variant='tertiary'
variant={hasUnsavedChanges ? 'tertiary' : 'default'}
onClick={handleSaveContent}
type='button'
disabled={!isFormValid || isSaving || !hasUnsavedChanges || isNavigating}

View File

@@ -39,6 +39,9 @@ export function RenameDocumentModal({
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Check if name has changed from initial value
const hasChanges = name.trim() !== initialName.trim()
useEffect(() => {
if (open) {
setName(initialName)
@@ -123,7 +126,11 @@ export function RenameDocumentModal({
>
Cancel
</Button>
<Button variant='tertiary' type='submit' disabled={isSubmitting || !name?.trim()}>
<Button
variant={hasChanges ? 'tertiary' : 'default'}
type='submit'
disabled={isSubmitting || !name?.trim() || !hasChanges}
>
{isSubmitting ? 'Renaming...' : 'Rename'}
</Button>
</div>

View File

@@ -1,8 +1,8 @@
'use client'
import { useCallback, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Badge, DocumentAttachment, Tooltip } from '@/components/emcn'
import { Badge, Button, DocumentAttachment, Tooltip } from '@/components/emcn'
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
@@ -143,6 +143,7 @@ export function BaseCard({
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const menuButtonRef = useRef<HTMLButtonElement>(null)
const searchParams = new URLSearchParams({
kbName: title,
@@ -151,6 +152,23 @@ export function BaseCard({
const shortId = id ? `kb-${id.slice(0, 8)}` : ''
const handleMenuButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (menuButtonRef.current) {
const rect = menuButtonRef.current.getBoundingClientRect()
const syntheticEvent = {
preventDefault: () => {},
stopPropagation: () => {},
clientX: rect.right,
clientY: rect.bottom,
} as React.MouseEvent
handleContextMenu(syntheticEvent)
}
},
[handleContextMenu]
)
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (isContextMenuOpen) {
@@ -223,9 +241,24 @@ export function BaseCard({
<h3 className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
{title}
</h3>
{shortId && (
<Badge className='flex-shrink-0 rounded-[4px] text-[12px]'>{shortId}</Badge>
)}
<div className='flex items-center gap-[4px]'>
{shortId && (
<Badge className='flex-shrink-0 rounded-[4px] text-[12px]'>{shortId}</Badge>
)}
<Button
ref={menuButtonRef}
variant='ghost'
size='sm'
className='h-[20px] w-[20px] flex-shrink-0 p-0 text-[var(--text-tertiary)]'
onClick={handleMenuButtonClick}
>
<svg className='h-[14px] w-[14px]' viewBox='0 0 16 16' fill='currentColor'>
<circle cx='3' cy='8' r='1.5' />
<circle cx='8' cy='8' r='1.5' />
<circle cx='13' cy='8' r='1.5' />
</svg>
</Button>
</div>
</div>
<div className='flex flex-1 flex-col gap-[8px]'>

View File

@@ -70,6 +70,12 @@ export function EditKnowledgeBaseModal({
})
const nameValue = watch('name')
const descriptionValue = watch('description')
// Check if form values have changed from initial values
const hasChanges =
nameValue?.trim() !== initialName.trim() ||
(descriptionValue?.trim() || '') !== (initialDescription?.trim() || '')
useEffect(() => {
if (open) {
@@ -159,9 +165,9 @@ export function EditKnowledgeBaseModal({
Cancel
</Button>
<Button
variant='tertiary'
variant={hasChanges ? 'tertiary' : 'default'}
type='submit'
disabled={isSubmitting || !nameValue?.trim()}
disabled={isSubmitting || !nameValue?.trim() || !hasChanges}
>
{isSubmitting ? 'Saving...' : 'Save'}
</Button>

View File

@@ -265,12 +265,11 @@ export function Knowledge() {
</div>
</div>
) : error ? (
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
Error loading knowledge bases
</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>{error}</p>
<div className='col-span-full flex h-64 items-center justify-center'>
<div className='text-[var(--text-error)]'>
<span className='text-[13px]'>
Error: {typeof error === 'string' ? error : 'Failed to load knowledge bases'}
</span>
</div>
</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

@@ -53,7 +53,7 @@ export function EmptyRows({ columnCount, hasFilter, onAddRow }: EmptyRowsProps)
return (
<TableRow>
<TableCell colSpan={columnCount + 1} className='h-[160px]'>
<div className='fixed left-1/2 -translate-x-1/2'>
<div className='flex h-full w-full items-center justify-center'>
<div className='flex flex-col items-center gap-[12px]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
{hasFilter ? 'No rows match your filter' : 'No data'}

View File

@@ -0,0 +1,207 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { Checkbox, Input, Textarea } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition } from '@/lib/table'
interface EditableCellProps {
value: unknown
column: ColumnDefinition
onChange: (value: unknown) => void
isEditing?: boolean
isNew?: boolean
}
function formatValueForDisplay(value: unknown, type: string): string {
if (value === null || value === undefined) return 'NULL'
if (type === 'json') {
return typeof value === 'string' ? value : JSON.stringify(value)
}
if (type === 'boolean') {
return value ? 'TRUE' : 'FALSE'
}
if (type === 'date' && value) {
try {
const date = new Date(String(value))
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return String(value)
}
}
return String(value)
}
function formatValueForInput(value: unknown, 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(String(value))
return date.toISOString().split('T')[0]
} catch {
return String(value)
}
}
return String(value)
}
export function EditableCell({
value,
column,
onChange,
isEditing = false,
isNew = false,
}: EditableCellProps) {
const [localValue, setLocalValue] = useState<unknown>(value)
const [isActive, setIsActive] = useState(false)
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
useEffect(() => {
setLocalValue(value)
}, [value])
useEffect(() => {
if (isActive && inputRef.current) {
inputRef.current.focus()
}
}, [isActive])
const handleFocus = useCallback(() => {
setIsActive(true)
}, [])
const handleBlur = useCallback(() => {
setIsActive(false)
if (localValue !== value) {
onChange(localValue)
}
}, [localValue, value, onChange])
const handleChange = useCallback((newValue: unknown) => {
setLocalValue(newValue)
}, [])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && column.type !== 'json') {
e.preventDefault()
;(e.target as HTMLElement).blur()
}
if (e.key === 'Escape') {
setLocalValue(value)
;(e.target as HTMLElement).blur()
}
},
[value, column.type]
)
const isNull = value === null || value === undefined
// Boolean type - always show checkbox
if (column.type === 'boolean') {
return (
<div className='flex items-center'>
<Checkbox
size='sm'
checked={Boolean(localValue)}
onCheckedChange={(checked) => {
const newValue = checked === true
setLocalValue(newValue)
onChange(newValue)
}}
/>
<span
className={cn(
'ml-[8px] text-[12px]',
localValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'
)}
>
{localValue ? 'TRUE' : 'FALSE'}
</span>
</div>
)
}
// JSON type - use textarea
if (column.type === 'json') {
if (isActive || isNew) {
return (
<Textarea
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
value={formatValueForInput(localValue, column.type)}
onChange={(e) => handleChange(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className='h-[60px] min-w-[200px] resize-none font-mono text-[11px]'
placeholder='{"key": "value"}'
/>
)
}
return (
<button
type='button'
onClick={handleFocus}
className={cn(
'group flex max-w-[300px] cursor-pointer items-center truncate text-left font-mono text-[11px] transition-colors',
isNull
? 'text-[var(--text-muted)] italic'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
)}
>
<span className='truncate'>{formatValueForDisplay(value, column.type)}</span>
<ChevronRight className='ml-[4px] h-[10px] w-[10px] opacity-0 group-hover:opacity-100' />
</button>
)
}
// Active/editing state for other types
if (isActive || isNew) {
return (
<Input
ref={inputRef as React.RefObject<HTMLInputElement>}
type={column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'}
value={formatValueForInput(localValue, column.type)}
onChange={(e) => handleChange(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={cn(
'h-[28px] min-w-[120px] text-[12px]',
column.type === 'number' && 'font-mono'
)}
placeholder={isNull ? 'NULL' : ''}
/>
)
}
// Display state
return (
<button
type='button'
onClick={handleFocus}
className={cn(
'group flex max-w-[300px] cursor-pointer items-center truncate text-left text-[13px] transition-colors',
isNull
? 'text-[var(--text-muted)] italic'
: column.type === 'number'
? 'font-mono text-[12px] text-[var(--text-secondary)]'
: 'text-[var(--text-primary)]'
)}
>
<span className='truncate'>{formatValueForDisplay(value, column.type)}</span>
<ChevronRight className='ml-[4px] h-[10px] w-[10px] opacity-0 group-hover:opacity-100' />
</button>
)
}

View File

@@ -0,0 +1,41 @@
'use client'
import { X } from 'lucide-react'
import { Button, TableCell, TableRow } from '@/components/emcn'
import type { ColumnDefinition } from '@/lib/table'
import type { TempRow } from '../hooks/use-inline-editing'
import { EditableCell } from './editable-cell'
interface EditableRowProps {
row: TempRow
columns: ColumnDefinition[]
onUpdateCell: (tempId: string, column: string, value: unknown) => void
onRemove: (tempId: string) => void
}
export function EditableRow({ row, columns, onUpdateCell, onRemove }: EditableRowProps) {
return (
<TableRow className='bg-amber-500/20 hover:bg-amber-500/30'>
<TableCell className='w-[40px]'>
<Button
variant='ghost'
size='sm'
onClick={() => onRemove(row.tempId)}
className='h-[20px] w-[20px] p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
</TableCell>
{columns.map((column) => (
<TableCell key={column.name}>
<EditableCell
value={row.data[column.name]}
column={column}
onChange={(value) => onUpdateCell(row.tempId, column.name, value)}
isNew
/>
</TableCell>
))}
</TableRow>
)
}

View File

@@ -0,0 +1,169 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Plus, X } from 'lucide-react'
import { Button, Combobox, Input } from '@/components/emcn'
import type { FilterRule } from '@/lib/table/query-builder/constants'
import { filterRulesToFilter } from '@/lib/table/query-builder/converters'
import { useFilterBuilder } from '@/lib/table/query-builder/use-query-builder'
import type { ColumnDefinition } from '@/lib/table/types'
import type { QueryOptions } from '../lib/types'
type Column = Pick<ColumnDefinition, 'name' | 'type'>
interface FilterPanelProps {
columns: Column[]
isVisible: boolean
onApply: (options: QueryOptions) => void
onClose: () => void
isLoading?: boolean
}
// Operators that don't need a value input
const NO_VALUE_OPERATORS = ['is_null', 'is_not_null']
// Options for the first filter row
const WHERE_OPTIONS = [{ value: 'where', label: 'where' }]
export function FilterPanel({
columns,
isVisible,
onApply,
onClose,
isLoading = false,
}: FilterPanelProps) {
const [rules, setRules] = useState<FilterRule[]>([])
const columnOptions = useMemo(
() => columns.map((col) => ({ value: col.name, label: col.name })),
[columns]
)
const {
comparisonOptions,
logicalOptions,
addRule: handleAddRule,
removeRule: handleRemoveRule,
updateRule: handleUpdateRule,
} = useFilterBuilder({
columns: columnOptions,
rules,
setRules,
})
// Auto-add first filter when panel opens with no filters
useEffect(() => {
if (isVisible && rules.length === 0 && columns.length > 0) {
handleAddRule()
}
}, [isVisible, rules.length, columns.length, handleAddRule])
const handleApply = useCallback(() => {
const filter = filterRulesToFilter(rules)
onApply({ filter, sort: null })
}, [rules, onApply])
const handleClear = useCallback(() => {
setRules([])
onApply({ filter: null, sort: null })
onClose()
}, [onApply, onClose])
if (!isVisible) {
return null
}
return (
<div className='flex shrink-0 flex-col gap-2 border-[var(--border)] border-b px-4 py-3'>
{rules.map((rule, index) => {
const needsValue = !NO_VALUE_OPERATORS.includes(rule.operator)
const isFirst = index === 0
return (
<div key={rule.id} className='flex items-center gap-2'>
{/* Remove button */}
<Button
variant='ghost'
size='sm'
onClick={() => handleRemoveRule(rule.id)}
aria-label='Remove filter'
className='shrink-0 p-1'
>
<X className='h-3.5 w-3.5' />
</Button>
{/* Where / And / Or */}
<div className='w-20 shrink-0'>
{isFirst ? (
<Combobox size='sm' options={WHERE_OPTIONS} value='where' onChange={() => {}} />
) : (
<Combobox
size='sm'
options={logicalOptions}
value={rule.logicalOperator}
onChange={(value) =>
handleUpdateRule(rule.id, 'logicalOperator', value as 'and' | 'or')
}
/>
)}
</div>
{/* Column */}
<div className='w-[140px] shrink-0'>
<Combobox
size='sm'
options={columnOptions}
value={rule.column}
onChange={(value) => handleUpdateRule(rule.id, 'column', value)}
placeholder='Column'
/>
</div>
{/* Operator */}
<div className='w-[120px] shrink-0'>
<Combobox
size='sm'
options={comparisonOptions}
value={rule.operator}
onChange={(value) => handleUpdateRule(rule.id, 'operator', value)}
/>
</div>
{/* Value (only if operator needs it) */}
{needsValue && (
<Input
className='w-[160px] shrink-0'
value={rule.value}
onChange={(e) => handleUpdateRule(rule.id, 'value', e.target.value)}
placeholder='Enter a value'
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleApply()
}
}}
/>
)}
{/* Actions - only on first row */}
{isFirst && (
<div className='ml-1 flex items-center gap-1'>
<Button variant='tertiary' size='sm' onClick={handleApply} disabled={isLoading}>
Apply
</Button>
<Button variant='ghost' size='sm' onClick={handleAddRule}>
<Plus className='h-3 w-3' />
Add filter
</Button>
<Button variant='ghost' size='sm' onClick={handleClear}>
Clear filters
</Button>
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -1,63 +0,0 @@
import { Info, RefreshCw } from 'lucide-react'
import { Badge, Button, Tooltip } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
interface HeaderBarProps {
tableName: string
totalCount: number
isLoading: boolean
onNavigateBack: () => void
onShowSchema: () => void
onRefresh: () => void
}
export function HeaderBar({
tableName,
totalCount,
isLoading,
onNavigateBack,
onShowSchema,
onRefresh,
}: HeaderBarProps) {
return (
<div className='flex h-[48px] shrink-0 items-center justify-between border-[var(--border)] border-b px-[16px]'>
<div className='flex items-center gap-[8px]'>
<button
onClick={onNavigateBack}
className='text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
Tables
</button>
<span className='text-[var(--text-muted)]'>/</span>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>{tableName}</span>
{isLoading ? (
<Skeleton className='h-[18px] w-[60px] rounded-full' />
) : (
<Badge variant='gray-secondary' size='sm'>
{totalCount} {totalCount === 1 ? 'row' : 'rows'}
</Badge>
)}
</div>
<div className='flex items-center gap-[8px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' size='sm' onClick={onShowSchema}>
<Info className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>View Schema</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' size='sm' onClick={onRefresh}>
<RefreshCw className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Refresh</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
)
}

View File

@@ -1,11 +1,11 @@
export * from './action-bar'
export * from './body-states'
export * from './cell-renderer'
export * from './cell-viewer-modal'
export * from './context-menu'
export * from './header-bar'
export * from './pagination'
export * from './query-builder'
export * from './editable-cell'
export * from './editable-row'
export * from './filter-panel'
export * from './row-modal'
export * from './schema-modal'
export * from './table-toolbar'
export * from './table-viewer'

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,89 +0,0 @@
'use client'
import { X } from 'lucide-react'
import { Button, Combobox, Input } from '@/components/emcn'
import type { FilterRule } from '@/lib/table/query-builder/constants'
interface FilterRowProps {
rule: FilterRule
index: number
columnOptions: Array<{ value: string; label: string }>
comparisonOptions: Array<{ value: string; label: string }>
logicalOptions: Array<{ value: string; label: string }>
onUpdate: (id: string, field: keyof FilterRule, value: string) => void
onRemove: (id: string) => void
onApply: () => void
}
export function FilterRow({
rule,
index,
columnOptions,
comparisonOptions,
logicalOptions,
onUpdate,
onRemove,
onApply,
}: FilterRowProps) {
return (
<div className='flex items-center gap-[8px]'>
<Button
variant='ghost'
size='sm'
onClick={() => onRemove(rule.id)}
className='h-[28px] w-[28px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
<div className='w-[80px] shrink-0'>
{index === 0 ? (
<Combobox
size='sm'
options={[{ value: 'where', label: 'where' }]}
value='where'
disabled
/>
) : (
<Combobox
size='sm'
options={logicalOptions}
value={rule.logicalOperator}
onChange={(value) => onUpdate(rule.id, 'logicalOperator', value as 'and' | 'or')}
/>
)}
</div>
<div className='w-[140px] shrink-0'>
<Combobox
size='sm'
options={columnOptions}
value={rule.column}
onChange={(value) => onUpdate(rule.id, 'column', value)}
placeholder='Column'
/>
</div>
<div className='w-[130px] shrink-0'>
<Combobox
size='sm'
options={comparisonOptions}
value={rule.operator}
onChange={(value) => onUpdate(rule.id, 'operator', value)}
/>
</div>
<Input
className='h-[28px] min-w-[200px] flex-1 text-[12px]'
value={rule.value}
onChange={(e) => onUpdate(rule.id, 'value', e.target.value)}
placeholder='Value'
onKeyDown={(e) => {
if (e.key === 'Enter') {
onApply()
}
}}
/>
</div>
)
}

View File

@@ -1,137 +0,0 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { ArrowUpAZ, Loader2, Plus } from 'lucide-react'
import { nanoid } from 'nanoid'
import { Button } from '@/components/emcn'
import type { FilterRule, SortRule } from '@/lib/table/query-builder/constants'
import { filterRulesToFilter, sortRuleToSort } from '@/lib/table/query-builder/converters'
import { useFilterBuilder } from '@/lib/table/query-builder/use-query-builder'
import type { ColumnDefinition } from '@/lib/table/types'
import type { QueryOptions } from '../../lib/types'
import { FilterRow } from './filter-row'
import { SortRow } from './sort-row'
type Column = Pick<ColumnDefinition, 'name' | 'type'>
interface QueryBuilderProps {
columns: Column[]
onApply: (options: QueryOptions) => void
onAddRow: () => void
isLoading?: boolean
}
export function QueryBuilder({ columns, onApply, onAddRow, isLoading = false }: QueryBuilderProps) {
const [rules, setRules] = useState<FilterRule[]>([])
const [sortRule, setSortRule] = useState<SortRule | null>(null)
const columnOptions = useMemo(
() => columns.map((col) => ({ value: col.name, label: col.name })),
[columns]
)
const {
comparisonOptions,
logicalOptions,
sortDirectionOptions,
addRule: handleAddRule,
removeRule: handleRemoveRule,
updateRule: handleUpdateRule,
} = useFilterBuilder({
columns: columnOptions,
rules,
setRules,
})
const handleAddSort = useCallback(() => {
setSortRule({
id: nanoid(),
column: columns[0]?.name || '',
direction: 'asc',
})
}, [columns])
const handleRemoveSort = useCallback(() => {
setSortRule(null)
}, [])
const handleApply = useCallback(() => {
const filter = filterRulesToFilter(rules)
const sort = sortRuleToSort(sortRule)
onApply({ filter, sort })
}, [rules, sortRule, onApply])
const handleClear = useCallback(() => {
setRules([])
setSortRule(null)
onApply({
filter: null,
sort: null,
})
}, [onApply])
const hasChanges = rules.length > 0 || sortRule !== null
return (
<div className='flex flex-col gap-[8px]'>
{rules.map((rule, index) => (
<FilterRow
key={rule.id}
rule={rule}
index={index}
columnOptions={columnOptions}
comparisonOptions={comparisonOptions}
logicalOptions={logicalOptions}
onUpdate={handleUpdateRule}
onRemove={handleRemoveRule}
onApply={handleApply}
/>
))}
{sortRule && (
<SortRow
sortRule={sortRule}
columnOptions={columnOptions}
sortDirectionOptions={sortDirectionOptions}
onChange={setSortRule}
onRemove={handleRemoveSort}
/>
)}
<div className='flex items-center gap-[8px]'>
<Button variant='default' size='sm' onClick={onAddRow}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add row
</Button>
<Button variant='default' size='sm' onClick={handleAddRule}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add filter
</Button>
{!sortRule && (
<Button variant='default' size='sm' onClick={handleAddSort}>
<ArrowUpAZ className='mr-[4px] h-[12px] w-[12px]' />
Add sort
</Button>
)}
{hasChanges && (
<>
<Button variant='default' size='sm' onClick={handleApply} disabled={isLoading}>
{isLoading && <Loader2 className='mr-[4px] h-[12px] w-[12px] animate-spin' />}
{isLoading ? 'Applying...' : 'Apply'}
</Button>
<button
onClick={handleClear}
className='text-[12px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
Clear all
</button>
</>
)}
</div>
</div>
)
}

View File

@@ -1,65 +0,0 @@
'use client'
import { ArrowDownAZ, ArrowUpAZ, X } from 'lucide-react'
import { Button, Combobox } from '@/components/emcn'
import type { SortRule } from '@/lib/table/query-builder/constants'
interface SortRowProps {
sortRule: SortRule
columnOptions: Array<{ value: string; label: string }>
sortDirectionOptions: Array<{ value: string; label: string }>
onChange: (rule: SortRule | null) => void
onRemove: () => void
}
export function SortRow({
sortRule,
columnOptions,
sortDirectionOptions,
onChange,
onRemove,
}: SortRowProps) {
return (
<div className='flex items-center gap-[8px]'>
<Button
variant='ghost'
size='sm'
onClick={onRemove}
className='h-[28px] w-[28px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
<div className='w-[80px] shrink-0'>
<Combobox size='sm' options={[{ value: 'order', label: 'order' }]} value='order' disabled />
</div>
<div className='w-[140px] shrink-0'>
<Combobox
size='sm'
options={columnOptions}
value={sortRule.column}
onChange={(value) => onChange({ ...sortRule, column: value })}
placeholder='Column'
/>
</div>
<div className='w-[130px] shrink-0'>
<Combobox
size='sm'
options={sortDirectionOptions}
value={sortRule.direction}
onChange={(value) => onChange({ ...sortRule, direction: value as 'asc' | 'desc' })}
/>
</div>
<div className='flex items-center text-[12px] text-[var(--text-tertiary)]'>
{sortRule.direction === 'asc' ? (
<ArrowUpAZ className='h-[14px] w-[14px]' />
) : (
<ArrowDownAZ className='h-[14px] w-[14px]' />
)}
</div>
</div>
)
}

View File

@@ -1,10 +1,10 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { AlertCircle } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
Button,
Checkbox,
Input,
@@ -92,6 +92,13 @@ function formatValueForInput(value: unknown, type: string): string {
return String(value)
}
function isFieldEmpty(value: unknown, type: string): boolean {
if (value === null || value === undefined) return true
if (type === 'boolean') return false // booleans always have a value (true/false)
if (typeof value === 'string') return value.trim() === ''
return false
}
export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess }: RowModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -103,6 +110,12 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
// Check if all required fields are filled
const hasRequiredFields = useMemo(() => {
const requiredColumns = columns.filter((col) => col.required)
return requiredColumns.every((col) => !isFieldEmpty(rowData[col.name], col.type))
}, [columns, rowData])
// Initialize form data based on mode
useEffect(() => {
if (!isOpen) return
@@ -228,43 +241,24 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
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 {isSingleRow ? 'Row' : `${deleteCount} Rows`}
</h2>
</div>
</ModalHeader>
<ModalContent size='sm'>
<ModalHeader>Delete {isSingleRow ? 'Row' : `${deleteCount} Rows`}</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-[16px]'>
<ErrorMessage error={error} />
<p className='text-[14px] text-[var(--text-secondary)]'>
Are you sure you want to delete {isSingleRow ? 'this row' : 'these rows'}? This
action cannot be undone.
</p>
</div>
<ErrorMessage error={error} />
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{isSingleRow ? '1 row' : `${deleteCount} rows`}
</span>
? This will permanently remove the data.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter className='gap-[10px]'>
<Button
type='button'
variant='default'
onClick={handleClose}
className='min-w-[90px]'
disabled={isSubmitting}
>
<ModalFooter>
<Button variant='default' onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
type='button'
variant='destructive'
onClick={handleDelete}
disabled={isSubmitting}
className='min-w-[120px]'
>
<Button variant='destructive' onClick={handleDelete} disabled={isSubmitting}>
{isSubmitting ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
@@ -277,19 +271,12 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent className='w-[600px]'>
<ModalHeader>
<div className='flex flex-col gap-[4px]'>
<h2 className='font-semibold text-[16px]'>{isAddMode ? 'Add New Row' : 'Edit Row'}</h2>
<p className='font-normal text-[13px] text-[var(--text-tertiary)]'>
{isAddMode ? 'Fill in the values for' : 'Update values for'} {table?.name ?? 'table'}
</p>
</div>
</ModalHeader>
<ModalBody className='max-h-[60vh] overflow-y-auto'>
<form onSubmit={handleFormSubmit} className='flex flex-col gap-[16px]'>
<ErrorMessage error={error} />
<ModalContent className='max-w-[480px]'>
<ModalHeader>{isAddMode ? 'Add New Row' : 'Edit Row'}</ModalHeader>
<ModalBody className='max-h-[60vh] space-y-[12px] overflow-y-auto'>
<ErrorMessage error={error} />
<div className='flex flex-col gap-[8px]'>
{columns.map((column) => (
<ColumnField
key={column.name}
@@ -298,24 +285,16 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
onChange={(value) => setRowData((prev) => ({ ...prev, [column.name]: value }))}
/>
))}
</form>
</div>
</ModalBody>
<ModalFooter className='gap-[10px]'>
<Button
type='button'
variant='default'
onClick={handleClose}
className='min-w-[90px]'
disabled={isSubmitting}
>
<ModalFooter>
<Button variant='default' onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
type='button'
variant='tertiary'
onClick={handleFormSubmit}
disabled={isSubmitting}
className='min-w-[120px]'
disabled={isSubmitting || !hasRequiredFields}
>
{isSubmitting
? isAddMode
@@ -348,19 +327,9 @@ interface ColumnFieldProps {
}
function ColumnField({ column, value, onChange }: ColumnFieldProps) {
return (
<div 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' ? (
const renderInput = () => {
if (column.type === 'boolean') {
return (
<div className='flex items-center gap-[8px]'>
<Checkbox
id={column.name}
@@ -374,31 +343,56 @@ function ColumnField({ column, value, onChange }: ColumnFieldProps) {
{value ? 'True' : 'False'}
</Label>
</div>
) : column.type === 'json' ? (
)
}
if (column.type === 'json') {
return (
<Textarea
id={column.name}
value={formatValueForInput(value, column.type)}
onChange={(e) => onChange(e.target.value)}
placeholder='{"key": "value"}'
rows={4}
rows={3}
className='font-mono text-[12px]'
required={column.required}
/>
) : (
<Input
id={column.name}
type={column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'}
value={formatValueForInput(value, column.type)}
onChange={(e) => onChange(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)'}
return (
<Input
id={column.name}
type={column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'}
value={formatValueForInput(value, column.type)}
onChange={(e) => onChange(e.target.value)}
placeholder={`Enter ${column.name}`}
required={column.required}
/>
)
}
return (
<div className='overflow-hidden rounded-[4px] border border-[var(--border-1)]'>
<div className='flex items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{column.name}
{column.required && <span className='text-[var(--text-error)]'> *</span>}
</span>
<Badge size='sm'>{column.type}</Badge>
{column.unique && (
<Badge size='sm' variant='gray-secondary'>
unique
</Badge>
)}
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Value</Label>
{renderInput()}
</div>
</div>
</div>
)

View File

@@ -1,10 +1,11 @@
import { Info, X } from 'lucide-react'
import {
Badge,
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Table,
TableBody,
TableCell,
@@ -13,7 +14,6 @@ import {
TableRow,
} from '@/components/emcn'
import type { ColumnDefinition } from '@/lib/table'
import { getTypeBadgeVariant } from '../lib/utils'
interface SchemaModalProps {
isOpen: boolean
@@ -24,20 +24,9 @@ interface SchemaModalProps {
export function SchemaModal({ isOpen, onClose, columns }: SchemaModalProps) {
return (
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent className='w-[500px] duration-100'>
<div className='flex items-center justify-between gap-[8px] px-[16px] py-[10px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
<Info className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
<span className='font-medium text-[14px] text-[var(--text-primary)]'>Table Schema</span>
<Badge variant='gray' size='sm'>
{columns.length} columns
</Badge>
</div>
<Button variant='ghost' size='sm' onClick={onClose}>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
<ModalBody className='p-0'>
<ModalContent size='sm'>
<ModalHeader>Table Schema</ModalHeader>
<ModalBody>
<div className='max-h-[400px] overflow-auto'>
<Table>
<TableHeader>
@@ -50,23 +39,21 @@ export function SchemaModal({ isOpen, onClose, columns }: SchemaModalProps) {
<TableBody>
{columns.map((column) => (
<TableRow key={column.name}>
<TableCell className='font-mono text-[12px] text-[var(--text-primary)]'>
{column.name}
</TableCell>
<TableCell>{column.name}</TableCell>
<TableCell>
<Badge variant={getTypeBadgeVariant(column.type)} size='sm'>
<Badge variant='gray-secondary' size='sm'>
{column.type}
</Badge>
</TableCell>
<TableCell className='text-[12px]'>
<TableCell>
<div className='flex gap-[6px]'>
{column.required && (
<Badge variant='red' size='sm'>
<Badge variant='gray-secondary' size='sm'>
required
</Badge>
)}
{column.unique && (
<Badge variant='purple' size='sm'>
<Badge variant='gray-secondary' size='sm'>
unique
</Badge>
)}
@@ -81,6 +68,11 @@ export function SchemaModal({ isOpen, onClose, columns }: SchemaModalProps) {
</Table>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => onClose(false)}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)

View File

@@ -0,0 +1,194 @@
'use client'
import {
ChevronLeft,
ChevronRight,
Filter,
MoreHorizontal,
Plus,
RefreshCw,
Trash2,
} from 'lucide-react'
import {
Button,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
interface TableToolbarProps {
tableName: string
totalCount: number
isLoading: boolean
onNavigateBack: () => void
onShowSchema: () => void
onRefresh: () => void
showFilters: boolean
onToggleFilters: () => void
onAddRecord: () => void
selectedCount: number
onDeleteSelected: () => void
onClearSelection: () => void
hasPendingChanges: boolean
onSaveChanges: () => void
onDiscardChanges: () => void
isSaving: boolean
currentPage: number
totalPages: number
onPreviousPage: () => void
onNextPage: () => void
}
export function TableToolbar({
tableName,
totalCount,
isLoading,
onNavigateBack,
onShowSchema,
onRefresh,
showFilters,
onToggleFilters,
onAddRecord,
selectedCount,
onDeleteSelected,
onClearSelection,
hasPendingChanges,
onSaveChanges,
onDiscardChanges,
isSaving,
currentPage,
totalPages,
onPreviousPage,
onNextPage,
}: TableToolbarProps) {
const hasSelection = selectedCount > 0
return (
<div className='flex h-[48px] shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-2)] px-[16px]'>
{/* Left section: Navigation and table info */}
<div className='flex items-center gap-[8px]'>
<button
onClick={onNavigateBack}
className='text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
Tables
</button>
<span className='text-[var(--text-muted)]'>/</span>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>{tableName}</span>
</div>
{/* Center section: Main actions */}
<div className='flex items-center gap-[8px]'>
{/* Pagination controls */}
<div className='flex items-center gap-[2px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={onPreviousPage}
disabled={currentPage === 0 || isLoading}
>
<ChevronLeft className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Previous page</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={onNextPage}
disabled={currentPage >= totalPages - 1 || isLoading}
>
<ChevronRight className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Next page</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='mx-[4px] h-[20px] w-[1px] bg-[var(--border)]' />
{/* Filters toggle */}
<Button variant={showFilters ? 'secondary' : 'ghost'} size='sm' onClick={onToggleFilters}>
<Filter className='mr-[4px] h-[12px] w-[12px]' />
Filters
</Button>
<div className='mx-[4px] h-[20px] w-[1px] bg-[var(--border)]' />
{/* Pending changes actions */}
{hasPendingChanges ? (
<>
<Button variant='tertiary' size='sm' onClick={onSaveChanges} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save changes'}
</Button>
<Button variant='ghost' size='sm' onClick={onDiscardChanges} disabled={isSaving}>
Discard changes
</Button>
</>
) : (
<>
{/* Add record */}
<Button variant='default' size='sm' onClick={onAddRecord}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add record
</Button>
{/* Delete selected */}
{hasSelection && (
<Button variant='destructive' size='sm' onClick={onDeleteSelected}>
<Trash2 className='mr-[4px] h-[12px] w-[12px]' />
Delete {selectedCount} {selectedCount === 1 ? 'record' : 'records'}
</Button>
)}
</>
)}
{/* Clear selection */}
{hasSelection && !hasPendingChanges && (
<Button variant='ghost' size='sm' onClick={onClearSelection}>
Clear selection
</Button>
)}
</div>
{/* Right section: Row count and utilities */}
<div className='flex items-center gap-[6px]'>
{isLoading ? (
<Skeleton className='h-[16px] w-[50px]' />
) : (
<span className='text-[13px] text-[var(--text-tertiary)]'>
{totalCount} {totalCount === 1 ? 'row' : 'rows'}
</span>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' size='sm' onClick={onRefresh} disabled={isLoading}>
<RefreshCw className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Refresh</Tooltip.Content>
</Tooltip.Root>
<Popover>
<PopoverTrigger asChild>
<Button variant='ghost' size='sm'>
<MoreHorizontal className='h-[14px] w-[14px]' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' className='w-[160px]'>
<PopoverItem onClick={onShowSchema}>View Schema</PopoverItem>
</PopoverContent>
</Popover>
</div>
</div>
)
}

View File

@@ -13,19 +13,17 @@ import {
TableRow,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { TableRow as TableRowType } from '@/lib/table'
import { useContextMenu, useRowSelection, useTableData } from '../hooks'
import { useContextMenu, useInlineEditing, useRowSelection, useTableData } from '../hooks'
import type { CellViewerData, QueryOptions } from '../lib/types'
import { ActionBar } from './action-bar'
import { EmptyRows, LoadingRows } from './body-states'
import { CellRenderer } from './cell-renderer'
import { CellViewerModal } from './cell-viewer-modal'
import { ContextMenu } from './context-menu'
import { HeaderBar } from './header-bar'
import { Pagination } from './pagination'
import { QueryBuilder } from './query-builder'
import { EditableCell } from './editable-cell'
import { EditableRow } from './editable-row'
import { FilterPanel } from './filter-panel'
import { RowModal } from './row-modal'
import { SchemaModal } from './schema-modal'
import { TableToolbar } from './table-toolbar'
export function TableViewer() {
const params = useParams()
@@ -39,9 +37,7 @@ export function TableViewer() {
sort: null,
})
const [currentPage, setCurrentPage] = useState(0)
const [showAddModal, setShowAddModal] = useState(false)
const [editingRow, setEditingRow] = useState<TableRowType | null>(null)
const [showFilters, setShowFilters] = useState(false)
const [deletingRows, setDeletingRows] = useState<string[]>([])
const [showSchemaModal, setShowSchemaModal] = useState(false)
@@ -56,11 +52,29 @@ export function TableViewer() {
currentPage,
})
const columns = tableData?.schema?.columns || []
const { selectedRows, handleSelectAll, handleSelectRow, clearSelection } = useRowSelection(rows)
const { contextMenu, handleRowContextMenu, closeContextMenu } = useContextMenu()
const columns = tableData?.schema?.columns || []
const {
newRows,
pendingChanges,
addNewRow,
updateNewRowCell,
updateExistingRowCell,
saveChanges,
discardChanges,
hasPendingChanges,
isSaving,
} = useInlineEditing({
workspaceId,
tableId,
columns,
onSuccess: refetchRows,
})
const selectedCount = selectedRows.size
const hasSelection = selectedCount > 0
const isAllSelected = rows.length > 0 && selectedCount === rows.length
@@ -73,8 +87,8 @@ export function TableViewer() {
setShowSchemaModal(true)
}, [])
const handleAddRow = useCallback(() => {
setShowAddModal(true)
const handleToggleFilters = useCallback(() => {
setShowFilters((prev) => !prev)
}, [])
const handleApplyQueryOptions = useCallback(
@@ -91,11 +105,10 @@ export function TableViewer() {
}, [selectedRows])
const handleContextMenuEdit = useCallback(() => {
if (contextMenu.row) {
setEditingRow(contextMenu.row)
}
// For inline editing, we don't need the modal anymore
// The cell becomes editable on click
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
}, [closeContextMenu])
const handleContextMenuDelete = useCallback(() => {
if (contextMenu.row) {
@@ -127,6 +140,21 @@ export function TableViewer() {
[]
)
const handleRemoveNewRow = useCallback(
(tempId: string) => {
discardChanges()
},
[discardChanges]
)
const handlePreviousPage = useCallback(() => {
setCurrentPage((p) => Math.max(0, p - 1))
}, [])
const handleNextPage = useCallback(() => {
setCurrentPage((p) => Math.min(totalPages - 1, p + 1))
}, [totalPages])
if (isLoadingTable) {
return (
<div className='flex h-full items-center justify-center'>
@@ -145,34 +173,36 @@ export function TableViewer() {
return (
<div className='flex h-full flex-col'>
<HeaderBar
<TableToolbar
tableName={tableData.name}
totalCount={totalCount}
isLoading={isLoadingRows}
onNavigateBack={handleNavigateBack}
onShowSchema={handleShowSchema}
onRefresh={refetchRows}
showFilters={showFilters}
onToggleFilters={handleToggleFilters}
onAddRecord={addNewRow}
selectedCount={selectedCount}
onDeleteSelected={handleDeleteSelected}
onClearSelection={clearSelection}
hasPendingChanges={hasPendingChanges}
onSaveChanges={saveChanges}
onDiscardChanges={discardChanges}
isSaving={isSaving}
currentPage={currentPage}
totalPages={totalPages}
onPreviousPage={handlePreviousPage}
onNextPage={handleNextPage}
/>
<div className='flex shrink-0 flex-col gap-[8px] border-[var(--border)] border-b px-[16px] py-[10px]'>
<QueryBuilder
columns={columns}
onApply={handleApplyQueryOptions}
onAddRow={handleAddRow}
isLoading={isLoadingRows}
/>
{hasSelection && (
<span className='text-[11px] text-[var(--text-tertiary)]'>{selectedCount} selected</span>
)}
</div>
{hasSelection && (
<ActionBar
selectedCount={selectedCount}
onDelete={handleDeleteSelected}
onClearSelection={clearSelection}
/>
)}
<FilterPanel
columns={columns}
isVisible={showFilters}
onApply={handleApplyQueryOptions}
onClose={() => setShowFilters(false)}
isLoading={isLoadingRows}
/>
<div className='flex-1 overflow-auto'>
<Table>
@@ -197,82 +227,71 @@ export function TableViewer() {
</TableRow>
</TableHeader>
<TableBody>
{/* New rows being added */}
{newRows.map((newRow) => (
<EditableRow
key={newRow.tempId}
row={newRow}
columns={columns}
onUpdateCell={updateNewRowCell}
onRemove={handleRemoveNewRow}
/>
))}
{/* Loading state */}
{isLoadingRows ? (
<LoadingRows columns={columns} />
) : rows.length === 0 ? (
) : rows.length === 0 && newRows.length === 0 ? (
<EmptyRows
columnCount={columns.length}
hasFilter={!!queryOptions.filter}
onAddRow={handleAddRow}
onAddRow={addNewRow}
/>
) : (
rows.map((row) => (
<TableRow
key={row.id}
className={cn(
'group hover:bg-[var(--surface-4)]',
selectedRows.has(row.id) && 'bg-[var(--surface-5)]'
)}
onContextMenu={(e) => handleRowContextMenu(e, row)}
>
<TableCell>
<Checkbox
size='sm'
checked={selectedRows.has(row.id)}
onCheckedChange={() => handleSelectRow(row.id)}
/>
</TableCell>
{columns.map((column) => (
<TableCell key={column.name}>
<div className='max-w-[300px] truncate text-[13px]'>
<CellRenderer
value={row.data[column.name]}
column={column}
onCellClick={handleCellClick}
/>
</div>
/* Existing rows with inline editing */
rows.map((row) => {
const rowChanges = pendingChanges.get(row.id)
const hasChanges = !!rowChanges
return (
<TableRow
key={row.id}
className={cn(
'group hover:bg-[var(--surface-4)]',
selectedRows.has(row.id) && 'bg-[var(--surface-5)]',
hasChanges && 'bg-amber-500/10'
)}
onContextMenu={(e) => handleRowContextMenu(e, row)}
>
<TableCell>
<Checkbox
size='sm'
checked={selectedRows.has(row.id)}
onCheckedChange={() => handleSelectRow(row.id)}
/>
</TableCell>
))}
</TableRow>
))
{columns.map((column) => {
const currentValue = rowChanges?.[column.name] ?? row.data[column.name]
return (
<TableCell key={column.name}>
<EditableCell
value={currentValue}
column={column}
onChange={(value) => updateExistingRowCell(row.id, column.name, value)}
/>
</TableCell>
)
})}
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalCount={totalCount}
onPreviousPage={() => setCurrentPage((p) => Math.max(0, p - 1))}
onNextPage={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
/>
<RowModal
mode='add'
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
table={tableData}
onSuccess={() => {
refetchRows()
setShowAddModal(false)
}}
/>
{editingRow && (
<RowModal
mode='edit'
isOpen={true}
onClose={() => setEditingRow(null)}
table={tableData}
row={editingRow}
onSuccess={() => {
refetchRows()
setEditingRow(null)
}}
/>
)}
{/* Delete confirmation modal */}
{deletingRows.length > 0 && (
<RowModal
mode='delete'

View File

@@ -1,71 +0,0 @@
'use client'
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { AlertTriangle, ArrowLeft, RefreshCw } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/emcn'
const logger = createLogger('TableViewerError')
interface TableViewerErrorProps {
error: Error & { digest?: string }
reset: () => void
}
export default function TableViewerError({ error, reset }: TableViewerErrorProps) {
const router = useRouter()
const params = useParams()
const workspaceId = params.workspaceId as string
useEffect(() => {
logger.error('Table viewer error:', { error: error.message, digest: error.digest })
}, [error])
return (
<div className='flex h-full flex-1 flex-col'>
{/* Header */}
<div className='flex h-[48px] shrink-0 items-center border-[var(--border)] border-b px-[16px]'>
<button
onClick={() => router.push(`/workspace/${workspaceId}/tables`)}
className='flex items-center gap-[6px] text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
<ArrowLeft className='h-[14px] w-[14px]' />
Back to Tables
</button>
</div>
{/* Error Content */}
<div className='flex flex-1 items-center justify-center'>
<div className='flex flex-col items-center gap-[16px] text-center'>
<div className='flex h-[48px] w-[48px] items-center justify-center rounded-full bg-[var(--surface-4)]'>
<AlertTriangle className='h-[24px] w-[24px] text-[var(--text-error)]' />
</div>
<div className='flex flex-col gap-[8px]'>
<h2 className='font-semibold text-[16px] text-[var(--text-primary)]'>
Failed to load table
</h2>
<p className='max-w-[300px] text-[13px] text-[var(--text-tertiary)]'>
Something went wrong while loading this table. The table may have been deleted or you
may not have permission to view it.
</p>
</div>
<div className='flex items-center gap-[8px]'>
<Button
variant='ghost'
size='sm'
onClick={() => router.push(`/workspace/${workspaceId}/tables`)}
>
<ArrowLeft className='mr-[6px] h-[14px] w-[14px]' />
Go back
</Button>
<Button variant='default' size='sm' onClick={reset}>
<RefreshCw className='mr-[6px] h-[14px] w-[14px]' />
Try again
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,3 +1,4 @@
export * from './use-context-menu'
export * from './use-inline-editing'
export * from './use-row-selection'
export * from './use-table-data'

View File

@@ -0,0 +1,192 @@
'use client'
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { nanoid } from 'nanoid'
import type { ColumnDefinition } from '@/lib/table'
const logger = createLogger('useInlineEditing')
export interface TempRow {
tempId: string
data: Record<string, unknown>
isNew: true
}
interface UseInlineEditingProps {
workspaceId: string
tableId: string
columns: ColumnDefinition[]
onSuccess: () => void
}
interface UseInlineEditingReturn {
newRows: TempRow[]
pendingChanges: Map<string, Record<string, unknown>>
addNewRow: () => void
updateNewRowCell: (tempId: string, column: string, value: unknown) => void
updateExistingRowCell: (rowId: string, column: string, value: unknown) => void
saveChanges: () => Promise<void>
discardChanges: () => void
hasPendingChanges: boolean
isSaving: boolean
error: string | null
}
function createInitialRowData(columns: ColumnDefinition[]): Record<string, unknown> {
const initial: Record<string, unknown> = {}
columns.forEach((col) => {
if (col.type === 'boolean') {
initial[col.name] = false
} else {
initial[col.name] = null
}
})
return initial
}
function cleanRowData(
columns: ColumnDefinition[],
rowData: Record<string, unknown>
): Record<string, unknown> {
const cleanData: Record<string, unknown> = {}
columns.forEach((col) => {
const value = rowData[col.name]
if (col.type === 'number') {
cleanData[col.name] = value === '' || value === null ? null : Number(value)
} else if (col.type === 'json') {
if (typeof value === 'string') {
if (value === '') {
cleanData[col.name] = null
} else {
try {
cleanData[col.name] = JSON.parse(value)
} catch {
throw new Error(`Invalid JSON for field: ${col.name}`)
}
}
} else {
cleanData[col.name] = value
}
} else if (col.type === 'boolean') {
cleanData[col.name] = Boolean(value)
} else {
cleanData[col.name] = value || null
}
})
return cleanData
}
export function useInlineEditing({
workspaceId,
tableId,
columns,
onSuccess,
}: UseInlineEditingProps): UseInlineEditingReturn {
const [newRows, setNewRows] = useState<TempRow[]>([])
const [pendingChanges, setPendingChanges] = useState<Map<string, Record<string, unknown>>>(
new Map()
)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const hasPendingChanges = newRows.length > 0 || pendingChanges.size > 0
const addNewRow = useCallback(() => {
const newRow: TempRow = {
tempId: `temp-${nanoid()}`,
data: createInitialRowData(columns),
isNew: true,
}
setNewRows((prev) => [newRow, ...prev])
}, [columns])
const updateNewRowCell = useCallback((tempId: string, column: string, value: unknown) => {
setNewRows((prev) =>
prev.map((row) =>
row.tempId === tempId ? { ...row, data: { ...row.data, [column]: value } } : row
)
)
}, [])
const updateExistingRowCell = useCallback((rowId: string, column: string, value: unknown) => {
setPendingChanges((prev) => {
const newMap = new Map(prev)
const existing = newMap.get(rowId) || {}
newMap.set(rowId, { ...existing, [column]: value })
return newMap
})
}, [])
const saveChanges = useCallback(async () => {
setIsSaving(true)
setError(null)
try {
// Save new rows
for (const newRow of newRows) {
const cleanData = cleanRowData(columns, newRow.data)
const res = await fetch(`/api/table/${tableId}/rows`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId, data: cleanData }),
})
const result: { error?: string } = await res.json()
if (!res.ok) {
throw new Error(result.error || 'Failed to add row')
}
}
// Save edited rows
for (const [rowId, changes] of pendingChanges.entries()) {
const cleanData = cleanRowData(columns, changes)
const res = await fetch(`/api/table/${tableId}/rows/${rowId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId, data: cleanData }),
})
const result: { error?: string } = await res.json()
if (!res.ok) {
throw new Error(result.error || 'Failed to update row')
}
}
// Clear state and refresh
setNewRows([])
setPendingChanges(new Map())
onSuccess()
logger.info('Changes saved successfully')
} catch (err) {
logger.error('Failed to save changes:', err)
setError(err instanceof Error ? err.message : 'Failed to save changes')
} finally {
setIsSaving(false)
}
}, [newRows, pendingChanges, columns, tableId, workspaceId, onSuccess])
const discardChanges = useCallback(() => {
setNewRows([])
setPendingChanges(new Map())
setError(null)
}, [])
return {
newRows,
pendingChanges,
addNewRow,
updateNewRowCell,
updateExistingRowCell,
saveChanges,
discardChanges,
hasPendingChanges,
isSaving,
error,
}
}

View File

@@ -0,0 +1,14 @@
export type SortOption = 'name' | 'createdAt' | 'updatedAt' | 'rowCount' | 'columnCount'
export type SortOrder = 'asc' | 'desc'
export const SORT_OPTIONS = [
{ value: 'updatedAt-desc', label: 'Last Updated' },
{ value: 'createdAt-desc', label: 'Newest First' },
{ value: 'createdAt-asc', label: 'Oldest First' },
{ value: 'name-asc', label: 'Name (A-Z)' },
{ value: 'name-desc', label: 'Name (Z-A)' },
{ value: 'rowCount-desc', label: 'Most Rows' },
{ value: 'rowCount-asc', label: 'Least Rows' },
{ value: 'columnCount-desc', label: 'Most Columns' },
{ value: 'columnCount-asc', label: 'Least Columns' },
] as const

View File

@@ -1,8 +1,8 @@
'use client'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Plus, Trash2 } from 'lucide-react'
import { Plus } from 'lucide-react'
import { nanoid } from 'nanoid'
import { useParams } from 'next/navigation'
import {
@@ -17,7 +17,9 @@ import {
ModalFooter,
ModalHeader,
Textarea,
Tooltip,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import type { ColumnDefinition } from '@/lib/table'
import { useCreateTable } from '@/hooks/queries/use-tables'
@@ -55,6 +57,28 @@ export function CreateModal({ isOpen, onClose }: CreateModalProps) {
const createTable = useCreateTable(workspaceId)
// Form validation
const validColumns = useMemo(() => columns.filter((col) => col.name.trim()), [columns])
const duplicateColumnNames = useMemo(() => {
const names = validColumns.map((col) => col.name.toLowerCase())
const seen = new Set<string>()
const duplicates = new Set<string>()
names.forEach((name) => {
if (seen.has(name)) {
duplicates.add(name)
}
seen.add(name)
})
return duplicates
}, [validColumns])
const isFormValid = useMemo(() => {
const hasTableName = tableName.trim().length > 0
const hasAtLeastOneColumn = validColumns.length > 0
const hasNoDuplicates = duplicateColumnNames.size === 0
return hasTableName && hasAtLeastOneColumn && hasNoDuplicates
}, [tableName, validColumns.length, duplicateColumnNames.size])
const handleAddColumn = () => {
setColumns([...columns, createEmptyColumn()])
}
@@ -132,44 +156,39 @@ export function CreateModal({ isOpen, onClose }: CreateModalProps) {
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent className='w-[700px]'>
<ModalHeader>
<div className='flex flex-col gap-[4px]'>
<h2 className='font-semibold text-[16px]'>Create New Table</h2>
<p className='font-normal text-[13px] text-[var(--text-tertiary)]'>
Define your table schema with columns and constraints
</p>
</div>
</ModalHeader>
<ModalContent size='lg'>
<ModalHeader>Create New Table</ModalHeader>
<ModalBody className='max-h-[70vh] overflow-y-auto'>
<form onSubmit={handleSubmit} className='flex flex-col gap-[20px]'>
<form onSubmit={handleSubmit} className='space-y-[12px]'>
{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)]'>
<div className='rounded-[4px] border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
{error}
</div>
)}
{/* Table Name */}
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='tableName' className='font-medium text-[13px]'>
Table Name*
<div>
<Label
htmlFor='tableName'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Table Name
</Label>
<Input
id='tableName'
value={tableName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTableName(e.target.value)}
placeholder='customers, orders, products'
className='h-[38px]'
required
placeholder='e.g., customer_orders'
className='h-9'
/>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Use lowercase with underscores (e.g., customer_orders)
</p>
</div>
{/* Description */}
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='description' className='font-medium text-[13px]'>
<div>
<Label
htmlFor='description'
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
>
Description
</Label>
<Textarea
@@ -185,68 +204,66 @@ export function CreateModal({ isOpen, onClose }: CreateModalProps) {
</div>
{/* Columns */}
<div className='flex flex-col gap-[14px]'>
<div className='flex items-center justify-between'>
<Label className='font-medium text-[13px]'>Columns*</Label>
<Button
type='button'
size='sm'
variant='default'
onClick={handleAddColumn}
className='h-[30px] rounded-[6px] px-[12px] text-[12px]'
>
<Plus className='mr-[4px] h-[14px] w-[14px]' />
Add Column
<div>
<div className='mb-[6.5px] flex items-center justify-between pl-[2px]'>
<Label className='font-medium text-[13px] text-[var(--text-primary)]'>
Columns
</Label>
<Button type='button' size='sm' variant='default' onClick={handleAddColumn}>
<Plus className='mr-1 h-3.5 w-3.5' />
Add
</Button>
</div>
{/* Column Headers */}
<div className='flex items-center gap-[10px] rounded-[6px] bg-[var(--bg-secondary)] px-[12px] py-[8px] font-semibold text-[11px] text-[var(--text-tertiary)]'>
<div className='flex-1'>Column Name</div>
<div className='w-[110px]'>Type</div>
<div className='w-[70px] text-center'>Required</div>
<div className='w-[70px] text-center'>Unique</div>
<div className='w-[36px]' />
<div className='mb-2 flex items-center gap-[10px] text-[11px] text-[var(--text-secondary)]'>
<div className='flex-1 pl-3'>Name</div>
<div className='w-[110px] pl-3'>Type</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-[70px] cursor-help text-center'>Required</div>
</Tooltip.Trigger>
<Tooltip.Content>Field must have a value</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-[70px] cursor-help text-center'>Unique</div>
</Tooltip.Trigger>
<Tooltip.Content>No duplicate values allowed</Tooltip.Content>
</Tooltip.Root>
<div className='w-9' />
</div>
{/* Column Rows */}
<div className='flex flex-col gap-[10px]'>
<div className='flex flex-col gap-2'>
{columns.map((column) => (
<ColumnRow
key={column.id}
column={column}
isRemovable={columns.length > 1}
isDuplicate={duplicateColumnNames.has(column.name.toLowerCase())}
onChange={handleColumnChange}
onRemove={handleRemoveColumn}
/>
))}
</div>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Mark columns as <span className='font-medium'>unique</span> to prevent duplicate
values (e.g., id, email)
<p className='mt-[6.5px] text-[11px] text-[var(--text-secondary)]'>
Mark columns as unique to prevent duplicate values (e.g., id, email)
</p>
</div>
</form>
</ModalBody>
<ModalFooter className='gap-[10px]'>
<Button
type='button'
variant='default'
onClick={handleClose}
className='min-w-[90px]'
disabled={createTable.isPending}
>
<ModalFooter>
<Button variant='default' onClick={handleClose} disabled={createTable.isPending}>
Cancel
</Button>
<Button
type='button'
variant='tertiary'
onClick={handleSubmit}
disabled={createTable.isPending}
className='min-w-[120px]'
disabled={createTable.isPending || !isFormValid}
>
{createTable.isPending ? 'Creating...' : 'Create Table'}
{createTable.isPending ? 'Creating...' : 'Create'}
</Button>
</ModalFooter>
</ModalContent>
@@ -257,68 +274,76 @@ export function CreateModal({ isOpen, onClose }: CreateModalProps) {
interface ColumnRowProps {
column: ColumnWithId
isRemovable: boolean
isDuplicate: boolean
onChange: (columnId: string, field: keyof ColumnDefinition, value: string | boolean) => void
onRemove: (columnId: string) => void
}
function ColumnRow({ column, isRemovable, onChange, onRemove }: ColumnRowProps) {
function ColumnRow({ column, isRemovable, isDuplicate, onChange, onRemove }: ColumnRowProps) {
return (
<div className='flex items-center gap-[10px]'>
{/* Column Name */}
<div className='flex-1'>
<Input
value={column.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(column.id, 'name', e.target.value)
}
placeholder='column_name'
className='h-[36px]'
/>
</div>
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-[10px]'>
{/* Column Name */}
<div className='flex-1'>
<Input
value={column.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(column.id, 'name', e.target.value)
}
placeholder='column_name'
className={`h-9 ${isDuplicate ? 'border-destructive focus-visible:ring-destructive' : ''}`}
/>
</div>
{/* Column Type */}
<div className='w-[110px]'>
<Combobox
options={COLUMN_TYPE_OPTIONS}
value={column.type}
selectedValue={column.type}
onChange={(value) => onChange(column.id, 'type', value as ColumnDefinition['type'])}
placeholder='Type'
editable={false}
filterOptions={false}
className='h-[36px]'
/>
</div>
{/* Column Type */}
<div className='w-[110px]'>
<Combobox
options={COLUMN_TYPE_OPTIONS}
value={column.type}
selectedValue={column.type}
onChange={(value) => onChange(column.id, 'type', value as ColumnDefinition['type'])}
placeholder='Type'
editable={false}
filterOptions={false}
className='h-9'
/>
</div>
{/* Required Checkbox */}
<div className='flex w-[70px] items-center justify-center'>
<Checkbox
checked={column.required}
onCheckedChange={(checked) => onChange(column.id, 'required', checked === true)}
/>
</div>
{/* Required Checkbox */}
<div className='flex w-[70px] items-center justify-center'>
<Checkbox
checked={column.required}
onCheckedChange={(checked) => onChange(column.id, 'required', checked === true)}
/>
</div>
{/* Unique Checkbox */}
<div className='flex w-[70px] items-center justify-center'>
<Checkbox
checked={column.unique}
onCheckedChange={(checked) => onChange(column.id, 'unique', checked === true)}
/>
</div>
{/* Unique Checkbox */}
<div className='flex w-[70px] items-center justify-center'>
<Checkbox
checked={column.unique}
onCheckedChange={(checked) => onChange(column.id, 'unique', checked === true)}
/>
</div>
{/* Delete Button */}
<div className='w-[36px]'>
<Button
type='button'
size='sm'
variant='ghost'
onClick={() => onRemove(column.id)}
disabled={!isRemovable}
className='h-[36px] w-[36px] p-0 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--bg-error)] hover:text-[var(--text-error)]'
>
<Trash2 className='h-[15px] w-[15px]' />
</Button>
{/* Delete Button */}
<div className='w-9'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={() => onRemove(column.id)}
disabled={!isRemovable}
className='h-9 w-9 p-0'
>
<Trash />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Remove column</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
{isDuplicate && <p className='mt-1 pl-1 text-destructive text-sm'>Duplicate column name</p>}
</div>
)
}

View File

@@ -4,7 +4,7 @@ interface EmptyStateProps {
export function EmptyState({ hasSearchQuery }: EmptyStateProps) {
return (
<div className='col-span-full flex h-64 items-center justify-center rounded-[4px] bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'>
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
{hasSearchQuery ? 'No tables found' : 'No tables yet'}

View File

@@ -4,12 +4,11 @@ interface ErrorStateProps {
export function ErrorState({ error }: ErrorStateProps) {
return (
<div className='col-span-full flex h-64 items-center justify-center rounded-[4px] bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>Error loading tables</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>
{error instanceof Error ? error.message : 'An error occurred'}
</p>
<div className='col-span-full flex h-64 items-center justify-center'>
<div className='text-[var(--text-error)]'>
<span className='text-[13px]'>
Error: {error instanceof Error ? error.message : 'Failed to load tables'}
</span>
</div>
</div>
)

View File

@@ -3,4 +3,6 @@ export * from './empty-state'
export * from './error-state'
export * from './loading-state'
export * from './table-card'
export * from './table-card-context-menu'
export * from './table-list-context-menu'
export * from './tables-view'

View File

@@ -0,0 +1,152 @@
'use client'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
interface TableCardContextMenuProps {
/**
* Whether the context menu is open
*/
isOpen: boolean
/**
* Position of the context menu
*/
position: { x: number; y: number }
/**
* Ref for the menu element
*/
menuRef: React.RefObject<HTMLDivElement | null>
/**
* Callback when menu should close
*/
onClose: () => void
/**
* Callback when open in new tab is clicked
*/
onOpenInNewTab?: () => void
/**
* Callback when view schema is clicked
*/
onViewSchema?: () => void
/**
* Callback when copy ID is clicked
*/
onCopyId?: () => void
/**
* Callback when delete is clicked
*/
onDelete?: () => void
/**
* Whether to show the open in new tab option
* @default true
*/
showOpenInNewTab?: boolean
/**
* Whether to show the view schema option
* @default true
*/
showViewSchema?: boolean
/**
* Whether to show the delete option
* @default true
*/
showDelete?: boolean
/**
* Whether the delete option is disabled
* @default false
*/
disableDelete?: boolean
}
/**
* Context menu component for table cards.
* Displays open in new tab, view schema, copy ID, and delete options in a popover at the right-click position.
*/
export function TableCardContextMenu({
isOpen,
position,
menuRef,
onClose,
onOpenInNewTab,
onViewSchema,
onCopyId,
onDelete,
showOpenInNewTab = true,
showViewSchema = true,
showDelete = true,
disableDelete = false,
}: TableCardContextMenuProps) {
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{/* Navigation */}
{showOpenInNewTab && onOpenInNewTab && (
<PopoverItem
onClick={() => {
onOpenInNewTab()
onClose()
}}
>
Open in new tab
</PopoverItem>
)}
{showOpenInNewTab && onOpenInNewTab && <PopoverDivider />}
{/* View and copy actions */}
{showViewSchema && onViewSchema && (
<PopoverItem
onClick={() => {
onViewSchema()
onClose()
}}
>
View schema
</PopoverItem>
)}
{onCopyId && (
<PopoverItem
onClick={() => {
onCopyId()
onClose()
}}
>
Copy ID
</PopoverItem>
)}
{((showViewSchema && onViewSchema) || onCopyId) && <PopoverDivider />}
{/* Destructive action */}
{showDelete && onDelete && (
<PopoverItem
disabled={disableDelete}
onClick={() => {
onDelete()
onClose()
}}
>
Delete
</PopoverItem>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -1,8 +1,8 @@
'use client'
import { useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Columns, Info, Rows3, Trash2 } from 'lucide-react'
import { Columns, Rows3 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import {
Badge,
@@ -12,22 +12,15 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tooltip,
} from '@/components/emcn'
import type { TableDefinition } from '@/lib/table'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SchemaModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/schema-modal'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useDeleteTable } from '@/hooks/queries/use-tables'
import { getTypeBadgeVariant } from '../[tableId]/lib/utils'
import { formatAbsoluteDate, formatRelativeTime } from '../lib/utils'
import { TableCardContextMenu } from './table-card-context-menu'
const logger = createLogger('TableCard')
@@ -38,9 +31,35 @@ interface TableCardProps {
export function TableCard({ table, workspaceId }: TableCardProps) {
const router = useRouter()
const userPermissions = useUserPermissionsContext()
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isSchemaModalOpen, setIsSchemaModalOpen] = useState(false)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const menuButtonRef = useRef<HTMLButtonElement>(null)
const {
isOpen: isContextMenuOpen,
position: contextMenuPosition,
menuRef,
handleContextMenu,
closeMenu: closeContextMenu,
} = useContextMenu()
const handleMenuButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (menuButtonRef.current) {
const rect = menuButtonRef.current.getBoundingClientRect()
const syntheticEvent = {
preventDefault: () => {},
stopPropagation: () => {},
clientX: rect.right,
clientY: rect.bottom,
} as React.MouseEvent
handleContextMenu(syntheticEvent)
}
},
[handleContextMenu]
)
const deleteTable = useDeleteTable(workspaceId)
@@ -57,7 +76,47 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
router.push(`/workspace/${workspaceId}/tables/${table.id}`)
}
const href = `/workspace/${workspaceId}/tables/${table.id}`
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (isContextMenuOpen) {
e.preventDefault()
return
}
navigateToTable()
},
[isContextMenuOpen, navigateToTable]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
navigateToTable()
}
},
[navigateToTable]
)
const handleOpenInNewTab = useCallback(() => {
window.open(href, '_blank')
}, [href])
const handleViewSchema = useCallback(() => {
setIsSchemaModalOpen(true)
}, [])
const handleCopyId = useCallback(() => {
navigator.clipboard.writeText(table.id)
}, [table.id])
const handleDeleteFromContextMenu = useCallback(() => {
setIsDeleteDialogOpen(true)
}, [])
const columnCount = table.schema.columns.length
const shortId = `tb-${table.id.slice(0, 8)}`
return (
<>
@@ -66,58 +125,31 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
tabIndex={0}
data-table-card
className='h-full cursor-pointer'
onClick={navigateToTable}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
navigateToTable()
}
}}
onClick={handleClick}
onKeyDown={handleKeyDown}
onContextMenu={handleContextMenu}
>
<div className='group flex h-full flex-col gap-[12px] rounded-[4px] bg-[var(--surface-3)] px-[8px] py-[6px] transition-colors hover:bg-[var(--surface-4)] dark:bg-[var(--surface-4)] dark:hover:bg-[var(--surface-5)]'>
<div className='flex items-center justify-between gap-[8px]'>
<h3 className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
{table.name}
</h3>
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<PopoverTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-[20px] w-[20px] p-0 text-[var(--text-tertiary)]'
onClick={(e) => e.stopPropagation()}
>
<svg className='h-[14px] w-[14px]' viewBox='0 0 16 16' fill='currentColor'>
<circle cx='8' cy='3' r='1.5' />
<circle cx='8' cy='8' r='1.5' />
<circle cx='8' cy='13' r='1.5' />
</svg>
</Button>
</PopoverTrigger>
<PopoverContent align='end' className='w-[160px]'>
<PopoverItem
onClick={(e) => {
e.stopPropagation()
setIsMenuOpen(false)
setIsSchemaModalOpen(true)
}}
>
<Info className='mr-[8px] h-[14px] w-[14px]' />
View Schema
</PopoverItem>
<PopoverItem
onClick={(e) => {
e.stopPropagation()
setIsMenuOpen(false)
setIsDeleteDialogOpen(true)
}}
className='text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash2 className='mr-[8px] h-[14px] w-[14px]' />
Delete
</PopoverItem>
</PopoverContent>
</Popover>
<div className='flex items-center gap-[4px]'>
<Badge className='flex-shrink-0 rounded-[4px] text-[12px]'>{shortId}</Badge>
<Button
ref={menuButtonRef}
variant='ghost'
size='sm'
className='h-[20px] w-[20px] flex-shrink-0 p-0 text-[var(--text-tertiary)]'
onClick={handleMenuButtonClick}
>
<svg className='h-[14px] w-[14px]' viewBox='0 0 16 16' fill='currentColor'>
<circle cx='3' cy='8' r='1.5' />
<circle cx='8' cy='8' r='1.5' />
<circle cx='13' cy='8' r='1.5' />
</svg>
</Button>
</div>
</div>
<div className='flex flex-1 flex-col gap-[8px]'>
@@ -135,7 +167,7 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='text-[12px] text-[var(--text-tertiary)]'>
{formatRelativeTime(table.updatedAt)}
last updated: {formatRelativeTime(table.updatedAt)}
</span>
</Tooltip.Trigger>
<Tooltip.Content>{formatAbsoluteDate(table.updatedAt)}</Tooltip.Content>
@@ -153,7 +185,7 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
{/* Delete Confirmation Modal */}
<Modal open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<ModalContent className='w-[400px]'>
<ModalContent size='sm'>
<ModalHeader>Delete Table</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
@@ -171,12 +203,7 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
>
Cancel
</Button>
<Button
variant='ghost'
onClick={handleDelete}
disabled={deleteTable.isPending}
className='text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Button variant='destructive' onClick={handleDelete} disabled={deleteTable.isPending}>
{deleteTable.isPending ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
@@ -184,63 +211,23 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
</Modal>
{/* Schema Viewer Modal */}
<Modal open={isSchemaModalOpen} onOpenChange={setIsSchemaModalOpen}>
<ModalContent className='w-[500px] duration-100'>
<ModalHeader>
<div className='flex items-center gap-[8px]'>
<Info className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
<span>{table.name}</span>
<Badge variant='gray' size='sm'>
{columnCount} columns
</Badge>
</div>
</ModalHeader>
<ModalBody className='p-0'>
<div className='max-h-[400px] overflow-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-[180px]'>Column</TableHead>
<TableHead className='w-[100px]'>Type</TableHead>
<TableHead>Constraints</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{table.schema.columns.map((column) => (
<TableRow key={column.name}>
<TableCell className='font-mono text-[12px] text-[var(--text-primary)]'>
{column.name}
</TableCell>
<TableCell>
<Badge variant={getTypeBadgeVariant(column.type)} size='sm'>
{column.type}
</Badge>
</TableCell>
<TableCell className='text-[12px]'>
<div className='flex gap-[6px]'>
{column.required && (
<Badge variant='red' size='sm'>
required
</Badge>
)}
{column.unique && (
<Badge variant='purple' size='sm'>
unique
</Badge>
)}
{!column.required && !column.unique && (
<span className='text-[var(--text-muted)]'></span>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</ModalBody>
</ModalContent>
</Modal>
<SchemaModal
isOpen={isSchemaModalOpen}
onClose={() => setIsSchemaModalOpen(false)}
columns={table.schema.columns}
/>
<TableCardContextMenu
isOpen={isContextMenuOpen}
position={contextMenuPosition}
menuRef={menuRef}
onClose={closeContextMenu}
onOpenInNewTab={handleOpenInNewTab}
onViewSchema={handleViewSchema}
onCopyId={handleCopyId}
onDelete={handleDeleteFromContextMenu}
disableDelete={userPermissions.canEdit !== true}
/>
</>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
interface TableListContextMenuProps {
/**
* Whether the context menu is open
*/
isOpen: boolean
/**
* Position of the context menu
*/
position: { x: number; y: number }
/**
* Ref for the menu element
*/
menuRef: React.RefObject<HTMLDivElement | null>
/**
* Callback when menu should close
*/
onClose: () => void
/**
* Callback when add table is clicked
*/
onAddTable?: () => void
/**
* Whether the add option is disabled
* @default false
*/
disableAdd?: boolean
}
/**
* Context menu component for the tables list page.
* Displays "Add table" option when right-clicking on empty space.
*/
export function TableListContextMenu({
isOpen,
position,
menuRef,
onClose,
onAddTable,
disableAdd = false,
}: TableListContextMenuProps) {
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
{onAddTable && (
<PopoverItem
disabled={disableAdd}
onClick={() => {
onAddTable()
onClose()
}}
>
Add table
</PopoverItem>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -1,17 +1,29 @@
'use client'
import { useState } from 'react'
import { Database, Plus, Search } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { ChevronDown, Database, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Input, Tooltip } from '@/components/emcn'
import {
Button,
Input,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useTablesList } from '@/hooks/queries/use-tables'
import { useDebounce } from '@/hooks/use-debounce'
import { filterTables, sortTables } from '../lib/utils'
import { SORT_OPTIONS, type SortOption, type SortOrder } from './constants'
import { CreateModal } from './create-modal'
import { EmptyState } from './empty-state'
import { ErrorState } from './error-state'
import { LoadingState } from './loading-state'
import { TableCard } from './table-card'
import { TableListContextMenu } from './table-list-context-menu'
export function TablesView() {
const params = useParams()
@@ -23,27 +35,75 @@ export function TablesView() {
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [isSortPopoverOpen, setIsSortPopoverOpen] = useState(false)
const [sortBy, setSortBy] = useState<SortOption>('updatedAt')
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
// Filter tables by search query
const filteredTables = tables.filter((table) => {
if (!debouncedSearchQuery) return true
const {
isOpen: isListContextMenuOpen,
position: listContextMenuPosition,
menuRef: listMenuRef,
handleContextMenu: handleListContextMenu,
closeMenu: closeListContextMenu,
} = useContextMenu()
const query = debouncedSearchQuery.toLowerCase()
return (
table.name.toLowerCase().includes(query) || table.description?.toLowerCase().includes(query)
)
})
/**
* Handle context menu on the content area - only show menu when clicking on empty space
*/
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
const isOnCard = target.closest('[data-table-card]')
const isOnInteractive = target.closest('button, input, a, [role="button"]')
if (!isOnCard && !isOnInteractive) {
handleListContextMenu(e)
}
},
[handleListContextMenu]
)
/**
* Handle add table from context menu
*/
const handleAddTable = useCallback(() => {
setIsCreateModalOpen(true)
}, [])
const currentSortValue = `${sortBy}-${sortOrder}`
const currentSortLabel =
SORT_OPTIONS.find((opt) => opt.value === currentSortValue)?.label || 'Last Updated'
/**
* Handles sort option change from dropdown
*/
const handleSortChange = (value: string) => {
const [field, order] = value.split('-') as [SortOption, SortOrder]
setSortBy(field)
setSortOrder(order)
setIsSortPopoverOpen(false)
}
/**
* Filter and sort tables based on search query and sort options
*/
const filteredAndSortedTables = useMemo(() => {
const filtered = filterTables(tables, debouncedSearchQuery)
return sortTables(filtered, sortBy, sortOrder)
}, [tables, debouncedSearchQuery, sortBy, sortOrder])
return (
<>
<div className='flex h-full flex-1 flex-col'>
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-auto bg-white px-[24px] pt-[28px] pb-[24px] dark:bg-[var(--bg)]'>
{/* Header */}
<div
className='flex flex-1 flex-col overflow-auto bg-white px-[24px] pt-[28px] pb-[24px] dark:bg-[var(--bg)]'
onContextMenu={handleContentContextMenu}
>
<div>
<div className='flex items-start gap-[12px]'>
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#3B82F6] bg-[#EFF6FF] dark:border-[#1E40AF] dark:bg-[#1E3A5F]'>
<Database className='h-[14px] w-[14px] text-[#3B82F6] dark:text-[#60A5FA]' />
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#64748B] bg-[#F1F5F9] dark:border-[#334155] dark:bg-[#0F172A]'>
<Database className='h-[14px] w-[14px] text-[#64748B] dark:text-[#CBD5E1]' />
</div>
<h1 className='font-medium text-[18px]'>Tables</h1>
</div>
@@ -52,7 +112,6 @@ export function TablesView() {
</p>
</div>
{/* Search and Actions */}
<div className='mt-[14px] flex items-center justify-between'>
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-4)] px-[8px]'>
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
@@ -64,6 +123,30 @@ export function TablesView() {
/>
</div>
<div className='flex items-center gap-[8px]'>
{tables.length > 0 && (
<Popover open={isSortPopoverOpen} onOpenChange={setIsSortPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='default' className='h-[32px] rounded-[6px]'>
{currentSortLabel}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' side='bottom' sideOffset={4}>
<div className='flex flex-col gap-[2px]'>
{SORT_OPTIONS.map((option) => (
<PopoverItem
key={option.value}
active={currentSortValue === option.value}
onClick={() => handleSortChange(option.value)}
>
{option.label}
</PopoverItem>
))}
</div>
</PopoverContent>
</Popover>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -72,8 +155,7 @@ export function TablesView() {
variant='tertiary'
className='h-[32px] rounded-[6px]'
>
<Plus className='mr-[6px] h-[14px] w-[14px]' />
Create Table
Create
</Button>
</Tooltip.Trigger>
{userPermissions.canEdit !== true && (
@@ -83,16 +165,15 @@ export function TablesView() {
</div>
</div>
{/* Content */}
<div className='mt-[24px] grid grid-cols-1 gap-[20px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{isLoading ? (
<LoadingState />
) : filteredAndSortedTables.length === 0 ? (
<EmptyState hasSearchQuery={!!debouncedSearchQuery} />
) : error ? (
<ErrorState error={error} />
) : filteredTables.length === 0 ? (
<EmptyState hasSearchQuery={!!searchQuery} />
) : (
filteredTables.map((table) => (
filteredAndSortedTables.map((table) => (
<TableCard key={table.id} table={table} workspaceId={workspaceId} />
))
)}
@@ -102,6 +183,15 @@ export function TablesView() {
</div>
<CreateModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} />
<TableListContextMenu
isOpen={isListContextMenuOpen}
position={listContextMenuPosition}
menuRef={listMenuRef}
onClose={closeListContextMenu}
onAddTable={handleAddTable}
disableAdd={userPermissions.canEdit !== true}
/>
</>
)
}

View File

@@ -1,41 +0,0 @@
'use client'
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { AlertTriangle, RefreshCw } from 'lucide-react'
import { Button } from '@/components/emcn'
const logger = createLogger('TablesError')
interface TablesErrorProps {
error: Error & { digest?: string }
reset: () => void
}
export default function TablesError({ error, reset }: TablesErrorProps) {
useEffect(() => {
logger.error('Tables error:', { error: error.message, digest: error.digest })
}, [error])
return (
<div className='flex h-full flex-1 items-center justify-center bg-white dark:bg-[var(--bg)]'>
<div className='flex flex-col items-center gap-[16px] text-center'>
<div className='flex h-[48px] w-[48px] items-center justify-center rounded-full bg-[var(--surface-4)]'>
<AlertTriangle className='h-[24px] w-[24px] text-[var(--text-error)]' />
</div>
<div className='flex flex-col gap-[8px]'>
<h2 className='font-semibold text-[16px] text-[var(--text-primary)]'>
Failed to load tables
</h2>
<p className='max-w-[300px] text-[13px] text-[var(--text-tertiary)]'>
Something went wrong while loading the tables. Please try again.
</p>
</div>
<Button variant='default' size='sm' onClick={reset}>
<RefreshCw className='mr-[6px] h-[14px] w-[14px]' />
Try again
</Button>
</div>
</div>
)
}

View File

@@ -1,3 +1,54 @@
import type { TableDefinition } from '@/lib/table'
import type { SortOption, SortOrder } from '../components/constants'
/**
* Sort tables by the specified field and order
*/
export function sortTables(
tables: TableDefinition[],
sortBy: SortOption,
sortOrder: SortOrder
): TableDefinition[] {
return [...tables].sort((a, b) => {
let comparison = 0
switch (sortBy) {
case 'name':
comparison = a.name.localeCompare(b.name)
break
case 'createdAt':
comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
break
case 'updatedAt':
comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
break
case 'rowCount':
comparison = a.rowCount - b.rowCount
break
case 'columnCount':
comparison = a.schema.columns.length - b.schema.columns.length
break
}
return sortOrder === 'asc' ? comparison : -comparison
})
}
/**
* Filter tables by search query
*/
export function filterTables(tables: TableDefinition[], searchQuery: string): TableDefinition[] {
if (!searchQuery.trim()) {
return tables
}
const query = searchQuery.toLowerCase()
return tables.filter(
(table) =>
table.name.toLowerCase().includes(query) || table.description?.toLowerCase().includes(query)
)
}
/**
* Formats a date as relative time (e.g., "5m ago", "2d ago")
*/

View File

@@ -212,8 +212,10 @@ export default function Templates({
) : filteredTemplates.length === 0 ? (
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-muted-foreground text-sm'>{emptyState.title}</p>
<p className='mt-1 text-muted-foreground/70 text-xs'>{emptyState.description}</p>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
{emptyState.title}
</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>{emptyState.description}</p>
</div>
</div>
) : (

View File

@@ -1,10 +1,8 @@
'use client'
import { useCallback, useMemo } from 'react'
import { ExternalLink } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Combobox, type ComboboxOption, Tooltip } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Combobox, type ComboboxOption } from '@/components/emcn'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
import { useTablesList } from '@/hooks/queries/use-tables'
@@ -18,12 +16,12 @@ interface TableSelectorProps {
}
/**
* Table selector component with dropdown and link to view table
* Table selector component for selecting workspace tables
*
* @remarks
* Provides a dropdown to select workspace tables and an external link
* to navigate directly to the table page view when a table is selected.
* Provides a combobox to select workspace tables.
* Uses React Query for efficient data fetching and caching.
* The external link to navigate to the table is shown in the label area.
*/
export function TableSelector({
blockId,
@@ -62,50 +60,21 @@ export function TableSelector({
[isPreview, disabled, setStoreValue]
)
const handleNavigateToTable = useCallback(() => {
if (tableId && workspaceId) {
window.open(`/workspace/${workspaceId}/tables/${tableId}`, '_blank')
}
}, [workspaceId, tableId])
const hasSelectedTable = tableId && !tableId.startsWith('<')
// Convert error object to string if needed
const errorMessage = error instanceof Error ? error.message : error ? String(error) : undefined
return (
<div className='flex items-center gap-[6px]'>
<div className='flex-1'>
<Combobox
options={options}
value={tableId ?? undefined}
onChange={handleChange}
placeholder={subBlock.placeholder || 'Select a table'}
disabled={disabled || isPreview}
editable={false}
isLoading={isLoading}
error={errorMessage}
searchable={options.length > 5}
searchPlaceholder='Search...'
/>
</div>
{hasSelectedTable && !isPreview && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
className='h-[30px] w-[30px] flex-shrink-0 p-0'
onClick={handleNavigateToTable}
>
<ExternalLink className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>View table</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
<Combobox
options={options}
value={tableId ?? undefined}
onChange={handleChange}
placeholder={subBlock.placeholder || 'Select a table'}
disabled={disabled || isPreview}
editable={false}
isLoading={isLoading}
error={errorMessage}
searchable={options.length > 5}
searchPlaceholder='Search...'
/>
)
}

View File

@@ -1,6 +1,7 @@
import { type JSX, type MouseEvent, memo, useRef, useState } from 'react'
import { type JSX, type MouseEvent, memo, useCallback, useRef, useState } from 'react'
import { isEqual } from 'lodash'
import { AlertTriangle, ArrowLeftRight, ArrowUp } from 'lucide-react'
import { AlertTriangle, ArrowLeftRight, ArrowUp, ExternalLink } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
import type { FilterRule, SortRule } from '@/lib/table/query-builder/constants'
@@ -175,6 +176,7 @@ const getPreviewValue = (
* @param wandState - Optional state and handlers for the AI wand feature
* @param canonicalToggle - Optional canonical toggle metadata and handlers
* @param canonicalToggleIsDisabled - Whether the canonical toggle is disabled
* @param tableLinkState - Optional state for table selector external link
* @returns The label JSX element, or `null` for switch types or when no title is defined
*/
const renderLabel = (
@@ -200,7 +202,11 @@ const renderLabel = (
disabled?: boolean
onToggle?: () => void
},
canonicalToggleIsDisabled?: boolean
canonicalToggleIsDisabled?: boolean,
tableLinkState?: {
hasSelectedTable: boolean
onNavigateToTable: () => void
}
): JSX.Element | null => {
if (config.type === 'switch') return null
if (!config.title) return null
@@ -209,6 +215,11 @@ const renderLabel = (
const showWand = wandState?.isWandEnabled && !wandState.isPreview && !wandState.disabled
const showCanonicalToggle = !!canonicalToggle && !wandState?.isPreview
const canonicalToggleDisabledResolved = canonicalToggleIsDisabled ?? canonicalToggle?.disabled
const showTableLink =
config.type === 'table-selector' &&
tableLinkState?.hasSelectedTable &&
!wandState?.isPreview &&
!wandState?.disabled
return (
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
@@ -288,6 +299,23 @@ const renderLabel = (
)}
</>
)}
{showTableLink && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0'
onClick={tableLinkState.onNavigateToTable}
aria-label='View table'
>
<ExternalLink className='!h-[12px] !w-[12px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]' />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>View table</p>
</Tooltip.Content>
</Tooltip.Root>
)}
{showCanonicalToggle && (
<button
type='button'
@@ -368,6 +396,9 @@ function SubBlockComponent({
allowExpandInPreview,
canonicalToggle,
}: SubBlockProps): JSX.Element {
const params = useParams()
const workspaceId = params.workspaceId as string
const [isValidJson, setIsValidJson] = useState(true)
const [isSearchActive, setIsSearchActive] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
@@ -385,6 +416,20 @@ function SubBlockComponent({
// Check if wand is enabled for this sub-block
const isWandEnabled = config.wandConfig?.enabled ?? false
// Table selector link state
const tableValue = subBlockValues?.[config.id]?.value
const tableId = typeof tableValue === 'string' ? tableValue : null
const hasSelectedTable = Boolean(tableId && !tableId.startsWith('<'))
/**
* Handles navigation to the selected table in a new tab.
*/
const handleNavigateToTable = useCallback(() => {
if (tableId && workspaceId) {
window.open(`/workspace/${workspaceId}/tables/${tableId}`, '_blank')
}
}, [workspaceId, tableId])
/**
* Handles wand icon click to activate inline prompt mode.
* Focuses the input after a brief delay to ensure DOM is ready.
@@ -992,7 +1037,11 @@ function SubBlockComponent({
searchInputRef,
},
canonicalToggle,
Boolean(canonicalToggle?.disabled || disabled || isPreview)
Boolean(canonicalToggle?.disabled || disabled || isPreview),
{
hasSelectedTable,
onNavigateToTable: handleNavigateToTable,
}
)}
{renderInput()}
</div>