mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-22 13:28:04 -05:00
styling
This commit is contained in:
@@ -555,7 +555,7 @@ export function DocumentTagsModal({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
variant={canSaveTag ? 'tertiary' : 'default'}
|
||||
onClick={saveDocumentTag}
|
||||
className='flex-1'
|
||||
disabled={!canSaveTag}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]'>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './use-context-menu'
|
||||
export * from './use-inline-editing'
|
||||
export * from './use-row-selection'
|
||||
export * from './use-table-data'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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...'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user