feat: add table data viewer, structure inspector, and export
Add TableDataView with pagination, filtering, and inline editing, TableStructure with columns/constraints/indexes tabs, PaginationControls, and ExportDialog for CSV/JSON export. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
113
src/components/export/ExportDialog.tsx
Normal file
113
src/components/export/ExportDialog.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { exportCsv, exportJson } from "@/lib/tauri";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
columns: string[];
|
||||||
|
rows: unknown[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportDialog({ open, onOpenChange, columns, rows }: Props) {
|
||||||
|
const [format, setFormat] = useState<"csv" | "json">("csv");
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
const ext = format === "csv" ? "csv" : "json";
|
||||||
|
const path = await save({
|
||||||
|
defaultPath: `export.${ext}`,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: format.toUpperCase(),
|
||||||
|
extensions: [ext],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!path) return;
|
||||||
|
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
if (format === "csv") {
|
||||||
|
await exportCsv(path, columns, rows);
|
||||||
|
} else {
|
||||||
|
await exportJson(path, columns, rows);
|
||||||
|
}
|
||||||
|
toast.success(`Exported ${rows.length} rows to ${ext.toUpperCase()}`);
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Export failed", { description: String(err) });
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[360px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Export Data</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-3 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-3">
|
||||||
|
<label className="text-right text-sm text-muted-foreground">
|
||||||
|
Format
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={format}
|
||||||
|
onValueChange={(v) => setFormat(v as "csv" | "json")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="csv">CSV</SelectItem>
|
||||||
|
<SelectItem value="json">JSON</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-3">
|
||||||
|
<label className="text-right text-sm text-muted-foreground">
|
||||||
|
Rows
|
||||||
|
</label>
|
||||||
|
<span className="col-span-3 text-sm">
|
||||||
|
{rows.length.toLocaleString()} rows, {columns.length} columns
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleExport} disabled={isExporting}>
|
||||||
|
{isExporting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/components/table-viewer/PaginationControls.tsx
Normal file
105
src/components/table-viewer/PaginationControls.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronsRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalRows: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (size: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationControls({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalRows,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: Props) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
|
||||||
|
const from = (page - 1) * pageSize + 1;
|
||||||
|
const to = Math.min(page * pageSize, totalRows);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between border-t px-3 py-1.5">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Showing {totalRows > 0 ? from.toLocaleString() : 0}-
|
||||||
|
{to.toLocaleString()} of {totalRows.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onValueChange={(v) => onPageSizeChange(Number(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-[70px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[25, 50, 100, 500].map((size) => (
|
||||||
|
<SelectItem key={size} value={String(size)}>
|
||||||
|
{size}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => onPageChange(1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
<ChevronsLeft className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="px-2 text-xs text-muted-foreground">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => onPageChange(totalPages)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>
|
||||||
|
<ChevronsRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
src/components/table-viewer/TableDataView.tsx
Normal file
204
src/components/table-viewer/TableDataView.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { useState, useCallback, useMemo } from "react";
|
||||||
|
import { useTableData } from "@/hooks/use-table-data";
|
||||||
|
import { ResultsTable } from "@/components/results/ResultsTable";
|
||||||
|
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 { toast } from "sonner";
|
||||||
|
import { Save, RotateCcw, Filter, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
connectionId: string;
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableDataView({ connectionId, schema, table }: Props) {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(50);
|
||||||
|
const [sortColumn, _setSortColumn] = useState<string | undefined>();
|
||||||
|
const [sortDirection, _setSortDirection] = useState<string | undefined>();
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [appliedFilter, setAppliedFilter] = useState<string | undefined>();
|
||||||
|
const [pendingChanges, setPendingChanges] = useState<
|
||||||
|
Map<string, { rowIndex: number; colIndex: number; value: string | null }>
|
||||||
|
>(new Map());
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useTableData({
|
||||||
|
connectionId,
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
filter: appliedFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: columnsInfo } = useQuery({
|
||||||
|
queryKey: ["table-columns", connectionId, schema, table],
|
||||||
|
queryFn: () => getTableColumns(connectionId, schema, table),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pkColumns = useMemo(
|
||||||
|
() => columnsInfo?.filter((c) => c.is_primary_key).map((c) => c.name) ?? [],
|
||||||
|
[columnsInfo]
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlightedCells = useMemo(
|
||||||
|
() => new Set(pendingChanges.keys()),
|
||||||
|
[pendingChanges]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCellDoubleClick = useCallback(
|
||||||
|
(rowIndex: number, colIndex: number, value: unknown) => {
|
||||||
|
const key = `${rowIndex}:${colIndex}`;
|
||||||
|
const currentValue = pendingChanges.get(key)?.value ?? value;
|
||||||
|
const newVal = prompt("Edit value (leave empty and click NULL for null):",
|
||||||
|
currentValue === null ? "" : String(currentValue));
|
||||||
|
if (newVal !== null) {
|
||||||
|
setPendingChanges((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(key, { rowIndex, colIndex, value: newVal === "" ? null : newVal });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pendingChanges]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCommit = async () => {
|
||||||
|
if (!data || pkColumns.length === 0) {
|
||||||
|
toast.error("Cannot save: no primary key detected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
for (const [_key, change] of pendingChanges) {
|
||||||
|
const row = data.rows[change.rowIndex];
|
||||||
|
const pkValues = pkColumns.map((pkCol) => {
|
||||||
|
const idx = data.columns.indexOf(pkCol);
|
||||||
|
return row[idx];
|
||||||
|
});
|
||||||
|
const colName = data.columns[change.colIndex];
|
||||||
|
|
||||||
|
await updateRowApi({
|
||||||
|
connectionId,
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
pkColumns,
|
||||||
|
pkValues: pkValues as unknown[],
|
||||||
|
column: colName,
|
||||||
|
value: change.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setPendingChanges(new Map());
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["table-data", connectionId],
|
||||||
|
});
|
||||||
|
toast.success(`${pendingChanges.size} change(s) saved`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Save failed", { description: String(err) });
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRollback = () => {
|
||||||
|
setPendingChanges(new Map());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyFilter = () => {
|
||||||
|
setAppliedFilter(filter || undefined);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center gap-2 border-b px-2 py-1">
|
||||||
|
<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>
|
||||||
|
{pendingChanges.size > 0 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
className="h-6 gap-1 text-xs"
|
||||||
|
onClick={handleCommit}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Commit ({pendingChanges.size})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 gap-1 text-xs"
|
||||||
|
onClick={handleRollback}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
Rollback
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{isLoading && !data ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-4 text-sm text-destructive">
|
||||||
|
{String(error)}
|
||||||
|
</div>
|
||||||
|
) : data ? (
|
||||||
|
<ResultsTable
|
||||||
|
columns={data.columns}
|
||||||
|
types={data.types}
|
||||||
|
rows={data.rows}
|
||||||
|
onCellDoubleClick={handleCellDoubleClick}
|
||||||
|
highlightedCells={highlightedCells}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<PaginationControls
|
||||||
|
page={data.page}
|
||||||
|
pageSize={data.page_size}
|
||||||
|
totalRows={data.total_rows}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
src/components/table-viewer/TableStructure.tsx
Normal file
168
src/components/table-viewer/TableStructure.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getTableColumns,
|
||||||
|
getTableConstraints,
|
||||||
|
getTableIndexes,
|
||||||
|
} from "@/lib/tauri";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
connectionId: string;
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableStructure({ connectionId, schema, table }: Props) {
|
||||||
|
const { data: columns } = useQuery({
|
||||||
|
queryKey: ["table-columns", connectionId, schema, table],
|
||||||
|
queryFn: () => getTableColumns(connectionId, schema, table),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: constraints } = useQuery({
|
||||||
|
queryKey: ["table-constraints", connectionId, schema, table],
|
||||||
|
queryFn: () => getTableConstraints(connectionId, schema, table),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: indexes } = useQuery({
|
||||||
|
queryKey: ["table-indexes", connectionId, schema, table],
|
||||||
|
queryFn: () => getTableIndexes(connectionId, schema, table),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="columns" className="flex h-full flex-col">
|
||||||
|
<TabsList className="mx-2 mt-2 w-fit">
|
||||||
|
<TabsTrigger value="columns" className="text-xs">
|
||||||
|
Columns{columns ? ` (${columns.length})` : ""}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="constraints" className="text-xs">
|
||||||
|
Constraints{constraints ? ` (${constraints.length})` : ""}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="indexes" className="text-xs">
|
||||||
|
Indexes{indexes ? ` (${indexes.length})` : ""}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="columns" className="flex-1 overflow-hidden mt-0">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="text-xs">#</TableHead>
|
||||||
|
<TableHead className="text-xs">Name</TableHead>
|
||||||
|
<TableHead className="text-xs">Type</TableHead>
|
||||||
|
<TableHead className="text-xs">Nullable</TableHead>
|
||||||
|
<TableHead className="text-xs">Default</TableHead>
|
||||||
|
<TableHead className="text-xs">Key</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{columns?.map((col) => (
|
||||||
|
<TableRow key={col.name}>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{col.ordinal_position}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs font-medium">
|
||||||
|
{col.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{col.data_type}
|
||||||
|
{col.character_maximum_length
|
||||||
|
? `(${col.character_maximum_length})`
|
||||||
|
: ""}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{col.is_nullable ? "YES" : "NO"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate text-xs text-muted-foreground">
|
||||||
|
{col.column_default ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{col.is_primary_key && (
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
PK
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="constraints" className="flex-1 overflow-hidden mt-0">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="text-xs">Name</TableHead>
|
||||||
|
<TableHead className="text-xs">Type</TableHead>
|
||||||
|
<TableHead className="text-xs">Columns</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{constraints?.map((c) => (
|
||||||
|
<TableRow key={c.name}>
|
||||||
|
<TableCell className="text-xs font-medium">
|
||||||
|
{c.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{c.constraint_type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{c.columns.join(", ")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="indexes" className="flex-1 overflow-hidden mt-0">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="text-xs">Name</TableHead>
|
||||||
|
<TableHead className="text-xs">Definition</TableHead>
|
||||||
|
<TableHead className="text-xs">Unique</TableHead>
|
||||||
|
<TableHead className="text-xs">Primary</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{indexes?.map((idx) => (
|
||||||
|
<TableRow key={idx.name}>
|
||||||
|
<TableCell className="text-xs font-medium">
|
||||||
|
{idx.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[400px] truncate text-xs text-muted-foreground">
|
||||||
|
{idx.definition}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{idx.is_unique ? "YES" : "NO"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{idx.is_primary ? "YES" : "NO"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user