import { IconPlus, IconFilter, IconRefresh, IconTrash, IconAlertTriangle, IconCode, IconArrowBackUp, IconDeviceFloppy, IconDots, IconDownload, IconChevronLeft, IconChevronRight, IconChevronsLeft, IconChevronsRight, IconLoader2, IconCheck, IconKeyOff, } from "@tabler/icons-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { DbAdminDialect, DbAdminFilter, DbAdminForeignKey, DbAdminSort, DbAdminMutationResult, } from "../../db-admin/types.js"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "../components/ui/dropdown-menu.js"; import { Popover, PopoverContent, PopoverTrigger, } from "../components/ui/popover.js"; import { cn } from "../utils.js"; import { useChangeset, pkStringFor } from "./changeset.js"; import { DataGrid, type GridRow, type ActiveCell } from "./DataGrid.js"; import { downloadFile, toCSVTable } from "./export-utils.js"; import { FilterBar } from "./FilterBar.js"; import { RowSidePanel, type RowSidePanelMode } from "./RowSidePanel.js"; import { loadGridState, saveGridState } from "./storage.js"; import { useTableSchema, useTableRows, mutateTable, type DbAdminRequestConfig, } from "./useDbAdmin.js"; export interface TableEditorProps { table: string; dialect: DbAdminDialect; requestConfig?: DbAdminRequestConfig; initialFilters?: DbAdminFilter[]; onNavigateToRow: (table: string, filters: DbAdminFilter[]) => void; } const DEFAULT_PAGE_SIZE = 100; const PAGE_SIZES = [25, 50, 100, 250, 500]; function slugifyFilenamePart(value: string): string { const slug = value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); return slug || "table"; } export function TableEditor({ table, dialect: _dialect, requestConfig, initialFilters, onNavigateToRow, }: TableEditorProps) { // ─── Persisted view state ────────────────────────────────────────────── const persisted = useMemo(() => loadGridState(table), [table]); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState( persisted.pageSize ?? DEFAULT_PAGE_SIZE, ); const [sort, setSort] = useState(persisted.sort ?? []); const [filters, setFilters] = useState( initialFilters ?? persisted.filters ?? [], ); const [columnWidths, setColumnWidths] = useState>( persisted.columnWidths ?? {}, ); const [selectedPks, setSelectedPks] = useState>(new Set()); const [active, setActive] = useState(null); // Reset transient state when switching tables. useEffect(() => { const p = loadGridState(table); setPage(1); setPageSize(p.pageSize ?? DEFAULT_PAGE_SIZE); setSort(p.sort ?? []); setFilters(initialFilters ?? p.filters ?? []); setColumnWidths(p.columnWidths ?? {}); setSelectedPks(new Set()); setActive(null); // eslint-disable-next-line react-hooks/exhaustive-deps }, [table]); // Persist view state. useEffect(() => { saveGridState(table, { columnWidths, sort, filters, pageSize }); }, [table, columnWidths, sort, filters, pageSize]); // ─── Data ──────────────────────────────────────────────────────────────── const schemaState = useTableSchema(table, requestConfig); const rowsReq = useMemo( () => ({ page, pageSize, sort, filters }), [page, pageSize, sort, filters], ); const rowsState = useTableRows(table, rowsReq, requestConfig); const schema = schemaState.data; const changeset = useChangeset(schema); // ─── Toolbar UI state ────────────────────────────────────────────────── const [panel, setPanel] = useState<{ mode: RowSidePanelMode; pk?: string; } | null>(null); const [confirmDelete, setConfirmDelete] = useState(false); const [previewSql, setPreviewSql] = useState(null); const [committing, setCommitting] = useState(false); const [commitError, setCommitError] = useState(null); const [toast, setToast] = useState(null); const showToast = useCallback((msg: string) => { setToast(msg); window.setTimeout(() => setToast(null), 2500); }, []); // ─── Original rows map (pk → fetched row) ────────────────────────────── const originalRows = useMemo(() => { const map = new Map>(); for (const row of rowsState.data?.rows ?? []) { map.set(pkStringFor(schema, row), row); } return map; }, [rowsState.data, schema]); // ─── Build grid rows with staged edits applied ────────────────────────── const gridRows = useMemo(() => { const out: GridRow[] = []; // New rows first (top of grid, like Supabase). for (const nr of changeset.newRows) { out.push({ pk: `new:${nr._localId}`, localId: nr._localId, isNew: true, values: { ...nr.values }, }); } for (const row of rowsState.data?.rows ?? []) { const pk = pkStringFor(schema, row); const staged = changeset.edits.get(pk); out.push({ pk, values: staged ? { ...row, ...staged } : row, isDeleted: changeset.deletedKeys.has(pk), }); } return out; }, [ rowsState.data, schema, changeset.newRows, changeset.edits, changeset.deletedKeys, ]); // ─── Cell commit routing (existing rows vs new rows) ──────────────────── const onCellCommit = useCallback( (row: GridRow, col: string, value: unknown) => { if (row.isNew && row.localId) { changeset.setNewRowCell(row.localId, col, value); } else { changeset.setCell(row.pk, col, value); } }, [changeset], ); const isCellDirty = useCallback( (row: GridRow, col: string) => { if (row.isNew) return true; return changeset.isCellDirty(row.pk, col); }, [changeset], ); const onToggleDelete = useCallback( (row: GridRow) => { if (row.isNew && row.localId) { changeset.removeNewRow(row.localId); return; } changeset.deleteRows([row.pk]); }, [changeset], ); // ─── FK navigation ─────────────────────────────────────────────────────── const onFkNavigate = useCallback( (fk: DbAdminForeignKey, value: unknown) => { onNavigateToRow(fk.refTable, [{ column: fk.refColumn, op: "eq", value }]); }, [onNavigateToRow], ); // ─── Commit / preview ────────────────────────────────────────────────── const runCommit = useCallback( async (dryRun: boolean) => { const mutation = changeset.buildMutation(originalRows, dryRun); if (!mutation.inserts && !mutation.updates && !mutation.deletes) { return null; } return mutateTable(table, mutation, requestConfig); }, [changeset, originalRows, requestConfig, table], ); const handlePreview = useCallback(async () => { setCommitError(null); try { const res = await runCommit(true); setPreviewSql(res ? res.sql : []); } catch (err) { setCommitError(err instanceof Error ? err.message : String(err)); } }, [runCommit]); const handleCommit = useCallback(async () => { const hasDeletes = changeset.deletedKeys.size > 0; if (hasDeletes && !confirmDelete) { setConfirmDelete(true); return; } setCommitting(true); setCommitError(null); try { const res: DbAdminMutationResult | null = await runCommit(false); changeset.discardAll(); setSelectedPks(new Set()); setConfirmDelete(false); rowsState.refetch(); schemaState.refetch(); if (res) { const parts: string[] = []; if (res.inserted) parts.push(`${res.inserted} inserted`); if (res.updated) parts.push(`${res.updated} updated`); if (res.deleted) parts.push(`${res.deleted} deleted`); showToast(parts.length ? parts.join(", ") : "No changes"); } } catch (err) { setCommitError(err instanceof Error ? err.message : String(err)); } finally { setCommitting(false); } }, [changeset, confirmDelete, runCommit, rowsState, schemaState, showToast]); // ─── Cmd/Ctrl+S to commit ────────────────────────────────────────────── const commitRef = useRef(handleCommit); commitRef.current = handleCommit; useEffect(() => { const onKey = (e: KeyboardEvent) => { if ( (e.metaKey || e.ctrlKey) && typeof e.key === "string" && e.key.toLowerCase() === "s" ) { e.preventDefault(); if (changeset.isDirty) void commitRef.current(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [changeset.isDirty]); // ─── Bulk delete from selection ──────────────────────────────────────── const onBulkDelete = useCallback(() => { const pks = [...selectedPks].filter((pk) => !pk.startsWith("new:")); changeset.deleteRows(pks); setSelectedPks(new Set()); }, [selectedPks, changeset]); // ─── Render ────────────────────────────────────────────────────────────── const total = rowsState.data?.total ?? schema?.rowCount ?? 0; const totalPages = Math.max(1, Math.ceil(total / pageSize)); if (schemaState.error) { return (
{schemaState.error.message}
); } if (!schema) { return (
Loading {table}…
); } const noPk = schema.primaryKey.length === 0; const exportCurrentPageCsv = () => { const columns = schema.columns.map((column) => column.name); const stamp = new Date().toISOString().slice(0, 10); downloadFile( `${slugifyFilenamePart(table)}-${stamp}.csv`, "text/csv;charset=utf-8", toCSVTable( columns, gridRows .filter((row) => !row.isDeleted) .map((row) => columns.map((column) => row.values[column])), ), ); }; return (
{/* Toolbar */}
{table} {total.toLocaleString()} rows {schema.type === "view" && ( view )}
} label="Refresh" onClick={() => { rowsState.refetch(); schemaState.refetch(); }} /> {!noPk && schema.type === "table" && ( } label="Insert" onClick={() => setPanel({ mode: "insert" })} primary /> )} Download CSV
{/* Filters */}
{ setFilters(f); setPage(1); }} />
{/* No-PK warning */} {noPk && schema.type === "table" && (
This table has no primary key — editing and row deletion are disabled.
)} {/* Selection / changeset bar */} {(selectedPks.size > 0 || changeset.isDirty) && (
{selectedPks.size > 0 && ( <> {selectedPks.size} selected {!noPk && ( )} )} {changeset.isDirty && ( <> {changeset.pendingCount} pending change {changeset.pendingCount === 1 ? "" : "s"} setPreviewSql(null)} error={commitError} /> )}
)} {commitError && !previewSql && (
{commitError}
)}
{/* Grid */} { setSort(s); setPage(1); }} selectedPks={selectedPks} onSelectionChange={setSelectedPks} columnWidths={columnWidths} onColumnWidthsChange={setColumnWidths} active={active} onActiveChange={setActive} editable={!noPk && schema.type === "table"} onCellCommit={onCellCommit} isCellDirty={isCellDirty} onToggleDelete={onToggleDelete} onNavigateToRow={onFkNavigate} /> {/* Pagination footer */}
Rows per page
Page {page} of {totalPages} setPage(1)} icon={} /> setPage((p) => Math.max(1, p - 1))} icon={} /> = totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))} icon={} /> = totalPages} onClick={() => setPage(totalPages)} icon={} />
{/* Row side panel */} {panel && ( setPanel(null)} onSave={(values) => { if (panel.mode === "insert") { changeset.addRow(values); } else if (panel.pk) { changeset.setCells(panel.pk, values); } }} /> )} {/* Destructive commit confirmation */} {confirmDelete && ( setConfirmDelete(false)} onConfirm={() => void handleCommit()} /> )} {/* Toast */} {toast && typeof document !== "undefined" && createPortal(
{toast}
, document.body, )}
); } // ─── Small UI atoms ──────────────────────────────────────────────────────── function ToolbarButton({ icon, label, onClick, primary, }: { icon: React.ReactNode; label: string; onClick: () => void; primary?: boolean; }) { return ( ); } function PagerButton({ disabled, onClick, icon, }: { disabled: boolean; onClick: () => void; icon: React.ReactNode; }) { return ( ); } function PreviewSqlButton({ onPreview, sql, onClear, error, }: { onPreview: () => void; sql: string[] | null; onClear: () => void; error: string | null; }) { return ( { if (!o) onClear(); }} >
Preview SQL
{error ? (
{error}
) : sql && sql.length > 0 ? (
              {sql.join(";\n")}
            
) : (
No statements to run.
)}
); } function ConfirmModal({ title, body, confirmLabel, destructive, onCancel, onConfirm, }: { title: string; body: string; confirmLabel: string; destructive?: boolean; onCancel: () => void; onConfirm: () => void; }) { useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onCancel]); if (typeof document === "undefined") return null; return createPortal(
{destructive && ( )}

{title}

{body}

, document.body, ); }