From 652937f7f5bb7c1b926770fe827dd8e3eee2c621 Mon Sep 17 00:00:00 2001 From: Aleksey Shakhmatov Date: Wed, 8 Apr 2026 15:30:29 +0300 Subject: [PATCH] feat: add visual filter builder with SQL fallback for table data Replace raw WHERE input with a dual-mode filter: - Visual mode: column/operator/value dropdowns with AND/OR support - SQL mode: raw WHERE clause input (auto-strips "where" prefix) --- src/components/table-viewer/FilterBuilder.tsx | 549 ++++++++++++++++++ src/components/table-viewer/TableDataView.tsx | 45 +- 2 files changed, 568 insertions(+), 26 deletions(-) create mode 100644 src/components/table-viewer/FilterBuilder.tsx diff --git a/src/components/table-viewer/FilterBuilder.tsx b/src/components/table-viewer/FilterBuilder.tsx new file mode 100644 index 0000000..dbcb723 --- /dev/null +++ b/src/components/table-viewer/FilterBuilder.tsx @@ -0,0 +1,549 @@ +import { useState, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Plus, X, Check, ChevronsUpDown, Code, SlidersHorizontal } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { ColumnInfo } from "@/types"; + +// --- Types --- + +type Conjunction = "AND" | "OR"; + +interface FilterCondition { + id: string; + column: string; + operator: string; + value: string; + valueTo: string; +} + +interface FilterBuilderProps { + columns: ColumnInfo[]; + onFilterChange: (whereClause: string | undefined) => void; + children?: React.ReactNode; +} + +// --- Operator mapping by PG type --- + +type TypeCategory = + | "numeric" + | "text" + | "boolean" + | "datetime" + | "uuid" + | "json" + | "default"; + +const TYPE_CATEGORY_MAP: Record = { + int2: "numeric", + int4: "numeric", + int8: "numeric", + float4: "numeric", + float8: "numeric", + numeric: "numeric", + decimal: "numeric", + money: "numeric", + serial: "numeric", + bigserial: "numeric", + smallserial: "numeric", + varchar: "text", + text: "text", + char: "text", + bpchar: "text", + name: "text", + citext: "text", + bool: "boolean", + boolean: "boolean", + timestamp: "datetime", + timestamptz: "datetime", + date: "datetime", + time: "datetime", + timetz: "datetime", + interval: "datetime", + uuid: "uuid", + json: "json", + jsonb: "json", +}; + +const OPERATORS_BY_CATEGORY: Record = { + numeric: ["=", "!=", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL", "IN", "BETWEEN"], + text: ["=", "!=", "LIKE", "ILIKE", "IS NULL", "IS NOT NULL", "IN", "~"], + boolean: ["=", "IS NULL", "IS NOT NULL"], + datetime: ["=", "!=", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL", "BETWEEN"], + uuid: ["=", "!=", "IS NULL", "IS NOT NULL", "IN"], + json: ["IS NULL", "IS NOT NULL"], + default: ["=", "!=", "IS NULL", "IS NOT NULL"], +}; + +// --- Helpers --- + +function getTypeCategory(dataType: string): TypeCategory { + return TYPE_CATEGORY_MAP[dataType] ?? "default"; +} + +function getOperatorsForColumn(dataType: string): string[] { + return OPERATORS_BY_CATEGORY[getTypeCategory(dataType)]; +} + +function quoteIdentifier(name: string): string { + return `"${name.replace(/"/g, '""')}"`; +} + +function escapeLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function serializeConditions( + conditions: FilterCondition[], + conjunction: Conjunction, + columns: ColumnInfo[], +): string | undefined { + const parts: string[] = []; + + for (const c of conditions) { + if (!c.column) continue; + + const col = quoteIdentifier(c.column); + const colInfo = columns.find((ci) => ci.name === c.column); + const isBool = colInfo && getTypeCategory(colInfo.data_type) === "boolean"; + + if (c.operator === "IS NULL") { + parts.push(`${col} IS NULL`); + } else if (c.operator === "IS NOT NULL") { + parts.push(`${col} IS NOT NULL`); + } else if (c.operator === "IN") { + const items = c.value + .split(",") + .map((v) => v.trim()) + .filter(Boolean) + .map((v) => escapeLiteral(v)); + if (items.length > 0) { + parts.push(`${col} IN (${items.join(", ")})`); + } + } else if (c.operator === "BETWEEN") { + if (c.value && c.valueTo) { + parts.push( + `${col} BETWEEN ${escapeLiteral(c.value)} AND ${escapeLiteral(c.valueTo)}`, + ); + } + } else if (isBool) { + if (c.value === "true" || c.value === "false") { + parts.push(`${col} ${c.operator} ${c.value}`); + } + } else { + if (c.value !== "") { + parts.push(`${col} ${c.operator} ${escapeLiteral(c.value)}`); + } + } + } + + if (parts.length === 0) return undefined; + return parts.join(` ${conjunction} `); +} + +function createCondition(): FilterCondition { + return { + id: crypto.randomUUID(), + column: "", + operator: "=", + value: "", + valueTo: "", + }; +} + +// --- Sub-components --- + +function ColumnCombobox({ + columns, + value, + onSelect, +}: { + columns: ColumnInfo[]; + value: string; + onSelect: (column: string) => void; +}) { + const [open, setOpen] = useState(false); + const selected = columns.find((c) => c.name === value); + + return ( + + + + + + + + + No column found. + + {columns.map((col) => ( + { + onSelect(v); + setOpen(false); + }} + > + + {col.name} + + {col.data_type} + + + ))} + + + + + + ); +} + +function FilterConditionRow({ + condition, + columns, + onChange, + onRemove, +}: { + condition: FilterCondition; + columns: ColumnInfo[]; + onChange: (updated: FilterCondition) => void; + onRemove: () => void; +}) { + const colInfo = columns.find((c) => c.name === condition.column); + const dataType = colInfo?.data_type ?? ""; + const operators = getOperatorsForColumn(dataType); + const category = getTypeCategory(dataType); + const needsNoValue = + condition.operator === "IS NULL" || condition.operator === "IS NOT NULL"; + const isBetween = condition.operator === "BETWEEN"; + const isBoolEq = category === "boolean" && condition.operator === "="; + + const handleColumnChange = (columnName: string) => { + const newColInfo = columns.find((c) => c.name === columnName); + const newType = newColInfo?.data_type ?? ""; + const newOps = getOperatorsForColumn(newType); + const newOperator = newOps.includes(condition.operator) + ? condition.operator + : newOps[0]; + onChange({ + ...condition, + column: columnName, + operator: newOperator, + value: "", + valueTo: "", + }); + }; + + return ( +
+ + + + + {!needsNoValue && ( + isBoolEq ? ( + + ) : isBetween ? ( + <> + onChange({ ...condition, value: e.target.value })} + /> + + + onChange({ ...condition, valueTo: e.target.value }) + } + /> + + ) : ( + onChange({ ...condition, value: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.form?.requestSubmit(); + } + }} + /> + ) + )} + + +
+ ); +} + +// --- Main component --- + +type FilterMode = "visual" | "sql"; + +export function FilterBuilder({ columns, onFilterChange, children }: FilterBuilderProps) { + const [mode, setMode] = useState("visual"); + const [conditions, setConditions] = useState([]); + const [conjunction, setConjunction] = useState("AND"); + const [rawFilter, setRawFilter] = useState(""); + + const handleAdd = useCallback(() => { + setConditions((prev) => [...prev, createCondition()]); + }, []); + + const handleRemove = useCallback((id: string) => { + setConditions((prev) => prev.filter((c) => c.id !== id)); + }, []); + + const handleChange = useCallback((updated: FilterCondition) => { + setConditions((prev) => + prev.map((c) => (c.id === updated.id ? updated : c)), + ); + }, []); + + const handleApplyVisual = useCallback(() => { + const clause = serializeConditions(conditions, conjunction, columns); + onFilterChange(clause); + }, [conditions, conjunction, columns, onFilterChange]); + + const handleApplyRaw = useCallback(() => { + let clause = rawFilter.trim(); + if (clause.toLowerCase().startsWith("where ")) { + clause = clause.slice(6).trim(); + } + onFilterChange(clause || undefined); + }, [rawFilter, onFilterChange]); + + const handleClear = useCallback(() => { + setConditions([]); + setRawFilter(""); + onFilterChange(undefined); + }, [onFilterChange]); + + const hasActiveFilter = + mode === "visual" ? conditions.length > 0 : rawFilter.trim() !== ""; + + return ( + <> + {/* Toolbar row */} +
+ {/* Mode toggle */} +
+ + +
+ + {/* Visual mode controls */} + {mode === "visual" && ( + <> + + {conditions.length > 0 && ( + <> + {conditions.length >= 2 && ( +
+ + +
+ )} + + + )} + + )} + + {/* SQL mode controls */} + {mode === "sql" && ( + <> + setRawFilter(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleApplyRaw()} + /> + + + )} + + {hasActiveFilter && ( + + )} + + {children} +
+ + {/* Visual mode: condition rows below the toolbar */} + {mode === "visual" && conditions.length > 0 && ( +
+ {conditions.map((c) => ( + handleRemove(c.id)} + /> + ))} +
+ )} + + ); +} diff --git a/src/components/table-viewer/TableDataView.tsx b/src/components/table-viewer/TableDataView.tsx index 9adf61c..e15417f 100644 --- a/src/components/table-viewer/TableDataView.tsx +++ b/src/components/table-viewer/TableDataView.tsx @@ -4,13 +4,13 @@ import { ResultsTable } from "@/components/results/ResultsTable"; import { ResultsJsonView } from "@/components/results/ResultsJsonView"; import { PaginationControls } from "./PaginationControls"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { updateRow as updateRowApi } from "@/lib/tauri"; import { getTableColumns } from "@/lib/tauri"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAppStore } from "@/stores/app-store"; import { toast } from "sonner"; -import { Save, RotateCcw, Filter, Loader2, Lock, Download, Plus, Table2, Braces } from "lucide-react"; +import { Save, RotateCcw, Loader2, Lock, Download, Plus, Table2, Braces } from "lucide-react"; +import { FilterBuilder } from "./FilterBuilder"; import { InsertRowDialog } from "./InsertRowDialog"; import { DropdownMenu, @@ -35,7 +35,6 @@ export function TableDataView({ connectionId, schema, table }: Props) { const [pageSize, setPageSize] = useState(50); const [sortColumn, setSortColumn] = useState(); const [sortDirection, setSortDirection] = useState(); - const [filter, setFilter] = useState(""); const [appliedFilter, setAppliedFilter] = useState(); const [pendingChanges, setPendingChanges] = useState< Map @@ -184,14 +183,24 @@ export function TableDataView({ connectionId, schema, table }: Props) { [] ); - const handleApplyFilter = () => { - setAppliedFilter(filter || undefined); - setPage(1); - }; - return (
-
+ ({ + name, + data_type: data.types?.[i] ?? "text", + is_nullable: true, + column_default: null, + ordinal_position: i, + character_maximum_length: null, + is_primary_key: false, + comment: null, + })) ?? []} + onFilterChange={(clause) => { + setAppliedFilter(clause); + setPage(1); + }} + > {isReadOnly && ( @@ -206,22 +215,6 @@ export function TableDataView({ connectionId, schema, table }: Props) { No PK — using ctid )} - - setFilter(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleApplyFilter()} - /> - {data && data.columns.length > 0 && ( <> @@ -310,7 +303,7 @@ export function TableDataView({ connectionId, schema, table }: Props) { )} -
+
{isLoading && !data ? (