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
|
||||
|
||||
Reference in New Issue
Block a user