Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d431352816 |
@@ -1,549 +0,0 @@
|
|||||||
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<string, TypeCategory> = {
|
|
||||||
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<TypeCategory, string[]> = {
|
|
||||||
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 (
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
|
||||||
className="h-6 w-[130px] justify-between px-2 text-xs font-normal"
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{selected ? selected.name : "Column..."}
|
|
||||||
</span>
|
|
||||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[220px] p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search column..." className="text-xs" />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No column found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{columns.map((col) => (
|
|
||||||
<CommandItem
|
|
||||||
key={col.name}
|
|
||||||
value={col.name}
|
|
||||||
onSelect={(v) => {
|
|
||||||
onSelect(v);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-1 h-3 w-3",
|
|
||||||
value === col.name ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{col.name}</span>
|
|
||||||
<span className="ml-auto text-[10px] text-muted-foreground">
|
|
||||||
{col.data_type}
|
|
||||||
</span>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<ColumnCombobox
|
|
||||||
columns={columns}
|
|
||||||
value={condition.column}
|
|
||||||
onSelect={handleColumnChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={condition.operator}
|
|
||||||
onValueChange={(op) =>
|
|
||||||
onChange({ ...condition, operator: op, value: "", valueTo: "" })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-6 w-[100px] px-2 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{operators.map((op) => (
|
|
||||||
<SelectItem key={op} value={op} className="text-xs">
|
|
||||||
{op}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{!needsNoValue && (
|
|
||||||
isBoolEq ? (
|
|
||||||
<Select
|
|
||||||
value={condition.value}
|
|
||||||
onValueChange={(v) => onChange({ ...condition, value: v })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-6 w-[80px] px-2 text-xs">
|
|
||||||
<SelectValue placeholder="value" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="true" className="text-xs">
|
|
||||||
true
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="false" className="text-xs">
|
|
||||||
false
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : isBetween ? (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
className="h-6 w-[80px] text-xs"
|
|
||||||
placeholder="from"
|
|
||||||
value={condition.value}
|
|
||||||
onChange={(e) => onChange({ ...condition, value: e.target.value })}
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] text-muted-foreground">—</span>
|
|
||||||
<Input
|
|
||||||
className="h-6 w-[80px] text-xs"
|
|
||||||
placeholder="to"
|
|
||||||
value={condition.valueTo}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange({ ...condition, valueTo: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
className="h-6 w-[120px] text-xs"
|
|
||||||
placeholder={
|
|
||||||
condition.operator === "IN" ? "val1, val2, ..." : "value"
|
|
||||||
}
|
|
||||||
value={condition.value}
|
|
||||||
onChange={(e) => onChange({ ...condition, value: e.target.value })}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.currentTarget.form?.requestSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={onRemove}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Main component ---
|
|
||||||
|
|
||||||
type FilterMode = "visual" | "sql";
|
|
||||||
|
|
||||||
export function FilterBuilder({ columns, onFilterChange, children }: FilterBuilderProps) {
|
|
||||||
const [mode, setMode] = useState<FilterMode>("visual");
|
|
||||||
const [conditions, setConditions] = useState<FilterCondition[]>([]);
|
|
||||||
const [conjunction, setConjunction] = useState<Conjunction>("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 */}
|
|
||||||
<div className="flex items-center gap-2 border-b px-2 py-1">
|
|
||||||
{/* Mode toggle */}
|
|
||||||
<div className="flex items-center rounded-md border text-xs">
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1 px-1.5 py-0.5 font-medium",
|
|
||||||
mode === "visual"
|
|
||||||
? "bg-muted text-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
onClick={() => setMode("visual")}
|
|
||||||
title="Visual filter builder"
|
|
||||||
>
|
|
||||||
<SlidersHorizontal className="h-3 w-3" />
|
|
||||||
Filter
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1 px-1.5 py-0.5 font-medium",
|
|
||||||
mode === "sql"
|
|
||||||
? "bg-muted text-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
onClick={() => setMode("sql")}
|
|
||||||
title="Raw SQL WHERE clause"
|
|
||||||
>
|
|
||||||
<Code className="h-3 w-3" />
|
|
||||||
SQL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Visual mode controls */}
|
|
||||||
{mode === "visual" && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 gap-1 text-xs"
|
|
||||||
onClick={handleAdd}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
{conditions.length === 0 ? "Add filter" : "Add"}
|
|
||||||
</Button>
|
|
||||||
{conditions.length > 0 && (
|
|
||||||
<>
|
|
||||||
{conditions.length >= 2 && (
|
|
||||||
<div className="flex items-center rounded-md border text-[10px]">
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"px-1.5 py-0.5 font-medium",
|
|
||||||
conjunction === "AND"
|
|
||||||
? "bg-muted text-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
onClick={() => setConjunction("AND")}
|
|
||||||
>
|
|
||||||
AND
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"px-1.5 py-0.5 font-medium",
|
|
||||||
conjunction === "OR"
|
|
||||||
? "bg-muted text-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
onClick={() => setConjunction("OR")}
|
|
||||||
>
|
|
||||||
OR
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 text-xs"
|
|
||||||
onClick={handleApplyVisual}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* SQL mode controls */}
|
|
||||||
{mode === "sql" && (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
placeholder="e.g. id > 10 AND status = 'active'"
|
|
||||||
className="h-6 max-w-md flex-1 text-xs"
|
|
||||||
value={rawFilter}
|
|
||||||
onChange={(e) => setRawFilter(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleApplyRaw()}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 text-xs"
|
|
||||||
onClick={handleApplyRaw}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasActiveFilter && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 text-xs text-muted-foreground"
|
|
||||||
onClick={handleClear}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Visual mode: condition rows below the toolbar */}
|
|
||||||
{mode === "visual" && conditions.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-1 border-b bg-muted/30 px-2 py-1.5">
|
|
||||||
{conditions.map((c) => (
|
|
||||||
<FilterConditionRow
|
|
||||||
key={c.id}
|
|
||||||
condition={c}
|
|
||||||
columns={columns}
|
|
||||||
onChange={handleChange}
|
|
||||||
onRemove={() => handleRemove(c.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,13 +4,13 @@ import { ResultsTable } from "@/components/results/ResultsTable";
|
|||||||
import { ResultsJsonView } from "@/components/results/ResultsJsonView";
|
import { ResultsJsonView } from "@/components/results/ResultsJsonView";
|
||||||
import { PaginationControls } from "./PaginationControls";
|
import { PaginationControls } from "./PaginationControls";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { updateRow as updateRowApi } from "@/lib/tauri";
|
import { updateRow as updateRowApi } from "@/lib/tauri";
|
||||||
import { getTableColumns } from "@/lib/tauri";
|
import { getTableColumns } from "@/lib/tauri";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Save, RotateCcw, Loader2, Lock, Download, Plus, Table2, Braces } from "lucide-react";
|
import { Save, RotateCcw, Filter, Loader2, Lock, Download, Plus, Table2, Braces } from "lucide-react";
|
||||||
import { FilterBuilder } from "./FilterBuilder";
|
|
||||||
import { InsertRowDialog } from "./InsertRowDialog";
|
import { InsertRowDialog } from "./InsertRowDialog";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -35,6 +35,7 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
const [pageSize, setPageSize] = useState(50);
|
const [pageSize, setPageSize] = useState(50);
|
||||||
const [sortColumn, setSortColumn] = useState<string | undefined>();
|
const [sortColumn, setSortColumn] = useState<string | undefined>();
|
||||||
const [sortDirection, setSortDirection] = useState<string | undefined>();
|
const [sortDirection, setSortDirection] = useState<string | undefined>();
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
const [appliedFilter, setAppliedFilter] = useState<string | undefined>();
|
const [appliedFilter, setAppliedFilter] = useState<string | undefined>();
|
||||||
const [pendingChanges, setPendingChanges] = useState<
|
const [pendingChanges, setPendingChanges] = useState<
|
||||||
Map<string, { rowIndex: number; colIndex: number; value: string | null }>
|
Map<string, { rowIndex: number; colIndex: number; value: string | null }>
|
||||||
@@ -183,24 +184,14 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleApplyFilter = () => {
|
||||||
|
setAppliedFilter(filter || undefined);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<FilterBuilder
|
<div className="flex items-center gap-2 border-b px-2 py-1">
|
||||||
columns={columnsInfo ?? data?.columns.map((name, i) => ({
|
|
||||||
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 && (
|
{isReadOnly && (
|
||||||
<span className="flex items-center gap-1 rounded bg-yellow-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-yellow-600 dark:text-yellow-500">
|
<span className="flex items-center gap-1 rounded bg-yellow-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-yellow-600 dark:text-yellow-500">
|
||||||
<Lock className="h-3 w-3" />
|
<Lock className="h-3 w-3" />
|
||||||
@@ -215,6 +206,22 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
No PK — using ctid
|
No PK — using ctid
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="WHERE clause (e.g. id > 10)"
|
||||||
|
className="h-6 flex-1 text-xs"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleApplyFilter()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
onClick={handleApplyFilter}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
{data && data.columns.length > 0 && (
|
{data && data.columns.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -303,7 +310,7 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</FilterBuilder>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{isLoading && !data ? (
|
{isLoading && !data ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user