diff --git a/src/components/export/ExportDialog.tsx b/src/components/export/ExportDialog.tsx new file mode 100644 index 0000000..7ef2784 --- /dev/null +++ b/src/components/export/ExportDialog.tsx @@ -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 ( + + + + Export Data + + +
+
+ + +
+
+ + + {rows.length.toLocaleString()} rows, {columns.length} columns + +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/table-viewer/PaginationControls.tsx b/src/components/table-viewer/PaginationControls.tsx new file mode 100644 index 0000000..2ed8a6a --- /dev/null +++ b/src/components/table-viewer/PaginationControls.tsx @@ -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 ( +
+ + Showing {totalRows > 0 ? from.toLocaleString() : 0}- + {to.toLocaleString()} of {totalRows.toLocaleString()} + + +
+ + +
+ + + + + {page} / {totalPages} + + + + +
+
+
+ ); +} diff --git a/src/components/table-viewer/TableDataView.tsx b/src/components/table-viewer/TableDataView.tsx new file mode 100644 index 0000000..104f7d5 --- /dev/null +++ b/src/components/table-viewer/TableDataView.tsx @@ -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(); + const [sortDirection, _setSortDirection] = useState(); + const [filter, setFilter] = useState(""); + const [appliedFilter, setAppliedFilter] = useState(); + const [pendingChanges, setPendingChanges] = useState< + Map + >(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 ( +
+
+ + setFilter(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleApplyFilter()} + /> + + {pendingChanges.size > 0 && ( + <> + + + + )} +
+ +
+ {isLoading && !data ? ( +
+ + Loading... +
+ ) : error ? ( +
+ {String(error)} +
+ ) : data ? ( + + ) : null} +
+ + {data && ( + { + setPageSize(size); + setPage(1); + }} + /> + )} +
+ ); +} diff --git a/src/components/table-viewer/TableStructure.tsx b/src/components/table-viewer/TableStructure.tsx new file mode 100644 index 0000000..dc7676e --- /dev/null +++ b/src/components/table-viewer/TableStructure.tsx @@ -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 ( + + + + Columns{columns ? ` (${columns.length})` : ""} + + + Constraints{constraints ? ` (${constraints.length})` : ""} + + + Indexes{indexes ? ` (${indexes.length})` : ""} + + + + + + + + + # + Name + Type + Nullable + Default + Key + + + + {columns?.map((col) => ( + + + {col.ordinal_position} + + + {col.name} + + + {col.data_type} + {col.character_maximum_length + ? `(${col.character_maximum_length})` + : ""} + + + {col.is_nullable ? "YES" : "NO"} + + + {col.column_default ?? "—"} + + + {col.is_primary_key && ( + + PK + + )} + + + ))} + +
+
+
+ + + + + + + Name + Type + Columns + + + + {constraints?.map((c) => ( + + + {c.name} + + + + {c.constraint_type} + + + + {c.columns.join(", ")} + + + ))} + +
+
+
+ + + + + + + Name + Definition + Unique + Primary + + + + {indexes?.map((idx) => ( + + + {idx.name} + + + {idx.definition} + + + {idx.is_unique ? "YES" : "NO"} + + + {idx.is_primary ? "YES" : "NO"} + + + ))} + +
+
+
+
+ ); +}