mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-22 13:28:04 -05:00
filtering ui
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { ArrowDownAZ, ArrowUpAZ, Plus, X } from 'lucide-react'
|
||||
import { Button, Combobox, Input } from '@/components/emcn'
|
||||
|
||||
/**
|
||||
* Available comparison operators for filter conditions
|
||||
*/
|
||||
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 (for subsequent filters)
|
||||
*/
|
||||
const LOGICAL_OPERATORS = [
|
||||
{ value: 'and', label: 'and' },
|
||||
{ value: 'or', label: 'or' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Sort direction options
|
||||
*/
|
||||
const SORT_DIRECTIONS = [
|
||||
{ value: 'asc', label: 'ascending' },
|
||||
{ value: 'desc', label: 'descending' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Represents a single filter condition
|
||||
*/
|
||||
export interface FilterCondition {
|
||||
id: string
|
||||
logicalOperator: 'and' | 'or'
|
||||
column: string
|
||||
operator: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a sort configuration
|
||||
*/
|
||||
export interface SortConfig {
|
||||
column: string
|
||||
direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
/**
|
||||
* Query options for the table
|
||||
*/
|
||||
export interface QueryOptions {
|
||||
filter: Record<string, any> | null
|
||||
sort: SortConfig | null
|
||||
}
|
||||
|
||||
interface Column {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface FilterBuilderProps {
|
||||
columns: Column[]
|
||||
onApply: (options: QueryOptions) => void
|
||||
onAddRow: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique ID for filter conditions
|
||||
*/
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 9)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts filter conditions to MongoDB-style filter object
|
||||
*/
|
||||
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}`
|
||||
|
||||
let parsedValue: any = value
|
||||
if (value === 'true') parsedValue = true
|
||||
else if (value === 'false') parsedValue = false
|
||||
else if (value === 'null') parsedValue = null
|
||||
else if (!isNaN(Number(value)) && value !== '') parsedValue = Number(value)
|
||||
else if (operator === 'in') {
|
||||
parsedValue = 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
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function FilterBuilder({ columns, onApply, onAddRow }: FilterBuilderProps) {
|
||||
const [conditions, setConditions] = useState<FilterCondition[]>([])
|
||||
const [sortConfig, setSortConfig] = useState<SortConfig | null>(null)
|
||||
|
||||
const columnOptions = useMemo(
|
||||
() => columns.map((col) => ({ value: col.name, label: col.name })),
|
||||
[columns]
|
||||
)
|
||||
|
||||
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 sortDirectionOptions = useMemo(
|
||||
() => SORT_DIRECTIONS.map((d) => ({ value: d.value, label: d.label })),
|
||||
[]
|
||||
)
|
||||
|
||||
const handleAddCondition = useCallback(() => {
|
||||
const newCondition: FilterCondition = {
|
||||
id: generateId(),
|
||||
logicalOperator: 'and',
|
||||
column: columns[0]?.name || '',
|
||||
operator: 'eq',
|
||||
value: '',
|
||||
}
|
||||
setConditions((prev) => [...prev, newCondition])
|
||||
}, [columns])
|
||||
|
||||
const handleRemoveCondition = useCallback((id: string) => {
|
||||
setConditions((prev) => prev.filter((c) => c.id !== id))
|
||||
}, [])
|
||||
|
||||
const handleUpdateCondition = useCallback(
|
||||
(id: string, field: keyof FilterCondition, value: string) => {
|
||||
setConditions((prev) => prev.map((c) => (c.id === id ? { ...c, [field]: value } : c)))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleAddSort = useCallback(() => {
|
||||
setSortConfig({
|
||||
column: columns[0]?.name || '',
|
||||
direction: 'asc',
|
||||
})
|
||||
}, [columns])
|
||||
|
||||
const handleRemoveSort = useCallback(() => {
|
||||
setSortConfig(null)
|
||||
}, [])
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
const filter = conditionsToFilter(conditions)
|
||||
onApply({
|
||||
filter,
|
||||
sort: sortConfig,
|
||||
})
|
||||
}, [conditions, sortConfig, onApply])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setConditions([])
|
||||
setSortConfig(null)
|
||||
onApply({
|
||||
filter: null,
|
||||
sort: null,
|
||||
})
|
||||
}, [onApply])
|
||||
|
||||
const hasChanges = conditions.length > 0 || sortConfig !== null
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{/* Filter Conditions */}
|
||||
{conditions.map((condition, index) => (
|
||||
<div key={condition.id} className='flex items-center gap-[8px]'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleRemoveCondition(condition.id)}
|
||||
className='h-[28px] w-[28px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<X className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
|
||||
<div className='w-[80px] shrink-0'>
|
||||
{index === 0 ? (
|
||||
<Combobox
|
||||
size='sm'
|
||||
options={[{ value: 'where', label: 'where' }]}
|
||||
value='where'
|
||||
disabled
|
||||
/>
|
||||
) : (
|
||||
<Combobox
|
||||
size='sm'
|
||||
options={logicalOptions}
|
||||
value={condition.logicalOperator}
|
||||
onChange={(value) =>
|
||||
handleUpdateCondition(condition.id, 'logicalOperator', value as 'and' | 'or')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='w-[140px] shrink-0'>
|
||||
<Combobox
|
||||
size='sm'
|
||||
options={columnOptions}
|
||||
value={condition.column}
|
||||
onChange={(value) => handleUpdateCondition(condition.id, 'column', value)}
|
||||
placeholder='Column'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='w-[130px] shrink-0'>
|
||||
<Combobox
|
||||
size='sm'
|
||||
options={comparisonOptions}
|
||||
value={condition.operator}
|
||||
onChange={(value) => handleUpdateCondition(condition.id, 'operator', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
className='h-[28px] min-w-[200px] flex-1 text-[12px]'
|
||||
value={condition.value}
|
||||
onChange={(e) => handleUpdateCondition(condition.id, 'value', e.target.value)}
|
||||
placeholder='Value'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleApply()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Sort Row */}
|
||||
{sortConfig && (
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleRemoveSort}
|
||||
className='h-[28px] w-[28px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<X className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
|
||||
<div className='w-[80px] shrink-0'>
|
||||
<Combobox
|
||||
size='sm'
|
||||
options={[{ value: 'order', label: 'order' }]}
|
||||
value='order'
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='w-[140px] shrink-0'>
|
||||
<Combobox
|
||||
size='sm'
|
||||
options={columnOptions}
|
||||
value={sortConfig.column}
|
||||
onChange={(value) =>
|
||||
setSortConfig((prev) => (prev ? { ...prev, column: value } : null))
|
||||
}
|
||||
placeholder='Column'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='w-[130px] shrink-0'>
|
||||
<Combobox
|
||||
size='sm'
|
||||
options={sortDirectionOptions}
|
||||
value={sortConfig.direction}
|
||||
onChange={(value) =>
|
||||
setSortConfig((prev) =>
|
||||
prev ? { ...prev, direction: value as 'asc' | 'desc' } : null
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center text-[12px] text-[var(--text-tertiary)]'>
|
||||
{sortConfig.direction === 'asc' ? (
|
||||
<ArrowUpAZ className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<ArrowDownAZ className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Button variant='default' size='sm' onClick={onAddRow}>
|
||||
<Plus className='mr-[4px] h-[12px] w-[12px]' />
|
||||
Add row
|
||||
</Button>
|
||||
|
||||
<Button variant='default' size='sm' onClick={handleAddCondition}>
|
||||
<Plus className='mr-[4px] h-[12px] w-[12px]' />
|
||||
Add filter
|
||||
</Button>
|
||||
|
||||
{!sortConfig && (
|
||||
<Button variant='default' size='sm' onClick={handleAddSort}>
|
||||
<ArrowUpAZ className='mr-[4px] h-[12px] w-[12px]' />
|
||||
Add sort
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasChanges && (
|
||||
<>
|
||||
<Button variant='default' size='sm' onClick={handleApply}>
|
||||
Apply
|
||||
</Button>
|
||||
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className='text-[12px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,31 +3,15 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Columns,
|
||||
Copy,
|
||||
Edit,
|
||||
Filter,
|
||||
HelpCircle,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { Columns, Copy, Edit, Plus, RefreshCw, Trash2, X } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -42,6 +26,7 @@ import type { TableSchema } from '@/lib/table'
|
||||
import { AddRowModal } from './components/add-row-modal'
|
||||
import { DeleteRowModal } from './components/delete-row-modal'
|
||||
import { EditRowModal } from './components/edit-row-modal'
|
||||
import { FilterBuilder, type QueryOptions } from './components/filter-builder'
|
||||
import { TableActionBar } from './components/table-action-bar'
|
||||
|
||||
const logger = createLogger('TableDataViewer')
|
||||
@@ -82,9 +67,10 @@ export function TableDataViewer() {
|
||||
const tableId = params.tableId as string
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
|
||||
const [filterInput, setFilterInput] = useState('')
|
||||
const [appliedFilter, setAppliedFilter] = useState<Record<string, any> | null>(null)
|
||||
const [filterError, setFilterError] = useState<string | null>(null)
|
||||
const [queryOptions, setQueryOptions] = useState<QueryOptions>({
|
||||
filter: null,
|
||||
sort: null,
|
||||
})
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [editingRow, setEditingRow] = useState<TableRowData | null>(null)
|
||||
@@ -104,25 +90,31 @@ export function TableDataViewer() {
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch table rows with filter
|
||||
// Fetch table rows with filter and sort
|
||||
const {
|
||||
data: rowsData,
|
||||
isLoading: isLoadingRows,
|
||||
refetch: refetchRows,
|
||||
} = useQuery({
|
||||
queryKey: ['table-rows', tableId, currentPage, appliedFilter],
|
||||
queryKey: ['table-rows', tableId, queryOptions, currentPage],
|
||||
queryFn: async () => {
|
||||
const queryParams = new URLSearchParams({
|
||||
const params = new URLSearchParams({
|
||||
workspaceId,
|
||||
limit: String(ROWS_PER_PAGE),
|
||||
offset: String(currentPage * ROWS_PER_PAGE),
|
||||
})
|
||||
|
||||
if (appliedFilter) {
|
||||
queryParams.set('filter', JSON.stringify(appliedFilter))
|
||||
if (queryOptions.filter) {
|
||||
params.set('filter', JSON.stringify(queryOptions.filter))
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/table/${tableId}/rows?${queryParams}`)
|
||||
if (queryOptions.sort) {
|
||||
// Convert from {column, direction} to {column: direction} format expected by API
|
||||
const sortParam = { [queryOptions.sort.column]: queryOptions.sort.direction }
|
||||
params.set('sort', JSON.stringify(sortParam))
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/table/${tableId}/rows?${params}`)
|
||||
if (!res.ok) throw new Error('Failed to fetch rows')
|
||||
return res.json()
|
||||
},
|
||||
@@ -134,28 +126,8 @@ export function TableDataViewer() {
|
||||
const totalCount = rowsData?.totalCount || 0
|
||||
const totalPages = Math.ceil(totalCount / ROWS_PER_PAGE)
|
||||
|
||||
const handleApplyFilter = useCallback(() => {
|
||||
setFilterError(null)
|
||||
|
||||
if (!filterInput.trim()) {
|
||||
setAppliedFilter(null)
|
||||
setCurrentPage(0)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(filterInput)
|
||||
setAppliedFilter(parsed)
|
||||
setCurrentPage(0)
|
||||
} catch (err) {
|
||||
setFilterError('Invalid JSON. Use format: {"column": {"$eq": "value"}}')
|
||||
}
|
||||
}, [filterInput])
|
||||
|
||||
const handleClearFilter = useCallback(() => {
|
||||
setFilterInput('')
|
||||
setAppliedFilter(null)
|
||||
setFilterError(null)
|
||||
const handleApplyQueryOptions = useCallback((options: QueryOptions) => {
|
||||
setQueryOptions(options)
|
||||
setCurrentPage(0)
|
||||
}, [])
|
||||
|
||||
@@ -338,145 +310,16 @@ export function TableDataViewer() {
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Refresh</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Button variant='default' size='sm' onClick={() => setShowAddModal(true)}>
|
||||
<Plus className='mr-[4px] h-[12px] w-[12px]' />
|
||||
Add Row
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className='flex shrink-0 flex-col gap-[8px] border-[var(--border)] border-b px-[16px] py-[10px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Filter className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
<Input
|
||||
value={filterInput}
|
||||
onChange={(e) => setFilterInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleApplyFilter()
|
||||
}
|
||||
}}
|
||||
placeholder='{"column": {"$eq": "value"}}'
|
||||
className={cn(
|
||||
'h-[32px] flex-1 font-mono text-[11px]',
|
||||
filterError && 'border-[var(--text-error)]'
|
||||
)}
|
||||
/>
|
||||
<Button variant='default' size='sm' onClick={handleApplyFilter}>
|
||||
Apply
|
||||
</Button>
|
||||
{appliedFilter && (
|
||||
<Button variant='ghost' size='sm' onClick={handleClearFilter}>
|
||||
<X className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
)}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='ghost' size='sm'>
|
||||
<HelpCircle className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[360px] p-[12px]' align='end'>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<div>
|
||||
<h4 className='font-medium text-[12px] text-[var(--text-primary)]'>
|
||||
Filter Operators
|
||||
</h4>
|
||||
<p className='text-[11px] text-[var(--text-tertiary)]'>
|
||||
Use MongoDB-style operators to filter rows
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-col gap-[6px] font-mono text-[10px]'>
|
||||
<div className='flex items-start gap-[8px]'>
|
||||
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
|
||||
$eq
|
||||
</code>
|
||||
<span className='text-[var(--text-secondary)]'>
|
||||
Equals: {`{"status": "active"}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-start gap-[8px]'>
|
||||
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
|
||||
$ne
|
||||
</code>
|
||||
<span className='text-[var(--text-secondary)]'>
|
||||
Not equals: {`{"status": {"$ne": "deleted"}}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-start gap-[8px]'>
|
||||
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
|
||||
$gt
|
||||
</code>
|
||||
<span className='text-[var(--text-secondary)]'>
|
||||
Greater than: {`{"age": {"$gt": 18}}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-start gap-[8px]'>
|
||||
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
|
||||
$gte
|
||||
</code>
|
||||
<span className='text-[var(--text-secondary)]'>
|
||||
Greater or equal: {`{"age": {"$gte": 21}}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-start gap-[8px]'>
|
||||
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
|
||||
$lt
|
||||
</code>
|
||||
<span className='text-[var(--text-secondary)]'>
|
||||
Less than: {`{"price": {"$lt": 100}}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-start gap-[8px]'>
|
||||
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
|
||||
$lte
|
||||
</code>
|
||||
<span className='text-[var(--text-secondary)]'>
|
||||
Less or equal: {`{"qty": {"$lte": 10}}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-start gap-[8px]'>
|
||||
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
|
||||
$in
|
||||
</code>
|
||||
<span className='text-[var(--text-secondary)]'>
|
||||
In array: {`{"status": {"$in": ["a", "b"]}}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-start gap-[8px]'>
|
||||
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
|
||||
$contains
|
||||
</code>
|
||||
<span className='text-[var(--text-secondary)]'>
|
||||
String contains: {`{"email": {"$contains": "@"}}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-[var(--border)] border-t pt-[8px]'>
|
||||
<p className='text-[10px] text-[var(--text-tertiary)]'>
|
||||
Combine multiple conditions:{' '}
|
||||
<code className='text-[var(--text-secondary)]'>
|
||||
{`{"age": {"$gte": 18}, "active": true}`}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{filterError && <span className='text-[11px] text-[var(--text-error)]'>{filterError}</span>}
|
||||
{appliedFilter && (
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Badge variant='blue' size='sm'>
|
||||
Filter active
|
||||
</Badge>
|
||||
<span className='font-mono text-[10px] text-[var(--text-tertiary)]'>
|
||||
{JSON.stringify(appliedFilter)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<FilterBuilder
|
||||
columns={columns}
|
||||
onApply={handleApplyQueryOptions}
|
||||
onAddRow={() => setShowAddModal(true)}
|
||||
/>
|
||||
{selectedRows.size > 0 && (
|
||||
<span className='text-[11px] text-[var(--text-tertiary)]'>
|
||||
{selectedRows.size} selected
|
||||
@@ -565,9 +408,9 @@ export function TableDataViewer() {
|
||||
<TableCell colSpan={columns.length + 2} className='h-[160px] text-center'>
|
||||
<div className='flex flex-col items-center gap-[12px]'>
|
||||
<span className='text-[13px] text-[var(--text-tertiary)]'>
|
||||
{appliedFilter ? 'No rows match your filter' : 'No data'}
|
||||
{queryOptions.filter ? 'No rows match your filter' : 'No data'}
|
||||
</span>
|
||||
{!appliedFilter && (
|
||||
{!queryOptions.filter && (
|
||||
<Button variant='default' size='sm' onClick={() => setShowAddModal(true)}>
|
||||
<Plus className='mr-[4px] h-[12px] w-[12px]' />
|
||||
Add first row
|
||||
@@ -635,7 +478,7 @@ export function TableDataViewer() {
|
||||
{totalPages > 1 && (
|
||||
<div className='flex h-[40px] shrink-0 items-center justify-between border-[var(--border)] border-t px-[16px]'>
|
||||
<span className='text-[11px] text-[var(--text-tertiary)]'>
|
||||
Page {currentPage + 1} of {totalPages}
|
||||
Page {currentPage + 1} of {totalPages} ({totalCount} rows)
|
||||
</span>
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Button
|
||||
@@ -644,7 +487,7 @@ export function TableDataViewer() {
|
||||
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||||
disabled={currentPage === 0}
|
||||
>
|
||||
<ChevronLeft className='h-[14px] w-[14px]' />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -652,7 +495,7 @@ export function TableDataViewer() {
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={currentPage === totalPages - 1}
|
||||
>
|
||||
<ChevronRight className='h-[14px] w-[14px]' />
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,20 +15,22 @@
|
||||
import type { SQL } from 'drizzle-orm'
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
export interface FieldCondition {
|
||||
$eq?: any
|
||||
$ne?: any
|
||||
$gt?: number
|
||||
$gte?: number
|
||||
$lt?: number
|
||||
$lte?: number
|
||||
$in?: any[]
|
||||
$nin?: any[]
|
||||
$contains?: string
|
||||
}
|
||||
|
||||
export interface QueryFilter {
|
||||
[key: string]:
|
||||
| any
|
||||
| {
|
||||
$eq?: any
|
||||
$ne?: any
|
||||
$gt?: number
|
||||
$gte?: number
|
||||
$lt?: number
|
||||
$lte?: number
|
||||
$in?: any[]
|
||||
$nin?: any[]
|
||||
$contains?: string
|
||||
}
|
||||
$or?: QueryFilter[]
|
||||
$and?: QueryFilter[]
|
||||
[key: string]: any | FieldCondition | QueryFilter[] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,9 +43,79 @@ function buildContainmentClause(tableName: string, field: string, value: any): S
|
||||
return sql`${sql.raw(`${tableName}.data`)} @> ${jsonObj}::jsonb`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single field condition clause
|
||||
*/
|
||||
function buildFieldCondition(tableName: string, field: string, condition: any): SQL[] {
|
||||
const conditions: SQL[] = []
|
||||
const escapedField = field.replace(/'/g, "''")
|
||||
|
||||
if (typeof condition === 'object' && condition !== null && !Array.isArray(condition)) {
|
||||
// Operator-based filter
|
||||
for (const [op, value] of Object.entries(condition)) {
|
||||
switch (op) {
|
||||
case '$eq':
|
||||
conditions.push(buildContainmentClause(tableName, field, value))
|
||||
break
|
||||
case '$ne':
|
||||
conditions.push(sql`NOT (${buildContainmentClause(tableName, field, value)})`)
|
||||
break
|
||||
case '$gt':
|
||||
conditions.push(
|
||||
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric > ${value}`
|
||||
)
|
||||
break
|
||||
case '$gte':
|
||||
conditions.push(
|
||||
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric >= ${value}`
|
||||
)
|
||||
break
|
||||
case '$lt':
|
||||
conditions.push(
|
||||
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric < ${value}`
|
||||
)
|
||||
break
|
||||
case '$lte':
|
||||
conditions.push(
|
||||
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric <= ${value}`
|
||||
)
|
||||
break
|
||||
case '$in':
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
if (value.length === 1) {
|
||||
conditions.push(buildContainmentClause(tableName, field, value[0]))
|
||||
} else {
|
||||
const inConditions = value.map((v) => buildContainmentClause(tableName, field, v))
|
||||
conditions.push(sql`(${sql.join(inConditions, sql.raw(' OR '))})`)
|
||||
}
|
||||
}
|
||||
break
|
||||
case '$nin':
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const ninConditions = value.map(
|
||||
(v) => sql`NOT (${buildContainmentClause(tableName, field, v)})`
|
||||
)
|
||||
conditions.push(sql`(${sql.join(ninConditions, sql.raw(' AND '))})`)
|
||||
}
|
||||
break
|
||||
case '$contains':
|
||||
conditions.push(
|
||||
sql`${sql.raw(`${tableName}.data->>'${escapedField}'`)} ILIKE ${`%${value}%`}`
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct equality
|
||||
conditions.push(buildContainmentClause(tableName, field, condition))
|
||||
}
|
||||
|
||||
return conditions
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WHERE clause from filter object
|
||||
* Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains
|
||||
* Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains, $or, $and
|
||||
*
|
||||
* Uses GIN-index-compatible containment operator (@>) for:
|
||||
* - $eq (equality)
|
||||
@@ -55,81 +127,56 @@ function buildContainmentClause(tableName: string, field: string, value: any): S
|
||||
* - $gt, $gte, $lt, $lte (numeric comparisons)
|
||||
* - $nin (not in)
|
||||
* - $contains (pattern matching)
|
||||
*
|
||||
* Logical operators:
|
||||
* - $or: Array of filter objects, joined with OR
|
||||
* - $and: Array of filter objects, joined with AND
|
||||
*/
|
||||
export function buildFilterClause(filter: QueryFilter, tableName: string): SQL | undefined {
|
||||
const conditions: SQL[] = []
|
||||
|
||||
for (const [field, condition] of Object.entries(filter)) {
|
||||
// Escape field name to prevent SQL injection (for ->> operators)
|
||||
const escapedField = field.replace(/'/g, "''")
|
||||
|
||||
if (typeof condition === 'object' && condition !== null && !Array.isArray(condition)) {
|
||||
// Operator-based filter
|
||||
for (const [op, value] of Object.entries(condition)) {
|
||||
switch (op) {
|
||||
case '$eq':
|
||||
// Use containment operator for GIN index support
|
||||
conditions.push(buildContainmentClause(tableName, field, value))
|
||||
break
|
||||
case '$ne':
|
||||
// NOT containment - still uses GIN index for the containment check
|
||||
conditions.push(sql`NOT (${buildContainmentClause(tableName, field, value)})`)
|
||||
break
|
||||
case '$gt':
|
||||
// Numeric comparison requires text extraction (no GIN support)
|
||||
conditions.push(
|
||||
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric > ${value}`
|
||||
)
|
||||
break
|
||||
case '$gte':
|
||||
conditions.push(
|
||||
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric >= ${value}`
|
||||
)
|
||||
break
|
||||
case '$lt':
|
||||
conditions.push(
|
||||
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric < ${value}`
|
||||
)
|
||||
break
|
||||
case '$lte':
|
||||
conditions.push(
|
||||
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric <= ${value}`
|
||||
)
|
||||
break
|
||||
case '$in':
|
||||
// Use OR of containment checks for GIN index support
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
if (value.length === 1) {
|
||||
// Single value - just use containment
|
||||
conditions.push(buildContainmentClause(tableName, field, value[0]))
|
||||
} else {
|
||||
// Multiple values - OR of containment checks
|
||||
const inConditions = value.map((v) => buildContainmentClause(tableName, field, v))
|
||||
conditions.push(sql`(${sql.join(inConditions, sql.raw(' OR '))})`)
|
||||
}
|
||||
}
|
||||
break
|
||||
case '$nin':
|
||||
// NOT IN requires checking none of the values match
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const ninConditions = value.map(
|
||||
(v) => sql`NOT (${buildContainmentClause(tableName, field, v)})`
|
||||
)
|
||||
conditions.push(sql`(${sql.join(ninConditions, sql.raw(' AND '))})`)
|
||||
}
|
||||
break
|
||||
case '$contains':
|
||||
// Pattern matching requires text extraction (no GIN support)
|
||||
conditions.push(
|
||||
sql`${sql.raw(`${tableName}.data->>'${escapedField}'`)} ILIKE ${`%${value}%`}`
|
||||
)
|
||||
break
|
||||
// Handle $or operator
|
||||
if (field === '$or' && Array.isArray(condition)) {
|
||||
const orConditions: SQL[] = []
|
||||
for (const subFilter of condition) {
|
||||
const subClause = buildFilterClause(subFilter as QueryFilter, tableName)
|
||||
if (subClause) {
|
||||
orConditions.push(subClause)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct equality - use containment operator for GIN index support
|
||||
conditions.push(buildContainmentClause(tableName, field, condition))
|
||||
if (orConditions.length > 0) {
|
||||
if (orConditions.length === 1) {
|
||||
conditions.push(orConditions[0])
|
||||
} else {
|
||||
conditions.push(sql`(${sql.join(orConditions, sql.raw(' OR '))})`)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle $and operator
|
||||
if (field === '$and' && Array.isArray(condition)) {
|
||||
const andConditions: SQL[] = []
|
||||
for (const subFilter of condition) {
|
||||
const subClause = buildFilterClause(subFilter as QueryFilter, tableName)
|
||||
if (subClause) {
|
||||
andConditions.push(subClause)
|
||||
}
|
||||
}
|
||||
if (andConditions.length > 0) {
|
||||
if (andConditions.length === 1) {
|
||||
conditions.push(andConditions[0])
|
||||
} else {
|
||||
conditions.push(sql`(${sql.join(andConditions, sql.raw(' AND '))})`)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle regular field conditions
|
||||
const fieldConditions = buildFieldCondition(tableName, field, condition)
|
||||
conditions.push(...fieldConditions)
|
||||
}
|
||||
|
||||
if (conditions.length === 0) return undefined
|
||||
|
||||
Reference in New Issue
Block a user