Files
tusk/src/components/table-viewer/TableDataView.tsx
Aleksey Shakhmatov 6b925d6260 style: apply rustfmt, fix clippy warnings, and minor code cleanup
Reformat Rust code with rustfmt, suppress clippy::too_many_arguments
for Tauri IPC commands, derive Default for AppSettings, fix unused
variable pattern in TableDataView, and add unit tests for utils.
2026-04-06 13:12:52 +03:00

376 lines
12 KiB
TypeScript

import { useState, useCallback, useMemo } from "react";
import { useTableData } from "@/hooks/use-table-data";
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 { InsertRowDialog } from "./InsertRowDialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { exportCsv, exportJson } from "@/lib/tauri";
import { save } from "@tauri-apps/plugin-dialog";
interface Props {
connectionId: string;
schema: string;
table: string;
}
export function TableDataView({ connectionId, schema, table }: Props) {
const readOnlyMap = useAppStore((s) => s.readOnlyMap);
const isReadOnly = readOnlyMap[connectionId] ?? true;
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 [insertDialogOpen, setInsertDialogOpen] = useState(false);
const [viewMode, setViewMode] = useState<"table" | "json">("table");
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) => {
if (isReadOnly) {
toast.warning("Read-only mode is active", {
description: "Switch to Read-Write mode to edit data.",
});
return;
}
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, isReadOnly]
);
const usesCtid = pkColumns.length === 0;
const handleCommit = async () => {
if (!data) return;
if (pkColumns.length === 0 && (!data.ctids || data.ctids.length === 0)) {
toast.error("Cannot save: no primary key and no ctid available");
return;
}
setIsSaving(true);
try {
for (const [, change] of pendingChanges) {
const row = data.rows[change.rowIndex];
const colName = data.columns[change.colIndex];
if (usesCtid) {
await updateRowApi({
connectionId,
schema,
table,
pkColumns: [],
pkValues: [],
column: colName,
value: change.value,
ctid: data.ctids[change.rowIndex],
});
} else {
const pkValues = pkColumns.map((pkCol) => {
const idx = data.columns.indexOf(pkCol);
return row[idx];
});
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 handleExport = useCallback(
async (format: "csv" | "json") => {
if (!data || data.columns.length === 0) return;
const ext = format === "csv" ? "csv" : "json";
const path = await save({
title: `Export as ${ext.toUpperCase()}`,
defaultPath: `${table}.${ext}`,
filters: [{ name: ext.toUpperCase(), extensions: [ext] }],
});
if (!path) return;
try {
if (format === "csv") {
await exportCsv(path, data.columns, data.rows as unknown[][]);
} else {
await exportJson(path, data.columns, data.rows as unknown[][]);
}
toast.success(`Exported to ${path}`);
} catch (err) {
toast.error("Export failed", { description: String(err) });
}
},
[data, table]
);
const handleSort = useCallback(
(column: string | undefined, direction: string | undefined) => {
setSortColumn(column);
setSortDirection(direction);
setPage(1);
},
[]
);
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">
{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">
<Lock className="h-3 w-3" />
Read-Only
</span>
)}
{!isReadOnly && usesCtid && (
<span
className="rounded bg-orange-500/10 px-1.5 py-0.5 text-[10px] font-medium text-orange-600 dark:text-orange-400"
title="This table has no primary key. Edits use physical row ID (ctid), which may change after VACUUM or concurrent writes."
>
No PK using ctid
</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 && (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 gap-1 text-xs"
>
<Download className="h-3 w-3" />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => handleExport("csv")}>
Export CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("json")}>
Export JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center rounded-md border text-xs">
<button
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
viewMode === "table"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setViewMode("table")}
title="Table view"
>
<Table2 className="h-3 w-3" />
Table
</button>
<button
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
viewMode === "json"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setViewMode("json")}
title="JSON view"
>
<Braces className="h-3 w-3" />
JSON
</button>
</div>
</>
)}
{!isReadOnly && (
<Button
size="sm"
variant="ghost"
className="h-6 gap-1 text-xs"
onClick={() => setInsertDialogOpen(true)}
>
<Plus className="h-3 w-3" />
Insert Row
</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 ? (
viewMode === "json" ? (
<ResultsJsonView
columns={data.columns}
rows={data.rows}
/>
) : (
<ResultsTable
columns={data.columns}
types={data.types}
rows={data.rows}
onCellDoubleClick={handleCellDoubleClick}
highlightedCells={highlightedCells}
externalSort={{
column: sortColumn,
direction: sortDirection,
onSort: handleSort,
}}
/>
)
) : null}
</div>
{data && (
<PaginationControls
page={data.page}
pageSize={data.page_size}
totalRows={data.total_rows}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
)}
<InsertRowDialog
open={insertDialogOpen}
onOpenChange={setInsertDialogOpen}
connectionId={connectionId}
schema={schema}
table={table}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: ["table-data", connectionId],
});
}}
/>
</div>
);
}