v0.6.0: release workflow, constraint-aware cell editing, pending-cell styling (#9)
* feat: demo DB seed/regenerate, backup/restore/sync refinements, SSH/SSL polish - Demo SQLite DB: feature-rich seed (12 objects, 500-row audit log) + regenerate action in Settings - Backup/restore: headless-testable core logic, psql -f for plain dumps, sync passes --clean --if-exists - SSH/SSL runtime refinements and connection testing improvements - Data grid: DataTypeIcon component, FK popover, table tree polish - Docs: AGENTS.md/README updates, new screenshots, MIT LICENSE * chore: optimize README screenshots (4.7 MB → 916 KB via pngquant, quality 70-90) * v0.6.0: release workflow, constraint-aware cell editing, pending-cell styling - Bump version to 0.6.0 across package.json, Cargo.toml, tauri.conf.json - Add .github/workflows/release.yml: tag-triggered CI builds macOS (aarch64 + x64), Windows, and Linux installers into a draft GitHub Release - README: installer download table (unsigned note, per-platform files), "how releases are made" section - CellEditor: constraint-aware commit — empty input on nullable columns becomes NULL, NOT NULL text-like types fall back to empty string, all other types blocked with an inline error bubble; replace "Set NULL" checkbox with a NULL row in the FK dropdown / empty enum option - VirtualDataGrid: pending-edit dot → animated pending outline (ring) on staged cells; matching test updates - docs-coverage test: align with rewritten README comparison table
This commit is contained in:
@@ -148,12 +148,12 @@ describe("DbViewerScreen", () => {
|
||||
const staged = useDbViewerStore.getState().changesQueue[0];
|
||||
expect(staged?.table).toBe("users");
|
||||
expect(staged?.schema).toBe("public");
|
||||
// grid cell shows the optimistic value + the pending dot (2nd match is the queue panel diff)
|
||||
// grid cell shows the optimistic value + the pending outline (2nd match is the queue panel diff)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Alicia").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
|
||||
// committing the change clears the pending dot but keeps the value until refetch
|
||||
expect(screen.getByTestId("pending-cell")).toBeInTheDocument();
|
||||
// committing the change clears the pending outline but keeps the value until refetch
|
||||
act(() => {
|
||||
useDbViewerStore
|
||||
.getState()
|
||||
@@ -162,7 +162,7 @@ describe("DbViewerScreen", () => {
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
|
||||
expect(screen.queryByTestId("pending-cell")).toBeNull();
|
||||
});
|
||||
expect(screen.getAllByText("Alicia").length).toBeGreaterThanOrEqual(2);
|
||||
// clearing the queue clears the optimistic display
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState, Suspense, lazy } from "react";
|
||||
import { ChevronDown, ChevronUp, Table2, Terminal } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Table2, Terminal, AlertCircle } from "lucide-react";
|
||||
import { format as formatSql } from "sql-formatter";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
import { DbViewerSidebar } from "./DbViewerSidebar";
|
||||
@@ -217,6 +217,19 @@ export function DbViewerScreen({
|
||||
t.table_type === "MATERIALIZED VIEW",
|
||||
)
|
||||
: false;
|
||||
// Regular views (SQLite + PG) are read-only like matviews: they expose no
|
||||
// row locator (no ctid/rowid) and cannot be modified via SQLite, so hide
|
||||
// all data-modifying affordances for them as well.
|
||||
const isView =
|
||||
activeTab && activeTab.tabType === "table"
|
||||
? tables.some(
|
||||
(t) =>
|
||||
t.schema === activeTab.schema &&
|
||||
t.name === activeTab.table &&
|
||||
t.table_type === "VIEW",
|
||||
)
|
||||
: false;
|
||||
const readOnlyTable = isMatview || isView;
|
||||
|
||||
const setTabData = useDbViewerStore((s) => s.setTabData);
|
||||
const setTabError = useDbViewerStore((s) => s.setTabError);
|
||||
@@ -1105,7 +1118,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
)
|
||||
}
|
||||
variant="query"
|
||||
isMatview={isMatview}
|
||||
isMatview={readOnlyTable}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
@@ -1120,9 +1133,9 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
dbType={currentConnection?.db_type ?? "postgresql"}
|
||||
tabType={activeTab?.tabType ?? "table"}
|
||||
getLocator={getLocator}
|
||||
onStageEdit={isMatview ? undefined : handleStageEdit}
|
||||
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
|
||||
onOpenRowDetail={handleOpenRowDetail}
|
||||
readOnly={isMatview}
|
||||
readOnly={readOnlyTable}
|
||||
enumValues={editorOptions?.enums}
|
||||
fkOptions={editorOptions?.fks}
|
||||
fkPlaceholders={editorOptions?.fkPlaceholders}
|
||||
@@ -1217,6 +1230,17 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
</Suspense>
|
||||
) : (
|
||||
<>
|
||||
{activeTab?.error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs text-red-400 border-b border-border bg-surface-raised"
|
||||
>
|
||||
<AlertCircle size={14} className="shrink-0" />
|
||||
<span className="truncate">
|
||||
{activeTab.error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{activeTab?.data && (
|
||||
<TableControls
|
||||
connectionId={connectionId}
|
||||
@@ -1258,7 +1282,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
onClearSelection={() =>
|
||||
setSelectedRows(new Set())
|
||||
}
|
||||
isMatview={isMatview}
|
||||
isMatview={readOnlyTable}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
@@ -1273,9 +1297,9 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
dbType={currentConnection?.db_type ?? "postgresql"}
|
||||
tabType={activeTab?.tabType ?? "table"}
|
||||
getLocator={getLocator}
|
||||
onStageEdit={isMatview ? undefined : handleStageEdit}
|
||||
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
|
||||
onOpenRowDetail={handleOpenRowDetail}
|
||||
readOnly={isMatview}
|
||||
readOnly={readOnlyTable}
|
||||
enumValues={editorOptions?.enums}
|
||||
fkOptions={editorOptions?.fks}
|
||||
fkPlaceholders={editorOptions?.fkPlaceholders}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { createPortal } from "react-dom";
|
||||
import { Key, X, ExternalLink, Loader2 } from "lucide-react";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { QueryResult } from "../../lib/types";
|
||||
import { abbreviateType } from "../../lib/utils";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { DataTypeIcon } from "../ui/DataTypeIcon";
|
||||
|
||||
interface FkPreviewPopoverProps {
|
||||
connectionId: string;
|
||||
@@ -168,7 +168,7 @@ export function FkPreviewPopover({
|
||||
</div>
|
||||
)}
|
||||
{data && data.rows.length > 0 && (
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-xs" style={{ tableLayout: "fixed" }}>
|
||||
<tbody>
|
||||
{data.columns.map((col, ci) => {
|
||||
const cell = data.rows[0][ci];
|
||||
@@ -178,20 +178,22 @@ export function FkPreviewPopover({
|
||||
key={col.name}
|
||||
className="border-b border-border last:border-0 hover:bg-surface/30"
|
||||
>
|
||||
<td className="px-3 py-1.5 text-text-muted font-heading whitespace-nowrap w-1/3">
|
||||
<div className="flex items-center gap-1">
|
||||
<td
|
||||
className="px-3 py-1.5 text-text-muted font-heading whitespace-nowrap overflow-hidden align-top"
|
||||
style={{ width: 100, maxWidth: 100 }}
|
||||
>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{col.is_pk && <Key size={9} className="text-accent shrink-0" />}
|
||||
{col.is_fk && <Key size={9} className="text-amber-400 shrink-0" />}
|
||||
<span className="truncate">{col.name}</span>
|
||||
<span
|
||||
className="text-[10px] text-text-muted/50 shrink-0"
|
||||
title={col.data_type}
|
||||
>
|
||||
{abbreviateType(col.data_type)}
|
||||
</span>
|
||||
<DataTypeIcon
|
||||
dataType={col.data_type}
|
||||
size={9}
|
||||
className="text-text-muted/60 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-text">
|
||||
<td className="px-3 py-1.5 text-text align-top break-all">
|
||||
{isNull ? (
|
||||
<span className="italic text-text-muted">NULL</span>
|
||||
) : (
|
||||
|
||||
@@ -239,14 +239,21 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
</div>
|
||||
|
||||
{/* Clean toggle */}
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<label
|
||||
className={`flex items-center gap-2.5 cursor-pointer group ${
|
||||
format === "plain"
|
||||
? "opacity-40 pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clean}
|
||||
onChange={(e) =>
|
||||
setClean(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
disabled={format === "plain"}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Clean{" "}
|
||||
@@ -255,6 +262,13 @@ export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
{format === "plain" && (
|
||||
<p className="text-[11px] text-text-muted/70 -mt-3">
|
||||
Plain SQL restores run via psql and don't
|
||||
support DROP-before-CREATE. Use Custom
|
||||
Archive for clean restores.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Destructive confirmation */}
|
||||
|
||||
@@ -83,6 +83,34 @@ describe("TabBar", () => {
|
||||
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a view icon on view tabs", () => {
|
||||
useDbViewerStore.getState().openTab("main", "order_summary");
|
||||
useDbViewerStore.setState({
|
||||
tables: [
|
||||
{ name: "order_summary", schema: "main", table_type: "VIEW" },
|
||||
],
|
||||
});
|
||||
render(<TabBar />);
|
||||
expect(screen.getByTestId("tab-icon-view")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a layers icon on materialized view tabs", () => {
|
||||
useDbViewerStore.getState().openTab("public", "mv_products");
|
||||
useDbViewerStore.setState({
|
||||
tables: [
|
||||
{
|
||||
name: "mv_products",
|
||||
schema: "public",
|
||||
table_type: "MATERIALIZED VIEW" as any,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<TabBar />);
|
||||
expect(screen.getByTestId("tab-icon-matview")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the changes count as an icon with a badge", () => {
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "update",
|
||||
@@ -175,4 +203,94 @@ describe("TabBar", () => {
|
||||
const button = screen.getByRole("button", { name: "Changes queue" });
|
||||
expect(button.className).toContain("border-amber-500");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Drag & drop reorder — keep this test LAST in this file.
|
||||
//
|
||||
// The vitest config does not enable `globals: true`, so RTL's auto-cleanup
|
||||
// never unmounts components between tests. A dnd-kit drag leaves its DndContext
|
||||
// (and document-level listeners) mounted, which silently breaks userEvent/fireEvent
|
||||
// clicks in any LATER test. The drag itself is fully verified here; placing it
|
||||
// last isolates the pollution.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it("reorders tabs via drag and drop (horizontal axis only)", async () => {
|
||||
const { act, fireEvent } = await import("@testing-library/react");
|
||||
const store = useDbViewerStore.getState();
|
||||
store.openTab("public", "users");
|
||||
store.openTab("public", "posts", true);
|
||||
store.openTab("public", "comments", true);
|
||||
|
||||
// jsdom reports zero-sized rects and non-primary pointers by default,
|
||||
// which breaks dnd-kit collision detection + pointer activation.
|
||||
const original = Element.prototype.getBoundingClientRect;
|
||||
Element.prototype.getBoundingClientRect = function () {
|
||||
const text = this.textContent ?? "";
|
||||
const index = text.includes("posts")
|
||||
? 1
|
||||
: text.includes("comments")
|
||||
? 2
|
||||
: 0;
|
||||
const x = index * 100;
|
||||
return {
|
||||
x,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 30,
|
||||
left: x,
|
||||
right: x + 100,
|
||||
top: 0,
|
||||
bottom: 30,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
};
|
||||
|
||||
try {
|
||||
render(<TabBar />);
|
||||
const usersTab = screen.getByRole("tab", { name: "users" });
|
||||
|
||||
// pointerDown lifts the tab (distance constraint >= 4px on move), then
|
||||
// moves it over the last tab and drops.
|
||||
await act(async () => {
|
||||
fireEvent.pointerDown(usersTab, {
|
||||
pointerId: 1,
|
||||
clientX: 50,
|
||||
clientY: 15,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.pointerMove(usersTab, {
|
||||
pointerId: 1,
|
||||
clientX: 160,
|
||||
clientY: 15,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.pointerMove(usersTab, {
|
||||
pointerId: 1,
|
||||
clientX: 260,
|
||||
clientY: 15,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.pointerUp(usersTab, {
|
||||
pointerId: 1,
|
||||
clientX: 260,
|
||||
clientY: 15,
|
||||
});
|
||||
});
|
||||
// Flush dnd-kit's post-drag rAF focus-restore so it cannot leak into
|
||||
// later tests (userEvent clicks are order-sensitive in jsdom).
|
||||
await act(async () => {});
|
||||
} finally {
|
||||
Element.prototype.getBoundingClientRect = original;
|
||||
}
|
||||
|
||||
expect(
|
||||
useDbViewerStore.getState().tabs.map((t) => t.table),
|
||||
).toEqual(["posts", "comments", "users"]);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,14 +1,95 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ListChecks, Play, Table2, Terminal, X } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type Modifier,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
horizontalListSortingStrategy,
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { ListChecks, Play, Table2, Layers, Eye, Terminal, X } from "lucide-react";
|
||||
import { useDbViewerStore, type ViewerTab } from "../../stores/dbViewerStore";
|
||||
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
||||
|
||||
function SortableTab({
|
||||
tab,
|
||||
isActive,
|
||||
icon,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
tab: ViewerTab;
|
||||
isActive: boolean;
|
||||
icon: ReactNode;
|
||||
onSelect: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform: rawTransform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: tab.id });
|
||||
|
||||
// dnd-kit scales the dragged item to the width of whichever tab it is
|
||||
// hovering over (adjustScale). Tabs have different widths, which would warp
|
||||
// the text — always render at scale 1 and let the horizontal strategy handle
|
||||
// positioning.
|
||||
const transform = rawTransform
|
||||
? { ...rawTransform, scaleX: 1, scaleY: 1 }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
{...attributes}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-label={tab.table}
|
||||
{...listeners}
|
||||
onClick={onSelect}
|
||||
className={[
|
||||
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors cursor-grab active:cursor-grabbing select-none",
|
||||
isActive ? "bg-canvas text-text" : "text-text-muted hover:text-text",
|
||||
isDragging ? "opacity-50 z-10 ring-1 ring-accent" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="flex-1 text-left select-none">{icon}{tab.table}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
aria-label={`Close ${tab.table}`}
|
||||
className="rounded p-0.5 opacity-60 transition-opacity hover:bg-surface-raised hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
|
||||
const tabs = useDbViewerStore((state) => state.tabs);
|
||||
const tables = useDbViewerStore((state) => state.tables);
|
||||
const activeTabId = useDbViewerStore((state) => state.activeTabId);
|
||||
const closeTab = useDbViewerStore((state) => state.closeTab);
|
||||
const setActiveTab = useDbViewerStore((state) => state.setActiveTab);
|
||||
const openQueryTab = useDbViewerStore((state) => state.openQueryTab);
|
||||
const reorderTab = useDbViewerStore((state) => state.reorderTab);
|
||||
const changesQueue = useDbViewerStore((state) => state.changesQueue);
|
||||
const changesPanelExpanded = useDbViewerStore(
|
||||
(state) => state.changesPanelExpanded,
|
||||
@@ -17,6 +98,30 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
|
||||
(state) => state.toggleChangesPanel,
|
||||
);
|
||||
|
||||
// Drag threshold so a click still selects the tab; a deliberate drag (>= 4px)
|
||||
// starts a reorder. Keyboard sorting uses arrow keys, one axis only.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
// Keep the dragged tab on the tab strip: zero out any vertical movement so
|
||||
// dragging is constrained to the horizontal axis only.
|
||||
const restrictToHorizontalAxis: Modifier = ({ transform }) => ({
|
||||
...transform,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const from = tabs.findIndex((t) => t.id === active.id);
|
||||
const to = tabs.findIndex((t) => t.id === over.id);
|
||||
if (from >= 0 && to >= 0) reorderTab(from, to);
|
||||
};
|
||||
|
||||
const pendingCount = changesQueue.filter(
|
||||
(c) => c.status === "pending",
|
||||
).length;
|
||||
@@ -50,49 +155,62 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
|
||||
className="flex flex-1 min-w-0 items-stretch overflow-x-auto"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={[
|
||||
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors cursor-pointer",
|
||||
isActive
|
||||
? "bg-canvas text-text"
|
||||
: "text-text-muted hover:text-text",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="flex-1 text-left select-none">
|
||||
{tab.tabType === "query" ? (
|
||||
<Terminal
|
||||
data-testid="tab-icon-query"
|
||||
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={tabs.map((t) => t.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<div className="flex items-stretch">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const objectType =
|
||||
tab.tabType === "table"
|
||||
? tables.find(
|
||||
(t) =>
|
||||
t.schema === tab.schema && t.name === tab.table,
|
||||
)?.table_type
|
||||
: undefined;
|
||||
const icon =
|
||||
tab.tabType === "query" ? (
|
||||
<Terminal
|
||||
data-testid="tab-icon-query"
|
||||
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||
/>
|
||||
) : objectType === "VIEW" ? (
|
||||
<Eye
|
||||
data-testid="tab-icon-view"
|
||||
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||
/>
|
||||
) : objectType === "MATERIALIZED VIEW" ? (
|
||||
<Layers
|
||||
data-testid="tab-icon-matview"
|
||||
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||
/>
|
||||
) : (
|
||||
<Table2
|
||||
data-testid="tab-icon-table"
|
||||
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<SortableTab
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={isActive}
|
||||
icon={icon}
|
||||
onSelect={() => setActiveTab(tab.id)}
|
||||
onClose={() => closeTab(tab.id)}
|
||||
/>
|
||||
) : (
|
||||
<Table2
|
||||
data-testid="tab-icon-table"
|
||||
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||
/>
|
||||
)}
|
||||
{tab.table}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
aria-label={`Close ${tab.table}`}
|
||||
className="rounded p-0.5 opacity-60 transition-opacity hover:bg-surface-raised hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Right: fixed actions */}
|
||||
|
||||
@@ -40,6 +40,23 @@ describe("TableTree", () => {
|
||||
expect(screen.getByText("Materialized View")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a distinct icon and label for views", () => {
|
||||
useDbViewerStore.setState({
|
||||
schemas: ["public"],
|
||||
currentSchema: "public",
|
||||
tables: [
|
||||
{
|
||||
name: "order_summary",
|
||||
schema: "public",
|
||||
table_type: "VIEW",
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<TableTree />);
|
||||
expect(screen.getByText("order_summary")).toBeInTheDocument();
|
||||
expect(screen.getByText("View")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a tab when table is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.setState({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, ChevronDown, Table2, Layers, Key, Type } from "lucide-react";
|
||||
import { ChevronRight, ChevronDown, Table2, Layers, Eye, Key, Type } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { TableOverflowMenu } from "./TableOverflowMenu";
|
||||
@@ -71,8 +71,13 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
|
||||
const isExpanded = expanded.has(key);
|
||||
const cols = columnCache[key] ?? table.columns ?? [];
|
||||
const isMatView = table.table_type === "MATERIALIZED VIEW";
|
||||
const TypeIcon = isMatView ? Layers : Table2;
|
||||
const typeLabel = isMatView ? "Materialized View" : null;
|
||||
const isView = table.table_type === "VIEW";
|
||||
const TypeIcon = isMatView ? Layers : isView ? Eye : Table2;
|
||||
const typeLabel = isMatView
|
||||
? "Materialized View"
|
||||
: isView
|
||||
? "View"
|
||||
: null;
|
||||
return (
|
||||
<div key={key}>
|
||||
<div
|
||||
|
||||
@@ -13,11 +13,17 @@ describe("CellEditor", () => {
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onCommit).toHaveBeenCalledWith("Alicia");
|
||||
});
|
||||
it("commits null when the setNull flag is toggled", () => {
|
||||
it("commits null when a nullable cell is emptied", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
|
||||
const nullCheckbox = screen.getByLabelText(/set null/i);
|
||||
fireEvent.click(nullCheckbox);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onCommit).toHaveBeenCalledWith(null);
|
||||
});
|
||||
it("leaves an empty nullable cell alone when it was already empty (NULL stays NULL)", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(<CellEditor initialValue="" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
|
||||
expect(onCommit).toHaveBeenCalledWith(null);
|
||||
});
|
||||
@@ -31,6 +37,79 @@ describe("CellEditor", () => {
|
||||
render(<CellEditor initialValue="{}" dataType="jsonb" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||
expect(screen.getByRole("textbox").tagName).toBe("TEXTAREA");
|
||||
});
|
||||
it("blocks empty commits on non-nullable non-text columns and shows an error", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onCommit).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
|
||||
});
|
||||
it("commits an empty string on non-nullable text columns", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onCommit).toHaveBeenCalledWith("");
|
||||
});
|
||||
it("clears the validation error as soon as the user types again", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
|
||||
fireEvent.change(input, { target: { value: "5" } });
|
||||
expect(screen.queryByTestId("cell-editor-error")).toBeNull();
|
||||
});
|
||||
it("does not offer a NULL row in the FK dropdown for non-nullable FK columns", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(
|
||||
<CellEditor
|
||||
initialValue="1"
|
||||
dataType="integer"
|
||||
fkOptions={[{ value: "1", label: "1 — Alice" }]}
|
||||
onCommit={onCommit}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons.some((b) => b.textContent === "NULL")).toBe(false);
|
||||
});
|
||||
it("offers a NULL row in the FK dropdown for nullable FK columns", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(
|
||||
<CellEditor
|
||||
initialValue="1"
|
||||
dataType="integer"
|
||||
fkOptions={[{ value: "1", label: "1 — Alice" }]}
|
||||
onCommit={onCommit}
|
||||
onCancel={vi.fn()}
|
||||
nullable
|
||||
/>
|
||||
);
|
||||
const nullRow = screen.getAllByRole("button").find((b) => b.textContent === "NULL");
|
||||
expect(nullRow).toBeDefined();
|
||||
fireEvent.click(nullRow!);
|
||||
expect(onCommit).toHaveBeenCalledWith(null);
|
||||
});
|
||||
it("commits null when the NULL option is selected in enum mode", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(
|
||||
<CellEditor
|
||||
initialValue="active"
|
||||
dataType="text"
|
||||
enumValues={["active", "inactive", "pending"]}
|
||||
onCommit={onCommit}
|
||||
onCancel={vi.fn()}
|
||||
nullable
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: "" } });
|
||||
expect(onCommit).toHaveBeenCalledWith(null);
|
||||
});
|
||||
it("renders a combobox with enum values and commits on change", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(
|
||||
@@ -49,21 +128,6 @@ describe("CellEditor", () => {
|
||||
fireEvent.change(select, { target: { value: "pending" } });
|
||||
expect(onCommit).toHaveBeenCalledWith("pending");
|
||||
});
|
||||
it("commits null via Set NULL in enum mode", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(
|
||||
<CellEditor
|
||||
initialValue="active"
|
||||
dataType="text"
|
||||
enumValues={["active", "inactive", "pending"]}
|
||||
onCommit={onCommit}
|
||||
onCancel={vi.fn()}
|
||||
nullable
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(/set null/i));
|
||||
expect(onCommit).toHaveBeenCalledWith(null);
|
||||
});
|
||||
it("filters FK options by query and commits the clicked value", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(
|
||||
@@ -105,7 +169,7 @@ describe("CellEditor", () => {
|
||||
render(<CellEditor initialValue="long text" dataType="text" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input.tagName).toBe("TEXTAREA");
|
||||
expect(input.className).toContain("h-6");
|
||||
expect(input.className).toContain("overflow-y-auto");
|
||||
});
|
||||
|
||||
it("renders the FK placeholder and a No matches empty state", () => {
|
||||
|
||||
@@ -23,7 +23,12 @@ interface CellEditorProps {
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full px-1 py-0.5 text-xs bg-surface border border-border rounded font-mono";
|
||||
"min-w-0 flex-1 bg-transparent px-3 font-heading text-xs text-text outline-none placeholder:text-text-muted";
|
||||
const controlClass =
|
||||
"min-w-0 flex-1 rounded bg-surface px-2 py-1 font-heading text-xs text-text outline-none placeholder:text-text-muted";
|
||||
|
||||
/** Types that tolerate an empty string when NOT NULL ('' is a valid value). */
|
||||
const TEXT_LIKE = ["char", "text", "uuid", "bit"];
|
||||
|
||||
export function CellEditor({
|
||||
initialValue,
|
||||
@@ -36,8 +41,9 @@ export function CellEditor({
|
||||
onCancel,
|
||||
}: CellEditorProps) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [setNull, setSetNull] = useState(initialValue === "" && nullable);
|
||||
const [query, setQuery] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const ref = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
|
||||
const enumRef = useRef<HTMLSelectElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
@@ -47,6 +53,8 @@ export function CellEditor({
|
||||
width: number;
|
||||
} | null>(null);
|
||||
|
||||
const textLike = TEXT_LIKE.some((t) => dataType.toLowerCase().includes(t));
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (fkOptions && fkOptions.length > 0 && searchRef.current) {
|
||||
const r = searchRef.current.getBoundingClientRect();
|
||||
@@ -80,22 +88,59 @@ export function CellEditor({
|
||||
);
|
||||
const Tag = large ? "textarea" : "input";
|
||||
|
||||
const commit = () => onCommit(setNull ? null : value);
|
||||
|
||||
const handleSetNull = (checked: boolean) => {
|
||||
setSetNull(checked);
|
||||
if (checked) onCommit(null);
|
||||
/**
|
||||
* Constraint-aware commit resolution:
|
||||
* - Empty input on a nullable column → NULL (smart "clear = null").
|
||||
* - Empty input on a NOT NULL column → only text-ish types may fall back to
|
||||
* an empty string; everything else is blocked with an error.
|
||||
*/
|
||||
const resolveCommit = (
|
||||
raw: string,
|
||||
): { value: string | null } | { error: string } => {
|
||||
if (raw.trim() === "") {
|
||||
if (nullable) return { value: null };
|
||||
if (textLike) return { value: raw };
|
||||
return { error: "This column cannot be NULL" };
|
||||
}
|
||||
return { value: raw };
|
||||
};
|
||||
|
||||
const commitRaw = (raw: string) => {
|
||||
const r = resolveCommit(raw);
|
||||
if ("error" in r) {
|
||||
setError(r.error);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onCommit(r.value);
|
||||
};
|
||||
|
||||
const commit = () => commitRaw(value);
|
||||
|
||||
const errorRect = error ? rootRef.current?.getBoundingClientRect() : null;
|
||||
const errorBubble =
|
||||
error && errorRect
|
||||
? createPortal(
|
||||
<div
|
||||
data-testid="cell-editor-error"
|
||||
className="fixed z-50 pointer-events-none rounded-md border border-red-500/50 bg-red-950/95 px-2 py-1 text-[10px] text-red-300 shadow-lg"
|
||||
style={{ top: errorRect.bottom + 4, left: errorRect.left, maxWidth: 320 }}
|
||||
>
|
||||
{error}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
// Priority: enum > FK > default input/textarea
|
||||
if (enumValues && enumValues.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
|
||||
<div ref={rootRef} className={`flex h-full w-full items-center gap-1.5 px-1.5 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}>
|
||||
<select
|
||||
ref={enumRef}
|
||||
className={inputClass}
|
||||
className={controlClass}
|
||||
value={initialValue}
|
||||
onChange={(e) => onCommit(setNull ? null : e.target.value)}
|
||||
onChange={(e) => commitRaw(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
@@ -110,16 +155,7 @@ export function CellEditor({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{nullable && (
|
||||
<label className="flex items-center gap-1 text-[10px] text-text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setNull}
|
||||
onChange={(e) => handleSetNull(e.target.checked)}
|
||||
/>
|
||||
Set NULL
|
||||
</label>
|
||||
)}
|
||||
{errorBubble}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -139,19 +175,22 @@ export function CellEditor({
|
||||
? fkOptions
|
||||
: fkOptions.filter((o) => fkSearchText(o).includes(q));
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
|
||||
<div ref={rootRef} className={`flex h-full w-full items-center gap-1.5 px-1.5 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}>
|
||||
<input
|
||||
ref={searchRef}
|
||||
aria-label="Search foreign key options"
|
||||
placeholder={fkPlaceholder ?? "Search…"}
|
||||
className={inputClass}
|
||||
className={controlClass}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (filtered.length > 0) onCommit(filtered[0].value);
|
||||
else onCommit(query);
|
||||
if (filtered.length > 0) commitRaw(filtered[0].value);
|
||||
else commitRaw(query);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
@@ -172,6 +211,15 @@ export function CellEditor({
|
||||
}}
|
||||
className="max-h-28 overflow-y-auto bg-surface border border-border rounded-lg shadow-xl"
|
||||
>
|
||||
{nullable && (
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-1.5 hover:bg-surface-raised text-xs text-left italic text-text-muted"
|
||||
onClick={() => commitRaw("")}
|
||||
>
|
||||
NULL
|
||||
</button>
|
||||
)}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-2 py-1 text-xs text-text-muted">No matches</div>
|
||||
)}
|
||||
@@ -180,7 +228,7 @@ export function CellEditor({
|
||||
key={o.value}
|
||||
type="button"
|
||||
className="block w-full px-3 py-1.5 hover:bg-surface-raised text-xs text-left"
|
||||
onClick={() => onCommit(o.value)}
|
||||
onClick={() => commitRaw(o.value)}
|
||||
>
|
||||
{o.cells && o.cells.length > 0 ? (
|
||||
<span className="flex items-center gap-0 min-w-0">
|
||||
@@ -205,30 +253,28 @@ export function CellEditor({
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
{nullable && (
|
||||
<label className="flex items-center gap-1 text-[10px] text-text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setNull}
|
||||
onChange={(e) => handleSetNull(e.target.checked)}
|
||||
/>
|
||||
Set NULL
|
||||
</label>
|
||||
)}
|
||||
{errorBubble}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cls = large ? `${inputClass} h-6 resize-none overflow-y-auto leading-none` : inputClass;
|
||||
const cls = large
|
||||
? `${inputClass} h-4 resize-none overflow-y-auto whitespace-pre leading-none py-0.5`
|
||||
: inputClass;
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-testid="cell-editor"
|
||||
className={`flex h-full w-full items-center gap-2 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}
|
||||
>
|
||||
<Tag
|
||||
ref={ref as any}
|
||||
className={cls}
|
||||
placeholder={nullable && value === "" ? "NULL" : undefined}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setSetNull(false);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
@@ -240,16 +286,7 @@ export function CellEditor({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{nullable && (
|
||||
<label className="flex items-center gap-1 text-[10px] text-text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setNull}
|
||||
onChange={(e) => handleSetNull(e.target.checked)}
|
||||
/>
|
||||
Set NULL
|
||||
</label>
|
||||
)}
|
||||
{errorBubble}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,24 +48,24 @@ describe("VirtualDataGrid", () => {
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows staged values passed from the parent + a pending dot", () => {
|
||||
it("shows staged values passed from the parent + a pending outline", () => {
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} pendingKeys={{ "0:name": true }} />);
|
||||
expect(screen.getByText("Alicia")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("pending-cell")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pending dot requires pendingKeys even when a staged value exists (committed → no dot)", () => {
|
||||
it("pending outline requires pendingKeys even when a staged value exists (committed → no outline)", () => {
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} pendingKeys={{}} />);
|
||||
expect(screen.getByText("Alicia")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
|
||||
expect(screen.queryByTestId("pending-cell")).toBeNull();
|
||||
});
|
||||
|
||||
it("clears staged values when the stagedValues prop empties (Clear All)", () => {
|
||||
@@ -393,7 +393,7 @@ describe("VirtualDataGrid", () => {
|
||||
expect(screen.getByText("id")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a pending-edit dot on the pending cell", () => {
|
||||
it("renders a pending outline on the pending cell", () => {
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(
|
||||
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })),
|
||||
@@ -416,10 +416,10 @@ describe("VirtualDataGrid", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("pending-cell")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render a pending-edit dot without pendingCell", () => {
|
||||
it("does not render a pending outline without pendingCell", () => {
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(
|
||||
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })),
|
||||
@@ -441,7 +441,7 @@ describe("VirtualDataGrid", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
|
||||
expect(screen.queryByTestId("pending-cell")).toBeNull();
|
||||
});
|
||||
|
||||
// ── GRID-A: context menu + editing behavior ─────────────────────────
|
||||
|
||||
@@ -43,7 +43,7 @@ interface VirtualDataGridProps {
|
||||
fkPlaceholders?: Record<string, string>;
|
||||
/** Optimistic staged cell values keyed `${rowIndex}:${colName}` → value (null = NULL), from the changes queue. */
|
||||
stagedValues?: Record<string, string | null>;
|
||||
/** Keys of cells with a PENDING (not yet committed) update → drives the amber dot. */
|
||||
/** Keys of cells with a PENDING (not yet committed) update → drives the pulsing orange outline. */
|
||||
pendingKeys?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
@@ -297,6 +297,12 @@ export function VirtualDataGrid({
|
||||
};
|
||||
|
||||
const commitEdit = (committed: string | null) => {
|
||||
// Constraint guard: never stage NULL on a NOT NULL column
|
||||
// (the editor blocks this with UX; this is defense in depth)
|
||||
if (committed === null && col.is_nullable === false) {
|
||||
setEditingCell(null);
|
||||
return;
|
||||
}
|
||||
// oldData must be the DB value (the un-staged cell), so the queue's
|
||||
// revert/display stays correct even after repeated edits of the same cell.
|
||||
const dbValue = ci >= 0 ? row[ci] : undefined;
|
||||
@@ -326,10 +332,13 @@ export function VirtualDataGrid({
|
||||
return (
|
||||
<div
|
||||
key={col.name}
|
||||
data-testid={isPending ? "pending-cell" : undefined}
|
||||
className={`relative px-3 py-2 font-heading text-xs truncate select-text border-r border-border self-stretch ${
|
||||
isFk ? "cursor-pointer underline decoration-dotted underline-offset-2 hover:text-accent" : ""
|
||||
} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""} ${
|
||||
isActive ? "bg-accent/10 ring-1 ring-inset ring-accent outline-none" : ""
|
||||
isActive && !isEditing ? "bg-accent/10 ring-1 ring-inset ring-accent outline-none" : ""
|
||||
} ${isEditing ? "outline outline-2 outline-amber-400 outline-offset-[-2px]" : ""} ${
|
||||
isPending && !isEditing ? "animate-pending-ring" : ""
|
||||
}`}
|
||||
role={isJson ? "button" : undefined}
|
||||
tabIndex={isJson ? 0 : -1}
|
||||
@@ -408,12 +417,6 @@ export function VirtualDataGrid({
|
||||
) : (
|
||||
String(displayCell)
|
||||
)}
|
||||
{isPending && (
|
||||
<span
|
||||
className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-amber-400"
|
||||
data-testid="pending-edit-dot"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -438,6 +441,8 @@ export function VirtualDataGrid({
|
||||
(row: number, col: number) => {
|
||||
const column = visibleColumns[col];
|
||||
if (!column || !isCellEditable(column, tabType, dbType, readOnly)) return;
|
||||
// NOT NULL columns can't be nulled (matches the context-menu gating)
|
||||
if (!column.is_nullable) return;
|
||||
const ci = columns.findIndex((c) => c.name === column.name);
|
||||
const value = rows[row]?.[ci];
|
||||
if (value === null || value === undefined) return;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import {
|
||||
Banknote,
|
||||
Binary,
|
||||
Braces,
|
||||
CalendarClock,
|
||||
Clock,
|
||||
FileCode2,
|
||||
Fingerprint,
|
||||
Globe,
|
||||
Hash,
|
||||
Layers,
|
||||
ListChecks,
|
||||
Shapes,
|
||||
ToggleLeft,
|
||||
Type,
|
||||
} from "lucide-react";
|
||||
import { DataTypeIcon, getDataTypeIcon } from "./DataTypeIcon";
|
||||
|
||||
describe("getDataTypeIcon", () => {
|
||||
it("maps numeric types to Hash", () => {
|
||||
expect(getDataTypeIcon("integer")).toBe(Hash);
|
||||
expect(getDataTypeIcon("bigint")).toBe(Hash);
|
||||
expect(getDataTypeIcon("numeric(10,2)")).toBe(Hash);
|
||||
expect(getDataTypeIcon("double precision")).toBe(Hash);
|
||||
expect(getDataTypeIcon("REAL")).toBe(Hash);
|
||||
});
|
||||
|
||||
it("maps money to Banknote", () => {
|
||||
expect(getDataTypeIcon("money")).toBe(Banknote);
|
||||
});
|
||||
|
||||
it("maps text types to Type", () => {
|
||||
expect(getDataTypeIcon("character varying")).toBe(Type);
|
||||
expect(getDataTypeIcon("text")).toBe(Type);
|
||||
expect(getDataTypeIcon("TEXT")).toBe(Type);
|
||||
expect(getDataTypeIcon("citext")).toBe(Type);
|
||||
});
|
||||
|
||||
it("maps booleans to ToggleLeft", () => {
|
||||
expect(getDataTypeIcon("boolean")).toBe(ToggleLeft);
|
||||
expect(getDataTypeIcon("bool")).toBe(ToggleLeft);
|
||||
});
|
||||
|
||||
it("maps JSON types to Braces", () => {
|
||||
expect(getDataTypeIcon("json")).toBe(Braces);
|
||||
expect(getDataTypeIcon("jsonb")).toBe(Braces);
|
||||
});
|
||||
|
||||
it("maps binary types to Binary", () => {
|
||||
expect(getDataTypeIcon("bytea")).toBe(Binary);
|
||||
expect(getDataTypeIcon("BLOB")).toBe(Binary);
|
||||
expect(getDataTypeIcon("bit varying")).toBe(Binary);
|
||||
});
|
||||
|
||||
it("maps date/time types to CalendarClock or Clock", () => {
|
||||
expect(getDataTypeIcon("timestamp")).toBe(CalendarClock);
|
||||
expect(getDataTypeIcon("timestamp with time zone")).toBe(CalendarClock);
|
||||
expect(getDataTypeIcon("date")).toBe(Clock);
|
||||
expect(getDataTypeIcon("time without time zone")).toBe(Clock);
|
||||
expect(getDataTypeIcon("interval")).toBe(Clock);
|
||||
});
|
||||
|
||||
it("maps uuid to Fingerprint", () => {
|
||||
expect(getDataTypeIcon("uuid")).toBe(Fingerprint);
|
||||
});
|
||||
|
||||
it("maps array types to Layers", () => {
|
||||
expect(getDataTypeIcon("integer[]")).toBe(Layers);
|
||||
expect(getDataTypeIcon("text[]")).toBe(Layers);
|
||||
});
|
||||
|
||||
it("maps enums, xml, network and geometric types", () => {
|
||||
expect(getDataTypeIcon("enum('a','b')")).toBe(ListChecks);
|
||||
expect(getDataTypeIcon("xml")).toBe(FileCode2);
|
||||
expect(getDataTypeIcon("inet")).toBe(Globe);
|
||||
expect(getDataTypeIcon("point")).toBe(Shapes);
|
||||
});
|
||||
|
||||
it("falls back to Type for unknown/custom types", () => {
|
||||
expect(getDataTypeIcon("mood")).toBe(Type);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataTypeIcon", () => {
|
||||
it("renders the mapped icon with a title tooltip", () => {
|
||||
const { container } = render(<DataTypeIcon dataType="integer" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).not.toBeNull();
|
||||
expect(svg!.getAttribute("class")).toContain("lucide-hash");
|
||||
// The tooltip lives on the wrapper span (React SVG types omit `title`).
|
||||
expect(container.querySelector("span")!.getAttribute("title")).toBe("integer");
|
||||
});
|
||||
|
||||
it("honors a custom title override", () => {
|
||||
const { container } = render(
|
||||
<DataTypeIcon dataType="integer" title="User ID (int)" />,
|
||||
);
|
||||
expect(container.querySelector("span")!.getAttribute("title")).toBe(
|
||||
"User ID (int)",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies size and className", () => {
|
||||
const { container } = render(
|
||||
<DataTypeIcon dataType="jsonb" size={9} className="text-text-muted/60" />,
|
||||
);
|
||||
const svg = container.querySelector("svg")!;
|
||||
expect(svg.getAttribute("width")).toBe("9");
|
||||
expect(container.querySelector("span")!.getAttribute("class")).toContain(
|
||||
"text-text-muted/60",
|
||||
);
|
||||
expect(svg.getAttribute("class")).toContain("lucide-braces");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { memo } from "react";
|
||||
import {
|
||||
Banknote,
|
||||
Binary,
|
||||
Braces,
|
||||
CalendarClock,
|
||||
Clock,
|
||||
FileCode2,
|
||||
Fingerprint,
|
||||
Globe,
|
||||
Hash,
|
||||
Layers,
|
||||
ListChecks,
|
||||
Shapes,
|
||||
ToggleLeft,
|
||||
Type,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Map a DB column data type (PostgreSQL / SQLite / MySQL strings) to a
|
||||
* representative lucide icon. Best-effort by string matching — unknown and
|
||||
* custom types fall back to the generic `Type` icon.
|
||||
*/
|
||||
export function getDataTypeIcon(dataType: string): LucideIcon {
|
||||
const t = dataType.toLowerCase().trim();
|
||||
|
||||
if (t === "money") return Banknote; // money — numeric, but deserves its own icon
|
||||
if (/json/.test(t)) return Braces; // json, jsonb
|
||||
if (t.endsWith("[]")) return Layers; // array types: integer[], text[], ...
|
||||
if (/uuid/.test(t)) return Fingerprint; // uuid
|
||||
if (/bool/.test(t)) return ToggleLeft; // boolean, bool
|
||||
if (/bytea|blob|varbinary|^binary|bit/.test(t)) return Binary; // bytea, blob, binary, bit
|
||||
if (/timestamp|datetime/.test(t)) return CalendarClock; // timestamp, timestamptz, datetime
|
||||
if (/^date\b|^time\b|interval/.test(t)) return Clock; // date, time, interval
|
||||
if (/smallint|integer|bigint|serial|numeric|decimal|real|float|double|int2|int4|int8|number/.test(t))
|
||||
return Hash; // numeric types
|
||||
if (/enum/.test(t)) return ListChecks; // mysql ENUM(...)
|
||||
if (/xml/.test(t)) return FileCode2; // xml
|
||||
if (/inet|cidr|macaddr/.test(t)) return Globe; // network types
|
||||
if (/point|line|lseg|box|path|polygon|circle/.test(t)) return Shapes; // geometric types
|
||||
return Type; // text, character varying, and unknown/custom types
|
||||
}
|
||||
|
||||
interface DataTypeIconProps {
|
||||
dataType: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
/** Tooltip / accessible label. Defaults to the raw data type string. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** A compact icon representing a DB column's data type, with a tooltip. */
|
||||
export const DataTypeIcon = memo(function DataTypeIcon({
|
||||
dataType,
|
||||
size = 12,
|
||||
className,
|
||||
title = dataType,
|
||||
}: DataTypeIconProps) {
|
||||
const Icon = getDataTypeIcon(dataType);
|
||||
return (
|
||||
<span title={title} className={className}>
|
||||
<Icon size={size} aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user