This commit is contained in:
Lakee Sivaraya
2026-01-13 17:47:56 -08:00
parent 0872314fbf
commit 9a3d5631f2
20 changed files with 1572 additions and 135 deletions

View File

@@ -61,7 +61,8 @@ export function AddRowModal({ isOpen, onClose, table, onSuccess }: AddRowModalPr
const cleanData: Record<string, any> = {}
columns.forEach((col) => {
const value = rowData[col.name]
if (col.required || (value !== '' && value !== null && value !== undefined)) {
const isRequired = !col.optional
if (isRequired || (value !== '' && value !== null && value !== undefined)) {
if (col.type === 'number') {
cleanData[col.name] = value === '' ? null : Number(value)
} else if (col.type === 'json') {
@@ -131,7 +132,7 @@ export function AddRowModal({ isOpen, onClose, table, onSuccess }: AddRowModalPr
<div key={column.name} className='flex flex-col gap-[8px]'>
<Label htmlFor={column.name} className='font-medium text-[13px]'>
{column.name}
{column.required && <span className='text-[var(--text-error)]'> *</span>}
{!column.optional && <span className='text-[var(--text-error)]'> *</span>}
{column.unique && (
<span className='ml-[6px] font-normal text-[11px] text-[var(--text-tertiary)]'>
(unique)
@@ -165,7 +166,7 @@ export function AddRowModal({ isOpen, onClose, table, onSuccess }: AddRowModalPr
placeholder='{"key": "value"}'
rows={4}
className='font-mono text-[12px]'
required={column.required}
required={!column.optional}
/>
) : (
<Input
@@ -179,13 +180,13 @@ export function AddRowModal({ isOpen, onClose, table, onSuccess }: AddRowModalPr
}
placeholder={`Enter ${column.name}`}
className='h-[38px]'
required={column.required}
required={!column.optional}
/>
)}
<div className='text-[12px] text-[var(--text-tertiary)]'>
Type: {column.type}
{!column.required && ' (optional)'}
{column.optional && ' (optional)'}
</div>
</div>
))}

View File

@@ -142,7 +142,7 @@ export function EditRowModal({ isOpen, onClose, table, row, onSuccess }: EditRow
<div key={column.name} className='flex flex-col gap-[8px]'>
<Label htmlFor={column.name} className='font-medium text-[13px]'>
{column.name}
{column.required && <span className='text-[var(--text-error)]'> *</span>}
{!column.optional && <span className='text-[var(--text-error)]'> *</span>}
{column.unique && (
<span className='ml-[6px] font-normal text-[11px] text-[var(--text-tertiary)]'>
(unique)
@@ -176,7 +176,7 @@ export function EditRowModal({ isOpen, onClose, table, row, onSuccess }: EditRow
placeholder='{"key": "value"}'
rows={4}
className='font-mono text-[12px]'
required={column.required}
required={!column.optional}
/>
) : (
<Input
@@ -190,13 +190,13 @@ export function EditRowModal({ isOpen, onClose, table, row, onSuccess }: EditRow
}
placeholder={`Enter ${column.name}`}
className='h-[38px]'
required={column.required}
required={!column.optional}
/>
)}
<div className='text-[12px] text-[var(--text-tertiary)]'>
Type: {column.type}
{!column.required && ' (optional)'}
{column.optional && ' (optional)'}
</div>
</div>
))}

View File

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

View File

@@ -1,9 +1,9 @@
'use client'
import { useCallback, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQuery } from '@tanstack/react-query'
import { Columns, Copy, Edit, Plus, RefreshCw, Trash2, X } from 'lucide-react'
import { Copy, Edit, Info, Plus, RefreshCw, Trash2, X } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
Badge,
@@ -12,6 +12,11 @@ import {
Modal,
ModalBody,
ModalContent,
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
Table,
TableBody,
TableCell,
@@ -54,11 +59,17 @@ interface TableData {
interface CellViewerData {
columnName: string
value: any
type: 'json' | 'text'
type: 'json' | 'text' | 'date'
}
const STRING_TRUNCATE_LENGTH = 50
interface ContextMenuState {
isOpen: boolean
position: { x: number; y: number }
row: TableRowData | null
}
export function TableDataViewer() {
const params = useParams()
const router = useRouter()
@@ -79,6 +90,14 @@ export function TableDataViewer() {
const [showSchemaModal, setShowSchemaModal] = useState(false)
const [copied, setCopied] = useState(false)
// Context menu state
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
isOpen: false,
position: { x: 0, y: 0 },
row: null,
})
const contextMenuRef = useRef<HTMLDivElement>(null)
// Fetch table metadata
const { data: tableData, isLoading: isLoadingTable } = useQuery({
queryKey: ['table', tableId],
@@ -159,12 +178,46 @@ export function TableDataViewer() {
setDeletingRows(Array.from(selectedRows))
}, [selectedRows])
// Context menu handlers
const handleRowContextMenu = useCallback((e: React.MouseEvent, row: TableRowData) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({
isOpen: true,
position: { x: e.clientX, y: e.clientY },
row,
})
}, [])
const closeContextMenu = useCallback(() => {
setContextMenu((prev) => ({ ...prev, isOpen: false }))
}, [])
const handleContextMenuEdit = useCallback(() => {
if (contextMenu.row) {
setEditingRow(contextMenu.row)
}
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
const handleContextMenuDelete = useCallback(() => {
if (contextMenu.row) {
setDeletingRows([contextMenu.row.id])
}
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
const handleCopyCellValue = useCallback(async () => {
if (cellViewer) {
const text =
cellViewer.type === 'json'
? JSON.stringify(cellViewer.value, null, 2)
: String(cellViewer.value)
let text: string
if (cellViewer.type === 'json') {
text = JSON.stringify(cellViewer.value, null, 2)
} else if (cellViewer.type === 'date') {
// Copy ISO format for dates (parseable)
text = String(cellViewer.value)
} else {
text = String(cellViewer.value)
}
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
@@ -193,7 +246,7 @@ export function TableDataViewer() {
}
const handleCellClick = useCallback(
(e: React.MouseEvent, columnName: string, value: any, type: 'json' | 'text') => {
(e: React.MouseEvent, columnName: string, value: any, type: 'json' | 'text' | 'date') => {
e.preventDefault()
e.stopPropagation()
setCellViewer({ columnName, value, type })
@@ -213,7 +266,7 @@ export function TableDataViewer() {
return (
<button
type='button'
className='block max-w-[300px] cursor-pointer select-none truncate rounded-[4px] border border-[var(--border-1)] px-[6px] py-[2px] text-left font-mono text-[11px] text-[var(--brand-secondary)] transition-colors hover:border-[var(--text-muted)] hover:text-[var(--text-primary)]'
className='block max-w-[300px] cursor-pointer select-none truncate rounded-[4px] border border-[var(--border-1)] px-[6px] py-[2px] text-left font-mono text-[11px] text-[var(--text-secondary)] transition-colors hover:border-[var(--text-muted)] hover:text-[var(--text-primary)]'
onClick={(e) => handleCellClick(e, column.name, value, 'json')}
title='Click to view full JSON'
>
@@ -231,7 +284,34 @@ export function TableDataViewer() {
}
if (column.type === 'number') {
return <span className='font-mono text-[var(--brand-secondary)]'>{String(value)}</span>
return (
<span className='font-mono text-[12px] text-[var(--text-secondary)]'>{String(value)}</span>
)
}
if (column.type === 'date') {
try {
const date = new Date(value)
const formatted = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
return (
<button
type='button'
className='cursor-pointer select-none text-left text-[12px] text-[var(--text-secondary)] underline decoration-[var(--border-1)] decoration-dotted underline-offset-2 transition-colors hover:decoration-[var(--text-muted)] hover:text-[var(--text-primary)]'
onClick={(e) => handleCellClick(e, column.name, value, 'date')}
title='Click to view ISO format'
>
{formatted}
</button>
)
} catch {
return <span className='text-[var(--text-primary)]'>{String(value)}</span>
}
}
// Handle long strings
@@ -296,7 +376,7 @@ export function TableDataViewer() {
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' size='sm' onClick={() => setShowSchemaModal(true)}>
<Columns className='h-[14px] w-[14px]' />
<Info className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>View Schema</Tooltip.Content>
@@ -355,7 +435,7 @@ export function TableDataViewer() {
<Badge variant='outline' size='sm'>
{column.type}
</Badge>
{column.required && (
{!column.optional && (
<span className='text-[10px] text-[var(--text-error)]'>*</span>
)}
</div>
@@ -427,6 +507,7 @@ export function TableDataViewer() {
'group hover:bg-[var(--surface-4)]',
selectedRows.has(row.id) && 'bg-[var(--surface-5)]'
)}
onContextMenu={(e) => handleRowContextMenu(e, row)}
>
<TableCell>
<Checkbox
@@ -544,7 +625,7 @@ export function TableDataViewer() {
<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]'>
<Columns className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
<Info className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
<span className='font-medium text-[14px] text-[var(--text-primary)]'>
Table Schema
</span>
@@ -594,9 +675,9 @@ export function TableDataViewer() {
</TableCell>
<TableCell className='text-[12px]'>
<div className='flex gap-[6px]'>
{column.required && (
<Badge variant='red' size='sm'>
required
{column.optional && (
<Badge variant='gray' size='sm'>
optional
</Badge>
)}
{column.unique && (
@@ -604,7 +685,7 @@ export function TableDataViewer() {
unique
</Badge>
)}
{!column.required && !column.unique && (
{!column.optional && !column.unique && (
<span className='text-[var(--text-muted)]'></span>
)}
</div>
@@ -626,8 +707,21 @@ export function TableDataViewer() {
<span className='truncate font-medium text-[14px] text-[var(--text-primary)]'>
{cellViewer?.columnName}
</span>
<Badge variant={cellViewer?.type === 'json' ? 'blue' : 'gray'} size='sm'>
{cellViewer?.type === 'json' ? 'JSON' : 'Text'}
<Badge
variant={
cellViewer?.type === 'json'
? 'blue'
: cellViewer?.type === 'date'
? 'purple'
: 'gray'
}
size='sm'
>
{cellViewer?.type === 'json'
? 'JSON'
: cellViewer?.type === 'date'
? 'Date'
: 'Text'}
</Badge>
</div>
<div className='flex shrink-0 items-center gap-[8px]'>
@@ -649,6 +743,36 @@ export function TableDataViewer() {
<pre className='m-[16px] max-h-[450px] overflow-auto rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] font-mono text-[12px] text-[var(--text-primary)] leading-[1.6]'>
{cellViewer ? JSON.stringify(cellViewer.value, null, 2) : ''}
</pre>
) : cellViewer?.type === 'date' ? (
<div className='m-[16px] space-y-[12px]'>
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
<div className='mb-[6px] text-[11px] font-medium uppercase tracking-wide text-[var(--text-tertiary)]'>
Formatted
</div>
<div className='text-[14px] text-[var(--text-primary)]'>
{cellViewer
? new Date(cellViewer.value).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
})
: ''}
</div>
</div>
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
<div className='mb-[6px] text-[11px] font-medium uppercase tracking-wide text-[var(--text-tertiary)]'>
ISO Format
</div>
<div className='font-mono text-[13px] text-[var(--text-secondary)]'>
{cellViewer ? String(cellViewer.value) : ''}
</div>
</div>
</div>
) : (
<div className='m-[16px] max-h-[450px] overflow-auto whitespace-pre-wrap break-words rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] text-[13px] text-[var(--text-primary)] leading-[1.7]'>
{cellViewer ? String(cellViewer.value) : ''}
@@ -657,6 +781,43 @@ export function TableDataViewer() {
</ModalBody>
</ModalContent>
</Modal>
{/* Row Context Menu */}
<Popover
open={contextMenu.isOpen}
onOpenChange={(open) => !open && closeContextMenu()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenu.position.x}px`,
top: `${contextMenu.position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent
ref={contextMenuRef}
align='start'
side='bottom'
sideOffset={4}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<PopoverItem onClick={handleContextMenuEdit}>
<Edit className='mr-[8px] h-[12px] w-[12px]' />
Edit row
</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleContextMenuDelete} className='text-[var(--text-error)]'>
<Trash2 className='mr-[8px] h-[12px] w-[12px]' />
Delete row
</PopoverItem>
</PopoverContent>
</Popover>
</div>
)
}

View File

@@ -24,7 +24,7 @@ const logger = createLogger('CreateTableModal')
interface ColumnDefinition {
name: string
type: 'string' | 'number' | 'boolean' | 'date' | 'json'
required: boolean
optional: boolean
unique: boolean
}
@@ -48,14 +48,14 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) {
const [tableName, setTableName] = useState('')
const [description, setDescription] = useState('')
const [columns, setColumns] = useState<ColumnDefinition[]>([
{ name: '', type: 'string', required: false, unique: false },
{ name: '', type: 'string', optional: false, unique: false },
])
const [error, setError] = useState<string | null>(null)
const createTable = useCreateTable(workspaceId)
const handleAddColumn = () => {
setColumns([...columns, { name: '', type: 'string', required: false, unique: false }])
setColumns([...columns, { name: '', type: 'string', optional: false, unique: false }])
}
const handleRemoveColumn = (index: number) => {
@@ -110,7 +110,7 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) {
// Reset form
setTableName('')
setDescription('')
setColumns([{ name: '', type: 'string', required: false, unique: false }])
setColumns([{ name: '', type: 'string', optional: false, unique: false }])
setError(null)
onClose()
} catch (err) {
@@ -123,7 +123,7 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) {
// Reset form on close
setTableName('')
setDescription('')
setColumns([{ name: '', type: 'string', required: false, unique: false }])
setColumns([{ name: '', type: 'string', optional: false, unique: false }])
setError(null)
onClose()
}
@@ -202,7 +202,7 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) {
<div className='flex items-center gap-[10px] rounded-[6px] bg-[var(--bg-secondary)] px-[12px] py-[8px] text-[11px] font-semibold 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'>Optional</div>
<div className='w-[70px] text-center'>Unique</div>
<div className='w-[36px]' />
</div>
@@ -239,12 +239,12 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) {
/>
</div>
{/* Required Checkbox */}
{/* Optional Checkbox */}
<div className='flex w-[70px] items-center justify-center'>
<Checkbox
checked={column.required}
checked={column.optional}
onCheckedChange={(checked) =>
handleColumnChange(index, 'required', checked === true)
handleColumnChange(index, 'optional', checked === true)
}
/>
</div>
@@ -277,8 +277,9 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) {
</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)
Columns are <span className='font-medium'>required</span> by default. Check{' '}
<span className='font-medium'>optional</span> for nullable fields, or{' '}
<span className='font-medium'>unique</span> to prevent duplicates.
</p>
</div>
</form>

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Columns, Database, MoreVertical, Trash2 } from 'lucide-react'
import { Columns, Info, Rows3, Trash2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import {
Badge,
@@ -22,6 +22,7 @@ import {
TableHead,
TableHeader,
TableRow,
Tooltip,
} from '@/components/emcn'
import { useDeleteTable } from '@/hooks/queries/use-tables'
import type { TableDefinition } from '@/tools/table/types'
@@ -33,6 +34,37 @@ interface TableCardProps {
workspaceId: string
}
/**
* Formats a date string to relative time (e.g., "2h ago", "3d ago")
*/
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) return 'just now'
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 604800)}w ago`
if (diffInSeconds < 31536000) return `${Math.floor(diffInSeconds / 2592000)}mo ago`
return `${Math.floor(diffInSeconds / 31536000)}y ago`
}
/**
* Formats a date string to absolute format for tooltip display
*/
function formatAbsoluteDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function TableCard({ table, workspaceId }: TableCardProps) {
const router = useRouter()
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
@@ -55,75 +87,92 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
return (
<>
<div
role='button'
tabIndex={0}
data-table-card
className='group relative cursor-pointer rounded-[8px] border border-[var(--border-muted)] bg-[var(--surface-1)] p-[16px] transition-colors hover:border-[var(--border-color)]'
className='h-full cursor-pointer'
onClick={() => router.push(`/workspace/${workspaceId}/tables/${table.id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
router.push(`/workspace/${workspaceId}/tables/${table.id}`)
}
}}
>
<div className='flex items-start justify-between gap-[8px]'>
<div className='flex min-w-0 flex-1 items-start gap-[12px]'>
<div className='mt-[2px] flex-shrink-0'>
<div className='flex h-[40px] w-[40px] items-center justify-center rounded-[8px] border border-[#3B82F6] bg-[#EFF6FF] dark:border-[#1E40AF] dark:bg-[#1E3A5F]'>
<Database className='h-[20px] w-[20px] text-[#3B82F6] dark:text-[#60A5FA]' />
</div>
</div>
<div className='min-w-0 flex-1'>
<h3 className='truncate font-medium text-[14px] text-[var(--text-primary)]'>
{table.name}
</h3>
{table.description && (
<p className='mt-[4px] line-clamp-2 text-[12px] text-[var(--text-tertiary)]'>
{table.description}
</p>
)}
<div className='mt-[12px] flex items-center gap-[16px] text-[11px] text-[var(--text-subtle)]'>
<span>{columnCount} columns</span>
<span>{table.rowCount} rows</span>
</div>
<div className='mt-[8px] text-[11px] text-[var(--text-muted)]'>
Updated {new Date(table.updatedAt).toLocaleDateString()}
</div>
</div>
<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>
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<PopoverTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-[24px] w-[24px] p-0 opacity-0 group-hover:opacity-100'
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className='h-[14px] w-[14px]' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' className='w-[160px]'>
<PopoverItem
onClick={(e) => {
e.stopPropagation()
setIsMenuOpen(false)
setIsSchemaModalOpen(true)
}}
>
<Columns 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 flex-1 flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px] text-[12px] text-[var(--text-tertiary)]'>
<span className='flex items-center gap-[4px]'>
<Columns className='h-[12px] w-[12px]' />
{columnCount} {columnCount === 1 ? 'col' : 'cols'}
</span>
<span className='flex items-center gap-[4px]'>
<Rows3 className='h-[12px] w-[12px]' />
{table.rowCount} {table.rowCount === 1 ? 'row' : 'rows'}
</span>
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='text-[12px] text-[var(--text-tertiary)]'>
{formatRelativeTime(table.updatedAt)}
</span>
</Tooltip.Trigger>
<Tooltip.Content>{formatAbsoluteDate(table.updatedAt)}</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='h-0 w-full border-[var(--divider)] border-t' />
<p className='line-clamp-2 h-[36px] text-[12px] text-[var(--text-tertiary)] leading-[18px]'>
{table.description || 'No description'}
</p>
</div>
</div>
</div>
@@ -162,7 +211,7 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
<ModalContent className='w-[500px] duration-100'>
<ModalHeader>
<div className='flex items-center gap-[8px]'>
<Columns className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
<Info className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
<span>{table.name}</span>
<Badge variant='gray' size='sm'>
{columnCount} columns
@@ -207,9 +256,9 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
</TableCell>
<TableCell className='text-[12px]'>
<div className='flex gap-[6px]'>
{column.required && (
<Badge variant='red' size='sm'>
required
{column.optional && (
<Badge variant='gray' size='sm'>
optional
</Badge>
)}
{column.unique && (
@@ -217,7 +266,7 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
unique
</Badge>
)}
{!column.required && !column.unique && (
{!column.optional && !column.unique && (
<span className='text-[var(--text-muted)]'></span>
)}
</div>

View File

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

View File

@@ -87,23 +87,34 @@ export function Tables() {
{/* Content */}
<div className='mt-[24px] grid grid-cols-1 gap-[20px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{isLoading ? (
// Loading skeleton
// Loading skeleton matching the new card style
Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className='animate-pulse rounded-[8px] border border-[var(--border-muted)] bg-[var(--surface-1)] p-[16px]'
className='flex h-full flex-col gap-[12px] rounded-[4px] bg-[var(--surface-3)] px-[8px] py-[6px] dark:bg-[var(--surface-4)]'
>
<div className='flex items-start gap-[12px]'>
<div className='h-[40px] w-[40px] rounded-[8px] bg-[var(--surface-4)]' />
<div className='flex-1 space-y-[8px]'>
<div className='h-[16px] w-3/4 rounded bg-[var(--surface-4)]' />
<div className='h-[12px] w-1/2 rounded bg-[var(--surface-4)]' />
<div className='flex items-center justify-between gap-[8px]'>
<div className='h-[17px] w-[120px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
<div className='h-[22px] w-[90px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
<div className='flex flex-1 flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<div className='h-[15px] w-[50px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
<div className='h-[15px] w-[50px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
<div className='h-[15px] w-[60px] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
<div className='h-0 w-full border-[var(--divider)] border-t' />
<div className='flex h-[36px] flex-col gap-[6px]'>
<div className='h-[15px] w-full animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
<div className='h-[15px] w-[75%] animate-pulse rounded-[4px] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' />
</div>
</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='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
@@ -114,7 +125,7 @@ export function Tables() {
</div>
</div>
) : filteredTables.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='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'>
{searchQuery ? 'No tables found' : 'No tables yet'}

View File

@@ -1,6 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Badge } from '@/components/emcn'
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
import type { FilterCondition, SortCondition } from '@/lib/table/filter-builder-utils'
import {
conditionsToJsonString,
jsonStringToConditions,
jsonStringToSortConditions,
sortConditionsToJsonString,
} from '@/lib/table/filter-builder-utils'
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 { getDependsOnFields } from '@/blocks/utils'
@@ -113,13 +120,52 @@ export function Dropdown({
const [builderData, setBuilderData] = useSubBlockValue<any[]>(blockId, 'builderData')
const [data, setData] = useSubBlockValue<string>(blockId, 'data')
// Filter builder state for filterMode conversion
const [filterBuilder, setFilterBuilder] = useSubBlockValue<FilterCondition[]>(
blockId,
'filterBuilder'
)
const [filter, setFilter] = useSubBlockValue<string>(blockId, 'filter')
// Sort builder state for sortMode conversion
const [sortBuilder, setSortBuilder] = useSubBlockValue<SortCondition[]>(blockId, 'sortBuilder')
const [sort, setSort] = useSubBlockValue<string>(blockId, 'sort')
// Bulk filter builder state for bulkFilterMode conversion
const [bulkFilterBuilder, setBulkFilterBuilder] = useSubBlockValue<FilterCondition[]>(
blockId,
'bulkFilterBuilder'
)
const [filterCriteria, setFilterCriteria] = useSubBlockValue<string>(blockId, 'filterCriteria')
const builderDataRef = useRef(builderData)
const dataRef = useRef(data)
const filterBuilderRef = useRef(filterBuilder)
const filterRef = useRef(filter)
const sortBuilderRef = useRef(sortBuilder)
const sortRef = useRef(sort)
const bulkFilterBuilderRef = useRef(bulkFilterBuilder)
const filterCriteriaRef = useRef(filterCriteria)
useEffect(() => {
builderDataRef.current = builderData
dataRef.current = data
}, [builderData, data])
filterBuilderRef.current = filterBuilder
filterRef.current = filter
sortBuilderRef.current = sortBuilder
sortRef.current = sort
bulkFilterBuilderRef.current = bulkFilterBuilder
filterCriteriaRef.current = filterCriteria
}, [
builderData,
data,
filterBuilder,
filter,
sortBuilder,
sort,
bulkFilterBuilder,
filterCriteria,
])
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
@@ -305,6 +351,123 @@ export function Dropdown({
previousModeRef.current = currentMode
}, [storeValue, subBlockId, isPreview, disabled, setData, setBuilderData, multiSelect])
/**
* Handle filterMode conversion between builder and json formats
*/
const previousFilterModeRef = useRef<string | null>(null)
useEffect(() => {
if (multiSelect || subBlockId !== 'filterMode' || isPreview || disabled) return
const currentMode = storeValue as string
const previousMode = previousFilterModeRef.current
if (previousMode !== null && previousMode !== currentMode) {
if (currentMode === 'json' && previousMode === 'builder') {
// Convert builder conditions to JSON string
const currentFilterBuilder = filterBuilderRef.current
if (
currentFilterBuilder &&
Array.isArray(currentFilterBuilder) &&
currentFilterBuilder.length > 0
) {
const jsonString = conditionsToJsonString(currentFilterBuilder)
setFilter(jsonString)
}
} else if (currentMode === 'builder' && previousMode === 'json') {
// Convert JSON string to builder conditions
const currentFilter = filterRef.current
if (currentFilter && typeof currentFilter === 'string' && currentFilter.trim().length > 0) {
const conditions = jsonStringToConditions(currentFilter)
setFilterBuilder(conditions)
}
}
}
previousFilterModeRef.current = currentMode
}, [storeValue, subBlockId, isPreview, disabled, setFilter, setFilterBuilder, multiSelect])
/**
* Handle sortMode conversion between builder and json formats
*/
const previousSortModeRef = useRef<string | null>(null)
useEffect(() => {
if (multiSelect || subBlockId !== 'sortMode' || isPreview || disabled) return
const currentMode = storeValue as string
const previousMode = previousSortModeRef.current
if (previousMode !== null && previousMode !== currentMode) {
if (currentMode === 'json' && previousMode === 'builder') {
// Convert sort builder conditions to JSON string
const currentSortBuilder = sortBuilderRef.current
if (
currentSortBuilder &&
Array.isArray(currentSortBuilder) &&
currentSortBuilder.length > 0
) {
const jsonString = sortConditionsToJsonString(currentSortBuilder)
setSort(jsonString)
}
} else if (currentMode === 'builder' && previousMode === 'json') {
// Convert JSON string to sort builder conditions
const currentSort = sortRef.current
if (currentSort && typeof currentSort === 'string' && currentSort.trim().length > 0) {
const conditions = jsonStringToSortConditions(currentSort)
setSortBuilder(conditions)
}
}
}
previousSortModeRef.current = currentMode
}, [storeValue, subBlockId, isPreview, disabled, setSort, setSortBuilder, multiSelect])
/**
* Handle bulkFilterMode conversion between builder and json formats
*/
const previousBulkFilterModeRef = useRef<string | null>(null)
useEffect(() => {
if (multiSelect || subBlockId !== 'bulkFilterMode' || isPreview || disabled) return
const currentMode = storeValue as string
const previousMode = previousBulkFilterModeRef.current
if (previousMode !== null && previousMode !== currentMode) {
if (currentMode === 'json' && previousMode === 'builder') {
// Convert bulk filter builder conditions to JSON string
const currentBulkFilterBuilder = bulkFilterBuilderRef.current
if (
currentBulkFilterBuilder &&
Array.isArray(currentBulkFilterBuilder) &&
currentBulkFilterBuilder.length > 0
) {
const jsonString = conditionsToJsonString(currentBulkFilterBuilder)
setFilterCriteria(jsonString)
}
} else if (currentMode === 'builder' && previousMode === 'json') {
// Convert JSON string to bulk filter builder conditions
const currentFilterCriteria = filterCriteriaRef.current
if (
currentFilterCriteria &&
typeof currentFilterCriteria === 'string' &&
currentFilterCriteria.trim().length > 0
) {
const conditions = jsonStringToConditions(currentFilterCriteria)
setBulkFilterBuilder(conditions)
}
}
}
previousBulkFilterModeRef.current = currentMode
}, [
storeValue,
subBlockId,
isPreview,
disabled,
setFilterCriteria,
setBulkFilterBuilder,
multiSelect,
])
/**
* Handles selection change for both single and multi-select modes
*/

View File

@@ -0,0 +1,214 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Plus, X } from 'lucide-react'
import { Button, Combobox, type ComboboxOption, Input } from '@/components/emcn'
import {
COMPARISON_OPERATORS,
type FilterCondition,
generateFilterId,
LOGICAL_OPERATORS,
} from '@/lib/table/filter-builder-utils'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
interface FilterFormatProps {
blockId: string
subBlockId: string
isPreview?: boolean
previewValue?: FilterCondition[] | null
disabled?: boolean
columns?: Array<{ value: string; label: string }>
tableIdSubBlockId?: string
}
/**
* Creates a new filter condition with default values
*/
const createDefaultCondition = (columns: ComboboxOption[]): FilterCondition => ({
id: generateFilterId(),
logicalOperator: 'and',
column: columns[0]?.value || '',
operator: 'eq',
value: '',
})
export function FilterFormat({
blockId,
subBlockId,
isPreview = false,
previewValue,
disabled = false,
columns: propColumns,
tableIdSubBlockId = 'tableId',
}: FilterFormatProps) {
const [storeValue, setStoreValue] = useSubBlockValue<FilterCondition[]>(blockId, subBlockId)
const [tableIdValue] = useSubBlockValue<string>(blockId, tableIdSubBlockId)
const [dynamicColumns, setDynamicColumns] = useState<ComboboxOption[]>([])
const fetchedTableIdRef = useRef<string | null>(null)
// Fetch columns when tableId changes
useEffect(() => {
const fetchColumns = async () => {
if (!tableIdValue || tableIdValue === fetchedTableIdRef.current) return
try {
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
if (!workspaceId) return
const response = await fetch(`/api/table/${tableIdValue}?workspaceId=${workspaceId}`)
if (!response.ok) return
const data = await response.json()
const cols = data.table?.schema?.columns || []
setDynamicColumns(
cols.map((col: { name: string }) => ({ value: col.name, label: col.name }))
)
fetchedTableIdRef.current = tableIdValue
} catch {
// Ignore errors
}
}
fetchColumns()
}, [tableIdValue])
const columns = useMemo(() => {
if (propColumns && propColumns.length > 0) return propColumns
return dynamicColumns
}, [propColumns, dynamicColumns])
const comparisonOptions = useMemo(
() => COMPARISON_OPERATORS.map((op) => ({ value: op.value, label: op.label })),
[]
)
const logicalOptions = useMemo(
() => LOGICAL_OPERATORS.map((op) => ({ value: op.value, label: op.label })),
[]
)
const value = isPreview ? previewValue : storeValue
const conditions: FilterCondition[] = Array.isArray(value) && value.length > 0 ? value : []
const isReadOnly = isPreview || disabled
const addCondition = useCallback(() => {
if (isReadOnly) return
setStoreValue([...conditions, createDefaultCondition(columns)])
}, [isReadOnly, conditions, columns, setStoreValue])
const removeCondition = useCallback(
(id: string) => {
if (isReadOnly) return
setStoreValue(conditions.filter((c) => c.id !== id))
},
[isReadOnly, conditions, setStoreValue]
)
const updateCondition = useCallback(
(id: string, field: keyof FilterCondition, newValue: string) => {
if (isReadOnly) return
setStoreValue(conditions.map((c) => (c.id === id ? { ...c, [field]: newValue } : c)))
},
[isReadOnly, conditions, setStoreValue]
)
return (
<div className='flex flex-col gap-[8px]'>
{conditions.length === 0 ? (
<div className='flex items-center justify-center rounded-[4px] border border-dashed border-[var(--border-1)] py-[16px]'>
<Button variant='ghost' size='sm' onClick={addCondition} disabled={isReadOnly}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add filter condition
</Button>
</div>
) : (
<>
{conditions.map((condition, index) => (
<div
key={condition.id}
className='flex items-center gap-[6px] rounded-[4px] border border-[var(--border-1)] p-[8px]'
>
{/* Remove Button */}
<Button
variant='ghost'
size='sm'
onClick={() => removeCondition(condition.id)}
disabled={isReadOnly}
className='h-[24px] w-[24px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
{/* Logical Operator */}
<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={condition.logicalOperator}
onChange={(v) =>
updateCondition(condition.id, 'logicalOperator', v as 'and' | 'or')
}
disabled={isReadOnly}
/>
)}
</div>
{/* Column Selector */}
<div className='w-[100px] shrink-0'>
<Combobox
size='sm'
options={columns}
value={condition.column}
onChange={(v) => updateCondition(condition.id, 'column', v)}
placeholder='Column'
disabled={isReadOnly}
/>
</div>
{/* Comparison Operator */}
<div className='w-[110px] shrink-0'>
<Combobox
size='sm'
options={comparisonOptions}
value={condition.operator}
onChange={(v) => updateCondition(condition.id, 'operator', v)}
disabled={isReadOnly}
/>
</div>
{/* Value Input */}
<Input
className='h-[28px] min-w-[80px] flex-1 text-[12px]'
value={condition.value}
onChange={(e) => updateCondition(condition.id, 'value', e.target.value)}
placeholder='Value'
disabled={isReadOnly}
/>
</div>
))}
{/* Add Button */}
<Button
variant='ghost'
size='sm'
onClick={addCondition}
disabled={isReadOnly}
className='self-start'
>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add condition
</Button>
</>
)}
</div>
)
}

View File

@@ -9,6 +9,7 @@ export { Dropdown } from './dropdown/dropdown'
export { EvalInput } from './eval-input/eval-input'
export { FileSelectorInput } from './file-selector/file-selector-input'
export { FileUpload } from './file-upload/file-upload'
export { FilterFormat } from './filter-format/filter-format'
export { FolderSelectorInput } from './folder-selector/components/folder-selector-input'
export { GroupedCheckboxList } from './grouped-checkbox-list/grouped-checkbox-list'
export { InputMapping } from './input-mapping/input-mapping'
@@ -25,6 +26,7 @@ export { ScheduleInfo } from './schedule-info/schedule-info'
export { ShortInput } from './short-input/short-input'
export { SlackSelectorInput } from './slack-selector/slack-selector-input'
export { SliderInput } from './slider-input/slider-input'
export { SortFormat } from './sort-format/sort-format'
export { InputFormat } from './starter/input-format'
export { SubBlockInputController } from './sub-block-input-controller'
export { Switch } from './switch/switch'

View File

@@ -0,0 +1,194 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Plus, X } from 'lucide-react'
import { Button, Combobox, type ComboboxOption } from '@/components/emcn'
import {
generateSortId,
SORT_DIRECTIONS,
type SortCondition,
} from '@/lib/table/filter-builder-utils'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
interface SortFormatProps {
blockId: string
subBlockId: string
isPreview?: boolean
previewValue?: SortCondition[] | null
disabled?: boolean
columns?: Array<{ value: string; label: string }>
tableIdSubBlockId?: string
}
/**
* Creates a new sort condition with default values
*/
const createDefaultCondition = (columns: ComboboxOption[]): SortCondition => ({
id: generateSortId(),
column: columns[0]?.value || '',
direction: 'asc',
})
export function SortFormat({
blockId,
subBlockId,
isPreview = false,
previewValue,
disabled = false,
columns: propColumns,
tableIdSubBlockId = 'tableId',
}: SortFormatProps) {
const [storeValue, setStoreValue] = useSubBlockValue<SortCondition[]>(blockId, subBlockId)
const [tableIdValue] = useSubBlockValue<string>(blockId, tableIdSubBlockId)
const [dynamicColumns, setDynamicColumns] = useState<ComboboxOption[]>([])
const fetchedTableIdRef = useRef<string | null>(null)
// Fetch columns when tableId changes
useEffect(() => {
const fetchColumns = async () => {
if (!tableIdValue || tableIdValue === fetchedTableIdRef.current) return
try {
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
if (!workspaceId) return
const response = await fetch(`/api/table/${tableIdValue}?workspaceId=${workspaceId}`)
if (!response.ok) return
const data = await response.json()
const cols = data.table?.schema?.columns || []
// Add built-in columns for sorting
const builtInCols = [
{ value: 'createdAt', label: 'createdAt' },
{ value: 'updatedAt', label: 'updatedAt' },
]
const schemaCols = cols.map((col: { name: string }) => ({
value: col.name,
label: col.name,
}))
setDynamicColumns([...schemaCols, ...builtInCols])
fetchedTableIdRef.current = tableIdValue
} catch {
// Ignore errors
}
}
fetchColumns()
}, [tableIdValue])
const columns = useMemo(() => {
if (propColumns && propColumns.length > 0) return propColumns
return dynamicColumns
}, [propColumns, dynamicColumns])
const directionOptions = useMemo(
() => SORT_DIRECTIONS.map((dir) => ({ value: dir.value, label: dir.label })),
[]
)
const value = isPreview ? previewValue : storeValue
const conditions: SortCondition[] = Array.isArray(value) && value.length > 0 ? value : []
const isReadOnly = isPreview || disabled
const addCondition = useCallback(() => {
if (isReadOnly) return
setStoreValue([...conditions, createDefaultCondition(columns)])
}, [isReadOnly, conditions, columns, setStoreValue])
const removeCondition = useCallback(
(id: string) => {
if (isReadOnly) return
setStoreValue(conditions.filter((c) => c.id !== id))
},
[isReadOnly, conditions, setStoreValue]
)
const updateCondition = useCallback(
(id: string, field: keyof SortCondition, newValue: string) => {
if (isReadOnly) return
setStoreValue(conditions.map((c) => (c.id === id ? { ...c, [field]: newValue } : c)))
},
[isReadOnly, conditions, setStoreValue]
)
return (
<div className='flex flex-col gap-[8px]'>
{conditions.length === 0 ? (
<div className='flex items-center justify-center rounded-[4px] border border-dashed border-[var(--border-1)] py-[16px]'>
<Button variant='ghost' size='sm' onClick={addCondition} disabled={isReadOnly}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add sort condition
</Button>
</div>
) : (
<>
{conditions.map((condition, index) => (
<div
key={condition.id}
className='flex items-center gap-[6px] rounded-[4px] border border-[var(--border-1)] p-[8px]'
>
{/* Remove Button */}
<Button
variant='ghost'
size='sm'
onClick={() => removeCondition(condition.id)}
disabled={isReadOnly}
className='h-[24px] w-[24px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
{/* Order indicator */}
<div className='w-[90px] shrink-0'>
<Combobox
size='sm'
options={[
{ value: String(index + 1), label: index === 0 ? 'order by' : `then by` },
]}
value={String(index + 1)}
disabled
/>
</div>
{/* Column Selector */}
<div className='min-w-[120px] flex-1'>
<Combobox
size='sm'
options={columns}
value={condition.column}
onChange={(v) => updateCondition(condition.id, 'column', v)}
placeholder='Column'
disabled={isReadOnly}
/>
</div>
{/* Direction Selector */}
<div className='w-[110px] shrink-0'>
<Combobox
size='sm'
options={directionOptions}
value={condition.direction}
onChange={(v) => updateCondition(condition.id, 'direction', v as 'asc' | 'desc')}
disabled={isReadOnly}
/>
</div>
</div>
))}
{/* Add Button */}
<Button
variant='ghost'
size='sm'
onClick={addCondition}
disabled={isReadOnly}
className='self-start'
>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add sort
</Button>
</>
)}
</div>
)
}

View File

@@ -3,6 +3,7 @@ import { AlertTriangle, Wand2 } from 'lucide-react'
import { Label, Tooltip } from '@/components/emcn/components'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/core/utils/cn'
import type { FilterCondition, SortCondition } from '@/lib/table/filter-builder-utils'
import type { FieldDiffStatus } from '@/lib/workflows/diff/types'
import {
CheckboxList,
@@ -16,6 +17,7 @@ import {
EvalInput,
FileSelectorInput,
FileUpload,
FilterFormat,
FolderSelectorInput,
GroupedCheckboxList,
InputFormat,
@@ -33,6 +35,7 @@ import {
ShortInput,
SlackSelectorInput,
SliderInput,
SortFormat,
Switch,
Table,
Text,
@@ -797,6 +800,28 @@ function SubBlockComponent({
/>
)
case 'filter-format':
return (
<FilterFormat
blockId={blockId}
subBlockId={config.id}
isPreview={isPreview}
previewValue={previewValue as FilterCondition[] | null | undefined}
disabled={isDisabled}
/>
)
case 'sort-format':
return (
<SortFormat
blockId={blockId}
subBlockId={config.id}
isPreview={isPreview}
previewValue={previewValue as SortCondition[] | null | undefined}
disabled={isDisabled}
/>
)
case 'channel-selector':
case 'user-selector':
return (

View File

@@ -184,6 +184,52 @@ const isDocumentTagArray = (value: unknown): value is DocumentTagItem[] => {
)
}
/**
* Type guard for filter condition array (used in table block filter builder)
*/
interface FilterConditionItem {
id: string
logicalOperator: 'and' | 'or'
column: string
operator: string
value: string
}
const isFilterConditionArray = (value: unknown): value is FilterConditionItem[] => {
if (!Array.isArray(value) || value.length === 0) return false
const firstItem = value[0]
return (
typeof firstItem === 'object' &&
firstItem !== null &&
'column' in firstItem &&
'operator' in firstItem &&
'logicalOperator' in firstItem &&
typeof firstItem.column === 'string'
)
}
/**
* Type guard for sort condition array (used in table block sort builder)
*/
interface SortConditionItem {
id: string
column: string
direction: 'asc' | 'desc'
}
const isSortConditionArray = (value: unknown): value is SortConditionItem[] => {
if (!Array.isArray(value) || value.length === 0) return false
const firstItem = value[0]
return (
typeof firstItem === 'object' &&
firstItem !== null &&
'column' in firstItem &&
'direction' in firstItem &&
typeof firstItem.column === 'string' &&
(firstItem.direction === 'asc' || firstItem.direction === 'desc')
)
}
/**
* Attempts to parse a JSON string, returns the parsed value or the original value if parsing fails
*/
@@ -248,6 +294,45 @@ export const getDisplayValue = (value: unknown): string => {
return `${validTags[0].tagName}, ${validTags[1].tagName} +${validTags.length - 2}`
}
if (isFilterConditionArray(parsedValue)) {
const validConditions = parsedValue.filter(
(c) => typeof c.column === 'string' && c.column.trim() !== ''
)
if (validConditions.length === 0) return '-'
const formatCondition = (c: FilterConditionItem) => {
const opLabels: Record<string, string> = {
eq: '=',
ne: '≠',
gt: '>',
gte: '≥',
lt: '<',
lte: '≤',
contains: '~',
in: 'in',
}
const op = opLabels[c.operator] || c.operator
return `${c.column} ${op} ${c.value || '?'}`
}
if (validConditions.length === 1) return formatCondition(validConditions[0])
if (validConditions.length === 2) {
return `${formatCondition(validConditions[0])}, ${formatCondition(validConditions[1])}`
}
return `${formatCondition(validConditions[0])}, ${formatCondition(validConditions[1])} +${validConditions.length - 2}`
}
if (isSortConditionArray(parsedValue)) {
const validConditions = parsedValue.filter(
(c) => typeof c.column === 'string' && c.column.trim() !== ''
)
if (validConditions.length === 0) return '-'
const formatSort = (c: SortConditionItem) => `${c.column} ${c.direction === 'desc' ? '↓' : '↑'}`
if (validConditions.length === 1) return formatSort(validConditions[0])
if (validConditions.length === 2) {
return `${formatSort(validConditions[0])}, ${formatSort(validConditions[1])}`
}
return `${formatSort(validConditions[0])}, ${formatSort(validConditions[1])} +${validConditions.length - 2}`
}
if (isTableRowArray(parsedValue)) {
const nonEmptyRows = parsedValue.filter((row) => {
const cellValues = Object.values(row.cells)

View File

@@ -1,4 +1,5 @@
import { TableIcon } from '@/components/icons'
import { conditionsToFilter, sortConditionsToSort } from '@/lib/table/filter-builder-utils'
import type { BlockConfig } from '@/blocks/types'
import type { TableQueryResponse } from '@/tools/table/types'
@@ -171,7 +172,39 @@ Return ONLY the rows array:`,
},
},
// Filter for update/delete/query operations
// Filter mode selector for bulk operations
{
id: 'bulkFilterMode',
title: 'Filter Mode',
type: 'dropdown',
options: [
{ label: 'Builder', id: 'builder' },
{ label: 'Editor', id: 'json' },
],
value: () => 'builder',
condition: {
field: 'operation',
value: ['updateRowsByFilter', 'deleteRowsByFilter'],
},
},
// Filter builder for bulk operations (visual)
{
id: 'bulkFilterBuilder',
title: 'Filter Conditions',
type: 'filter-format',
required: {
field: 'operation',
value: ['updateRowsByFilter', 'deleteRowsByFilter'],
},
condition: {
field: 'operation',
value: ['updateRowsByFilter', 'deleteRowsByFilter'],
and: { field: 'bulkFilterMode', value: 'builder' },
},
},
// Filter for update/delete operations (JSON editor)
{
id: 'filterCriteria',
title: 'Filter Criteria',
@@ -180,8 +213,13 @@ Return ONLY the rows array:`,
condition: {
field: 'operation',
value: ['updateRowsByFilter', 'deleteRowsByFilter'],
and: { field: 'bulkFilterMode', value: 'json' },
},
required: {
field: 'operation',
value: ['updateRowsByFilter', 'deleteRowsByFilter'],
and: { field: 'bulkFilterMode', value: 'json' },
},
required: true,
wandConfig: {
enabled: true,
maintainHistory: true,
@@ -234,13 +272,42 @@ Return ONLY the filter JSON:`,
},
},
// Query filters
// Filter mode selector for queryRows
{
id: 'filterMode',
title: 'Filter Mode',
type: 'dropdown',
options: [
{ label: 'Builder', id: 'builder' },
{ label: 'Editor', id: 'json' },
],
value: () => 'builder',
condition: { field: 'operation', value: 'queryRows' },
},
// Filter builder (visual)
{
id: 'filterBuilder',
title: 'Filter Conditions',
type: 'filter-format',
condition: {
field: 'operation',
value: 'queryRows',
and: { field: 'filterMode', value: 'builder' },
},
},
// Query filters (JSON editor)
{
id: 'filter',
title: 'Filter',
type: 'code',
placeholder: '{"column_name": {"$eq": "value"}}',
condition: { field: 'operation', value: 'queryRows' },
condition: {
field: 'operation',
value: 'queryRows',
and: { field: 'filterMode', value: 'json' },
},
wandConfig: {
enabled: true,
maintainHistory: true,
@@ -288,12 +355,42 @@ Return ONLY the filter JSON:`,
generationType: 'json-object',
},
},
// Sort mode selector for queryRows
{
id: 'sortMode',
title: 'Sort Mode',
type: 'dropdown',
options: [
{ label: 'Builder', id: 'builder' },
{ label: 'Editor', id: 'json' },
],
value: () => 'builder',
condition: { field: 'operation', value: 'queryRows' },
},
// Sort builder (visual)
{
id: 'sortBuilder',
title: 'Sort Order',
type: 'sort-format',
condition: {
field: 'operation',
value: 'queryRows',
and: { field: 'sortMode', value: 'builder' },
},
},
// Sort (JSON editor)
{
id: 'sort',
title: 'Sort',
type: 'code',
placeholder: '{"column_name": "desc"}',
condition: { field: 'operation', value: 'queryRows' },
condition: {
field: 'operation',
value: 'queryRows',
and: { field: 'sortMode', value: 'json' },
},
wandConfig: {
enabled: true,
maintainHistory: true,
@@ -438,7 +535,12 @@ Return ONLY the sort JSON:`,
// Update Rows by Filter
if (operation === 'updateRowsByFilter') {
const filter = parseJSON(rest.filterCriteria, 'Filter Criteria')
let filter: any
if (rest.bulkFilterMode === 'builder' && rest.bulkFilterBuilder) {
filter = conditionsToFilter(rest.bulkFilterBuilder as any) || undefined
} else if (rest.filterCriteria) {
filter = parseJSON(rest.filterCriteria, 'Filter Criteria')
}
const data = parseJSON(rest.rowData, 'Row Data')
return {
tableId: rest.tableId,
@@ -458,7 +560,12 @@ Return ONLY the sort JSON:`,
// Delete Rows by Filter
if (operation === 'deleteRowsByFilter') {
const filter = parseJSON(rest.filterCriteria, 'Filter Criteria')
let filter: any
if (rest.bulkFilterMode === 'builder' && rest.bulkFilterBuilder) {
filter = conditionsToFilter(rest.bulkFilterBuilder as any) || undefined
} else if (rest.filterCriteria) {
filter = parseJSON(rest.filterCriteria, 'Filter Criteria')
}
return {
tableId: rest.tableId,
filter,
@@ -476,8 +583,21 @@ Return ONLY the sort JSON:`,
// Query Rows
if (operation === 'queryRows') {
const filter = rest.filter ? parseJSON(rest.filter, 'Filter') : undefined
const sort = rest.sort ? parseJSON(rest.sort, 'Sort') : undefined
let filter: any
if (rest.filterMode === 'builder' && rest.filterBuilder) {
// Convert builder conditions to filter object
filter = conditionsToFilter(rest.filterBuilder as any) || undefined
} else if (rest.filter) {
filter = parseJSON(rest.filter, 'Filter')
}
let sort: any
if (rest.sortMode === 'builder' && rest.sortBuilder) {
// Convert sort builder conditions to sort object
sort = sortConditionsToSort(rest.sortBuilder as any) || undefined
} else if (rest.sort) {
sort = parseJSON(rest.sort, 'Sort')
}
return {
tableId: rest.tableId,
@@ -499,10 +619,22 @@ Return ONLY the sort JSON:`,
rowData: { type: 'json', description: 'Row data for insert/update' },
batchRows: { type: 'array', description: 'Array of row data for batch insert' },
rowId: { type: 'string', description: 'Row identifier for ID-based operations' },
filterCriteria: { type: 'json', description: 'Filter criteria for bulk operations' },
bulkFilterMode: {
type: 'string',
description: 'Filter input mode for bulk operations (builder or json)',
},
bulkFilterBuilder: {
type: 'json',
description: 'Visual filter builder conditions for bulk operations',
},
filterCriteria: { type: 'json', description: 'Filter criteria for bulk operations (JSON)' },
bulkLimit: { type: 'number', description: 'Safety limit for bulk operations' },
filter: { type: 'json', description: 'Query filter conditions' },
sort: { type: 'json', description: 'Sort order' },
filterMode: { type: 'string', description: 'Filter input mode (builder or json)' },
filterBuilder: { type: 'json', description: 'Visual filter builder conditions' },
filter: { type: 'json', description: 'Query filter conditions (JSON)' },
sortMode: { type: 'string', description: 'Sort input mode (builder or json)' },
sortBuilder: { type: 'json', description: 'Visual sort builder conditions' },
sort: { type: 'json', description: 'Sort order (JSON)' },
limit: { type: 'number', description: 'Query result limit' },
offset: { type: 'number', description: 'Query result offset' },
},

View File

@@ -71,6 +71,8 @@ export type SubBlockType =
| 'mcp-dynamic-args' // MCP dynamic arguments based on tool schema
| 'input-format' // Input structure format
| 'response-format' // Response structure format
| 'filter-format' // Filter conditions builder
| 'sort-format' // Sort conditions builder
| 'trigger-save' // Trigger save button with validation
| 'file-upload' // File uploader
| 'input-mapping' // Map parent variables to child workflow input schema

View File

@@ -2,6 +2,7 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { X } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
@@ -73,6 +74,9 @@ const DialogContent = React.forwardRef<
}}
{...props}
>
<VisuallyHidden.Root>
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
</VisuallyHidden.Root>
{children}
{!hideCloseButton && (
<DialogPrimitive.Close

View File

@@ -0,0 +1,280 @@
/**
* Shared utilities for filter builder UI components.
* Used by both the table data viewer and the block editor filter-format component.
*/
/**
* Available comparison operators for filter conditions
*/
export const COMPARISON_OPERATORS = [
{ value: 'eq', label: 'equals' },
{ value: 'ne', label: 'not equals' },
{ value: 'gt', label: 'greater than' },
{ value: 'gte', label: 'greater or equal' },
{ value: 'lt', label: 'less than' },
{ value: 'lte', label: 'less or equal' },
{ value: 'contains', label: 'contains' },
{ value: 'in', label: 'in array' },
] as const
/**
* Logical operators for combining conditions
*/
export const LOGICAL_OPERATORS = [
{ value: 'and', label: 'and' },
{ value: 'or', label: 'or' },
] as const
/**
* Represents a single filter condition in builder format
*/
export interface FilterCondition {
id: string
logicalOperator: 'and' | 'or'
column: string
operator: string
value: string
}
/**
* Generates a unique ID for filter conditions
*/
export function generateFilterId(): string {
return Math.random().toString(36).substring(2, 9)
}
/**
* Parses a value string into its appropriate type
*/
function parseValue(value: string, operator: string): any {
if (value === 'true') return true
if (value === 'false') return false
if (value === 'null') return null
if (!isNaN(Number(value)) && value !== '') return Number(value)
if (operator === 'in') {
return value.split(',').map((v) => {
const trimmed = v.trim()
if (trimmed === 'true') return true
if (trimmed === 'false') return false
if (trimmed === 'null') return null
if (!isNaN(Number(trimmed)) && trimmed !== '') return Number(trimmed)
return trimmed
})
}
return value
}
/**
* Converts builder filter conditions to MongoDB-style filter object
*/
export function conditionsToFilter(conditions: FilterCondition[]): Record<string, any> | null {
if (conditions.length === 0) return null
const orGroups: Record<string, any>[] = []
let currentAndGroup: Record<string, any> = {}
conditions.forEach((condition, index) => {
const { column, operator, value } = condition
const operatorKey = `$${operator}`
const parsedValue = parseValue(value, operator)
const conditionObj = operator === 'eq' ? parsedValue : { [operatorKey]: parsedValue }
if (index === 0 || condition.logicalOperator === 'and') {
currentAndGroup[column] = conditionObj
} else if (condition.logicalOperator === 'or') {
if (Object.keys(currentAndGroup).length > 0) {
orGroups.push({ ...currentAndGroup })
}
currentAndGroup = { [column]: conditionObj }
}
})
if (Object.keys(currentAndGroup).length > 0) {
orGroups.push(currentAndGroup)
}
if (orGroups.length > 1) {
return { $or: orGroups }
}
return orGroups[0] || null
}
/**
* Converts MongoDB-style filter object to builder conditions
*/
export function filterToConditions(filter: Record<string, any> | null): FilterCondition[] {
if (!filter) return []
const conditions: FilterCondition[] = []
// Handle $or at the top level
if (filter.$or && Array.isArray(filter.$or)) {
filter.$or.forEach((orGroup, groupIndex) => {
const groupConditions = parseFilterGroup(orGroup)
groupConditions.forEach((cond, condIndex) => {
conditions.push({
...cond,
logicalOperator:
groupIndex === 0 && condIndex === 0
? 'and'
: groupIndex > 0 && condIndex === 0
? 'or'
: 'and',
})
})
})
return conditions
}
// Handle simple filter (all AND conditions)
return parseFilterGroup(filter)
}
/**
* Parses a single filter group (AND conditions)
*/
function parseFilterGroup(group: Record<string, any>): FilterCondition[] {
const conditions: FilterCondition[] = []
for (const [column, value] of Object.entries(group)) {
if (column === '$or' || column === '$and') continue
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// Operator-based condition
for (const [op, opValue] of Object.entries(value)) {
if (op.startsWith('$')) {
conditions.push({
id: generateFilterId(),
logicalOperator: 'and',
column,
operator: op.substring(1),
value: formatValueForBuilder(opValue),
})
}
}
} else {
// Direct equality
conditions.push({
id: generateFilterId(),
logicalOperator: 'and',
column,
operator: 'eq',
value: formatValueForBuilder(value),
})
}
}
return conditions
}
/**
* Formats a value for display in the builder UI
*/
function formatValueForBuilder(value: any): string {
if (value === null) return 'null'
if (typeof value === 'boolean') return String(value)
if (Array.isArray(value)) return value.map(formatValueForBuilder).join(', ')
return String(value)
}
/**
* Converts builder conditions to JSON string
*/
export function conditionsToJsonString(conditions: FilterCondition[]): string {
const filter = conditionsToFilter(conditions)
if (!filter) return ''
return JSON.stringify(filter, null, 2)
}
/**
* Converts JSON string to builder conditions
*/
export function jsonStringToConditions(jsonString: string): FilterCondition[] {
if (!jsonString || !jsonString.trim()) return []
try {
const filter = JSON.parse(jsonString)
return filterToConditions(filter)
} catch {
return []
}
}
/**
* Sort direction options
*/
export const SORT_DIRECTIONS = [
{ value: 'asc', label: 'ascending' },
{ value: 'desc', label: 'descending' },
] as const
/**
* Represents a single sort condition in builder format
*/
export interface SortCondition {
id: string
column: string
direction: 'asc' | 'desc'
}
/**
* Generates a unique ID for sort conditions
*/
export function generateSortId(): string {
return Math.random().toString(36).substring(2, 9)
}
/**
* Converts builder sort conditions to sort object
*/
export function sortConditionsToSort(conditions: SortCondition[]): Record<string, string> | null {
if (conditions.length === 0) return null
const sort: Record<string, string> = {}
for (const condition of conditions) {
if (condition.column) {
sort[condition.column] = condition.direction
}
}
return Object.keys(sort).length > 0 ? sort : null
}
/**
* Converts sort object to builder conditions
*/
export function sortToConditions(sort: Record<string, string> | null): SortCondition[] {
if (!sort) return []
return Object.entries(sort).map(([column, direction]) => ({
id: generateSortId(),
column,
direction: direction === 'desc' ? 'desc' : 'asc',
}))
}
/**
* Converts builder sort conditions to JSON string
*/
export function sortConditionsToJsonString(conditions: SortCondition[]): string {
const sort = sortConditionsToSort(conditions)
if (!sort) return ''
return JSON.stringify(sort, null, 2)
}
/**
* Converts JSON string to sort builder conditions
*/
export function jsonStringToSortConditions(jsonString: string): SortCondition[] {
if (!jsonString || !jsonString.trim()) return []
try {
const sort = JSON.parse(jsonString)
return sortToConditions(sort)
} catch {
return []
}
}

View File

@@ -4,7 +4,7 @@ import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from './constants'
export interface ColumnDefinition {
name: string
type: ColumnType
required?: boolean
optional?: boolean
unique?: boolean
}
@@ -150,8 +150,8 @@ export function validateRowAgainstSchema(
for (const column of schema.columns) {
const value = data[column.name]
// Check required fields
if (column.required && (value === undefined || value === null)) {
// Check required fields (columns are required by default unless marked optional)
if (!column.optional && (value === undefined || value === null)) {
errors.push(`Missing required field: ${column.name}`)
continue
}

View File

@@ -22,7 +22,8 @@ export type ColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json'
export interface ColumnDefinition {
name: string
type: ColumnType
required?: boolean
optional?: boolean
unique?: boolean
}
export interface TableSchema {