mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-22 21:38:05 -05:00
updates
This commit is contained in:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
41
apps/sim/app/workspace/[workspaceId]/tables/error.tsx
Normal file
41
apps/sim/app/workspace/[workspaceId]/tables/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
280
apps/sim/lib/table/filter-builder-utils.ts
Normal file
280
apps/sim/lib/table/filter-builder-utils.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user