From 0872314fbf354566603d506d4fc3ef530b698e9a Mon Sep 17 00:00:00 2001 From: Lakee Sivaraya Date: Tue, 13 Jan 2026 16:19:22 -0800 Subject: [PATCH] filtering ui --- .../[tableId]/components/filter-builder.tsx | 369 ++++++++++++++++++ .../tables/[tableId]/table-data-viewer.tsx | 217 ++-------- apps/sim/lib/table/query-builder.ts | 209 ++++++---- 3 files changed, 527 insertions(+), 268 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/filter-builder.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/filter-builder.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/filter-builder.tsx new file mode 100644 index 000000000..6c16f825b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/filter-builder.tsx @@ -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 | 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 | null { + if (conditions.length === 0) return null + + const orGroups: Record[] = [] + let currentAndGroup: Record = {} + + 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([]) + const [sortConfig, setSortConfig] = useState(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 ( +
+ {/* Filter Conditions */} + {conditions.map((condition, index) => ( +
+ + +
+ {index === 0 ? ( + + ) : ( + + handleUpdateCondition(condition.id, 'logicalOperator', value as 'and' | 'or') + } + /> + )} +
+ +
+ handleUpdateCondition(condition.id, 'column', value)} + placeholder='Column' + /> +
+ +
+ handleUpdateCondition(condition.id, 'operator', value)} + /> +
+ + handleUpdateCondition(condition.id, 'value', e.target.value)} + placeholder='Value' + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleApply() + } + }} + /> +
+ ))} + + {/* Sort Row */} + {sortConfig && ( +
+ + +
+ +
+ +
+ + setSortConfig((prev) => (prev ? { ...prev, column: value } : null)) + } + placeholder='Column' + /> +
+ +
+ + setSortConfig((prev) => + prev ? { ...prev, direction: value as 'asc' | 'desc' } : null + ) + } + /> +
+ +
+ {sortConfig.direction === 'asc' ? ( + + ) : ( + + )} +
+
+ )} + + {/* Action Buttons */} +
+ + + + + {!sortConfig && ( + + )} + + {hasChanges && ( + <> + + + + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer.tsx index dbd471f08..bac829f14 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer.tsx @@ -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>(new Set()) - const [filterInput, setFilterInput] = useState('') - const [appliedFilter, setAppliedFilter] = useState | null>(null) - const [filterError, setFilterError] = useState(null) + const [queryOptions, setQueryOptions] = useState({ + filter: null, + sort: null, + }) const [currentPage, setCurrentPage] = useState(0) const [showAddModal, setShowAddModal] = useState(false) const [editingRow, setEditingRow] = useState(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() { Refresh - - {/* Filter Bar */}
-
- - 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)]' - )} - /> - - {appliedFilter && ( - - )} - - - - - -
-
-

- Filter Operators -

-

- Use MongoDB-style operators to filter rows -

-
-
-
- - $eq - - - Equals: {`{"status": "active"}`} - -
-
- - $ne - - - Not equals: {`{"status": {"$ne": "deleted"}}`} - -
-
- - $gt - - - Greater than: {`{"age": {"$gt": 18}}`} - -
-
- - $gte - - - Greater or equal: {`{"age": {"$gte": 21}}`} - -
-
- - $lt - - - Less than: {`{"price": {"$lt": 100}}`} - -
-
- - $lte - - - Less or equal: {`{"qty": {"$lte": 10}}`} - -
-
- - $in - - - In array: {`{"status": {"$in": ["a", "b"]}}`} - -
-
- - $contains - - - String contains: {`{"email": {"$contains": "@"}}`} - -
-
-
-

- Combine multiple conditions:{' '} - - {`{"age": {"$gte": 18}, "active": true}`} - -

-
-
-
-
-
- {filterError && {filterError}} - {appliedFilter && ( -
- - Filter active - - - {JSON.stringify(appliedFilter)} - -
- )} + setShowAddModal(true)} + /> {selectedRows.size > 0 && ( {selectedRows.size} selected @@ -565,9 +408,9 @@ export function TableDataViewer() {
- {appliedFilter ? 'No rows match your filter' : 'No data'} + {queryOptions.filter ? 'No rows match your filter' : 'No data'} - {!appliedFilter && ( + {!queryOptions.filter && (
diff --git a/apps/sim/lib/table/query-builder.ts b/apps/sim/lib/table/query-builder.ts index ab34b9cc2..27c199155 100644 --- a/apps/sim/lib/table/query-builder.ts +++ b/apps/sim/lib/table/query-builder.ts @@ -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