v0.7.0: New Connection screen revamp + full MySQL DB viewer (#10)
* docs: correct competitor comparison for DB Pro, Beekeeper, TablePlus Research-verified the 'Why Gridline vs the alternatives' claims against vendor docs, pricing pages, GitHub, and release notes (May 2026): - DB Pro is an Electron app (founder-confirmed), not native; add TablePlus column to the comparison table - Fix wrong cells: DB Pro has query/dashboard folders + table tags and CSV/JSON export on the free tier; object-explorer depth corrected for DB Pro (tables/views/indexes/enums) and Beekeeper (tables/views/routines/triggers) - Reframe differentiators: unlimited-everything framing dropped for Beekeeper (free tier is already unlimited on tabs/connections/queries); keep DB-to-DB sync as the genuinely unique feature - Add a dated 'Competitor reality check' section to AGENTS.md so future edits don't re-assert inaccurate claims * docs: add project roadmap, link it from README and AGENTS New ROADMAP.md is the source of truth for planned work, reflecting the in-flight v0.7.0 connection-screen-revamp spec (new-connection flow, full MySQL DB viewer, capability gating, Supabase/Neon presets, SQLite path mode, tag overflow scroll, styling sweep). Next-up scope: PostgreSQL object management CRUD with companion features (schema CRUD, global object search, copy-as-DDL, object dependencies) and an admin follow-up (users/roles/grants, VACUUM/ANALYZE/REINDEX). MySQL Objects view explicitly deferred. Queue: Redis browsing, MariaDB/TimescaleDB, PlanetScale/Turso, query workbench upgrades (multiple result sets, query cancel, result streaming, visual query builder), schema/data tooling, SQLite .dump, schema diff, more export formats. Planned: BYOK AI, website & docs, rolling UI/UX polish (incl. onboarding tour, settings import/export, SSH key management). README roadmap section now links to ROADMAP.md; AGENTS.md Related Documents + Implementation Status reference it and the v0.7.0 spec. * docs: release notes reference prod as the production branch The repo's production branch is prod (feature branches merge back to prod), not main. Update the release-cut instructions in the README and the trigger comment in release.yml. * docs: add robust bug report issue template Structured .github/ISSUE_TEMPLATE/bug_report.md covering environment (OS, Gridline version, install type, DB type/version, hosted provider, connection method incl. SSH/TLS/socket), steps to reproduce, expected vs actual, screenshots, logs, impact, and workarounds — plus a duplicate checklist and secrets-redaction note. Referenced from the README Contributing section. * docs: drop in-flight branch mention from roadmap; remove unused starter assets - ROADMAP.md no longer references the in-flight feature branch/spec (removed at the end anyway when the branch PRs into prod) - Remove unused Vite/Tauri starter SVGs from public/ (no favicon or asset references anywhere in the app) * test: fix stale README comparison-table regex in docs-coverage (5-col table) * feat: shared INPUT_ROUNDING constant + bump to v0.7.0 (Task 1.1) * fix: map SQLite file path to host field + provider host detection (Task 1.2) * test: bump version expectation to 0.7.0 (Task 1.1 follow-up) * feat: db capability matrix for DB viewer gating (Task 1.3) * feat: provider tab definitions, Supabase/Neon icons + setup guides (Task 1.4) * feat(rust): MySQL SQL builders + identifier quoting (Task 2.1) * chore(rust): sync Cargo.lock to gridline 0.7.0 * feat(rust): MySQL db_connect (SSL + SSH tunnel) + pool variant (Tasks 2.2-2.3) * feat(rust): MySQL execute_query with wrapped pagination + raw fallback (Task 2.4) * feat(rust): MySQL introspection + changes-queue editing + DDL (Task 2.5) * fix(ui): show table toolbar immediately while tab is still loading first data * feat: gate DB viewer sidebar nav by db capabilities (Task 3.1) * feat: guard DB viewer views by capability + Redis unsupported state (Task 3.2) * feat(ui): 2-column provider tab grid (Task 4.1) * feat(ui): collapsible Supabase/Neon setup guide (Task 4.2) * feat(ui): SQLite file-path input with Browse (Task 4.3) * feat(ui): connection metadata row (label + tags/env/folder) (Task 4.4) * feat(ui): rework GeneralTab (URI + OR + manual) + reduce Detailed form tabs (Task 4.5) * feat(ui): NewConnectionScreen two-stage flow; remove SimpleConnectionForm (Task 5.1) * feat(ui): scroll connection-card tag row past 3 tags (Task 5.2) * style: sweep form controls from rounded-full to rounded-lg (Task 5.3) * feat(ui): EditConnectionModal parity + managed-preset SSL hint (Task 5.4) * fix(rust): decode MySQL VARBINARY metadata columns (information_schema/SHOW) as strings * test: full suite green for v0.7.0 connection revamp (Task 5.5) * feat(ui): schema dropdown + tables tree loading state while schema tree fetches * docs: update AGENTS/README/ROADMAP for v0.7.0 (connection revamp, MySQL viewer, gating)
This commit is contained in:
@@ -8,7 +8,9 @@ import {
|
||||
} from "./DbViewerScreen";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import * as commands from "../../lib/commands";
|
||||
import type { Connection } from "../../lib/types";
|
||||
|
||||
vi.mock("../../hooks/useDbConnection", () => ({
|
||||
useDbConnection: (_connectionId: string) => ({
|
||||
@@ -92,6 +94,7 @@ const mockQueryResult = {
|
||||
describe("DbViewerScreen", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
useConnectionStore.setState({ connections: [] });
|
||||
useDbViewerStore.setState({
|
||||
databases: ["mydb"],
|
||||
schemas: ["public"],
|
||||
@@ -596,6 +599,46 @@ describe("DbViewerScreen", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the table toolbar while a table tab is still loading its first data", () => {
|
||||
useDbViewerStore.setState({
|
||||
tabs: [
|
||||
{
|
||||
id: "tab-loading",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
loading: true,
|
||||
error: null,
|
||||
data: null, // first fetch still in flight
|
||||
filterRules: [],
|
||||
sortRules: [],
|
||||
hiddenColumns: [],
|
||||
smartSortApplied: false,
|
||||
tabType: "table",
|
||||
},
|
||||
],
|
||||
activeTabId: "tab-loading",
|
||||
});
|
||||
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
onHome={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Toolbar must be visible immediately while data is still loading,
|
||||
// so the user sees the loading state instead of an empty pane.
|
||||
expect(
|
||||
screen.getByLabelText(/refresh table/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText(/column filters/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables Insert Row for a materialized-view tab", async () => {
|
||||
useDbViewerStore.setState({
|
||||
tables: [
|
||||
@@ -821,4 +864,88 @@ describe("DbViewerScreen", () => {
|
||||
const cols = [{ name: "id", data_type: "integer" }];
|
||||
expect(pickDisplayColumn(cols, "id")).toBe("id");
|
||||
});
|
||||
|
||||
it("shows a Redis unsupported state when a redis connection is active", () => {
|
||||
useConnectionStore.setState({
|
||||
connections: [
|
||||
{
|
||||
id: "redis-1",
|
||||
name: "Redis",
|
||||
db_type: "redis",
|
||||
host: "localhost",
|
||||
port: 6379,
|
||||
username: null,
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
} as Connection,
|
||||
],
|
||||
});
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="redis-1"
|
||||
onHome={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/redis browsing isn't supported/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("guards the Objects view for MySQL (capability false)", () => {
|
||||
useConnectionStore.setState({
|
||||
connections: [
|
||||
{
|
||||
id: "c1",
|
||||
name: "Postgres",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "user",
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
} as Connection,
|
||||
{
|
||||
id: "mysql-1",
|
||||
name: "MySQL",
|
||||
db_type: "mysql",
|
||||
host: "localhost",
|
||||
port: 3306,
|
||||
username: "user",
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
} as Connection,
|
||||
],
|
||||
});
|
||||
const { rerender } = render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
onHome={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(/objects/i));
|
||||
rerender(
|
||||
<DbViewerScreen
|
||||
connectionId="mysql-1"
|
||||
onHome={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/objects is unsupported for mysql/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useEffect, useRef, useState, Suspense, lazy } from "react";
|
||||
import { ChevronDown, ChevronUp, Table2, Terminal, AlertCircle } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Table2, Terminal, AlertCircle, Database } from "lucide-react";
|
||||
import { format as formatSql } from "sql-formatter";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
import { DbViewerSidebar } from "./DbViewerSidebar";
|
||||
import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar";
|
||||
import { DbViewerToolbar } from "./DbViewerToolbar";
|
||||
import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils";
|
||||
import { executeQuery } from "../../lib/commands";
|
||||
import { getCapabilities } from "../../lib/dbCapabilities";
|
||||
|
||||
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
|
||||
import { QueryToolbar } from "../editor/QueryToolbar";
|
||||
@@ -173,6 +174,10 @@ export function DbViewerScreen({
|
||||
const connections = useConnectionStore((s) => s.connections);
|
||||
const currentConnection =
|
||||
connections.find((c) => c.id === connectionId) ?? null;
|
||||
const capabilities = getCapabilities(currentConnection?.db_type ?? "postgresql");
|
||||
const isRedisUnsupported = (currentConnection?.db_type ?? "postgresql") === "redis" && !capabilities.explorer;
|
||||
const viewCapabilityKey = NAV_CAPABILITY_KEY[currentView] ?? "explorer";
|
||||
const viewSupported = capabilities[viewCapabilityKey];
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize);
|
||||
const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter);
|
||||
@@ -1241,7 +1246,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{activeTab?.data && (
|
||||
{activeTab && (
|
||||
<TableControls
|
||||
connectionId={connectionId}
|
||||
schema={activeSchema}
|
||||
@@ -1359,6 +1364,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
<DbViewerSidebar
|
||||
currentView={currentView}
|
||||
onNavigate={handleNavigate}
|
||||
capabilities={capabilities}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{connectionError && connectionError !== dismissedError && (
|
||||
@@ -1371,7 +1377,17 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
onDismiss={() => setDismissedError(connectionError)}
|
||||
/>
|
||||
)}
|
||||
{currentView === "db-viewer" ? (
|
||||
{isRedisUnsupported ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
|
||||
<Database size={32} />
|
||||
<span>Redis browsing isn't supported yet — this connection can be tested and used from the Home screen.</span>
|
||||
</div>
|
||||
) : !viewSupported ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
|
||||
<Database size={32} />
|
||||
<span className="capitalize">{currentView.replace("-", " ")} is unsupported for {currentConnection?.db_type}</span>
|
||||
</div>
|
||||
) : currentView === "db-viewer" ? (
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<div
|
||||
className="border-r border-border flex flex-col shrink-0"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { DbViewerSidebar } from "./DbViewerSidebar";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
import { DB_CAPABILITIES } from "../../lib/dbCapabilities";
|
||||
|
||||
describe("DbViewerSidebar", () => {
|
||||
it("renders all navigation icons", () => {
|
||||
@@ -68,4 +69,56 @@ describe("DbViewerSidebar", () => {
|
||||
);
|
||||
expect(screen.getByLabelText("Tools")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows all 5 tools for PostgreSQL (default capabilities)", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/schema visualizer/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/objects/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Objects and Tools for SQLite", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.sqlite} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/schema visualizer/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/objects/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/tools/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Objects, Visualizer, and Tools for MySQL", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.mysql} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/schema visualizer/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/objects/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/tools/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no top nav items for Redis (unsupported browsing)", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.redis} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
expect(screen.queryByLabelText(/explorer/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Queries")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/schema visualizer/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/settings/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,22 @@ import {
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import { DB_CAPABILITIES, type DbCapabilities } from "../../lib/dbCapabilities";
|
||||
|
||||
export interface DbViewerSidebarProps {
|
||||
currentView: string;
|
||||
onNavigate: (view: string) => void;
|
||||
capabilities?: DbCapabilities;
|
||||
}
|
||||
|
||||
export const NAV_CAPABILITY_KEY: Record<string, keyof DbCapabilities> = {
|
||||
"db-viewer": "explorer",
|
||||
queries: "queries",
|
||||
"schema-visualizer": "visualizer",
|
||||
objects: "objects",
|
||||
tools: "tools",
|
||||
};
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -24,6 +34,7 @@ interface NavItem {
|
||||
export function DbViewerSidebar({
|
||||
currentView,
|
||||
onNavigate,
|
||||
capabilities = DB_CAPABILITIES.postgresql,
|
||||
}: DbViewerSidebarProps) {
|
||||
const topItems: NavItem[] = [
|
||||
{ id: "db-viewer", label: "Explorer", icon: <Database size={16} /> },
|
||||
@@ -41,6 +52,10 @@ export function DbViewerSidebar({
|
||||
{ id: "tools", label: "Tools", icon: <DatabaseBackup size={16} /> },
|
||||
];
|
||||
|
||||
const visibleTopItems = topItems.filter(
|
||||
(item) => capabilities[NAV_CAPABILITY_KEY[item.id]]
|
||||
);
|
||||
|
||||
const bottomItems: NavItem[] = [
|
||||
{ id: "home", label: "Home", icon: <Home size={16} /> },
|
||||
{ id: "settings", label: "Settings", icon: <Settings size={16} /> },
|
||||
@@ -73,7 +88,7 @@ export function DbViewerSidebar({
|
||||
return (
|
||||
<div className="w-14 h-full bg-canvas border-r border-border flex flex-col items-center py-3 gap-2 shrink-0">
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
{topItems.map(renderItem)}
|
||||
{visibleTopItems.map(renderItem)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{bottomItems.map(renderItem)}
|
||||
|
||||
@@ -83,4 +83,29 @@ describe("DbViewerToolbar", () => {
|
||||
expect(screen.getByLabelText(/refresh/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/create table/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a disabled schema loading indicator while schema tree is loading", () => {
|
||||
useDbViewerStore.setState({ schemaTreeLoading: true });
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar {...defaultProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByLabelText(/select schema/i)).toBeDisabled();
|
||||
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders schema options when not loading and multiple schemas exist", () => {
|
||||
useDbViewerStore.setState({ schemaTreeLoading: false });
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar
|
||||
{...defaultProps}
|
||||
schemas={["public", "app"]}
|
||||
currentSchema="public"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText("public")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@ export function DbViewerToolbar({
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
const populate = useDbViewerStore((s) => s.populate);
|
||||
const schemaTreeLoading = useDbViewerStore((s) => s.schemaTreeLoading);
|
||||
|
||||
// Focus input when search opens
|
||||
useEffect(() => {
|
||||
@@ -95,7 +96,7 @@ export function DbViewerToolbar({
|
||||
}, [connectionId, refreshing, populate]);
|
||||
|
||||
const hasBelow =
|
||||
searchOpen || databases.length > 1 || schemas.length > 1;
|
||||
searchOpen || databases.length > 1 || schemas.length > 1 || schemaTreeLoading;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -193,7 +194,7 @@ export function DbViewerToolbar({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(databases.length > 1 || schemas.length > 1) && (
|
||||
{(databases.length > 1 || schemas.length > 1 || schemaTreeLoading) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{databases.length > 1 && (
|
||||
<SelectDropdown
|
||||
@@ -208,20 +209,22 @@ export function DbViewerToolbar({
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
{databases.length > 1 && schemas.length > 1 && (
|
||||
{databases.length > 1 && (schemas.length > 1 || schemaTreeLoading) && (
|
||||
<span className="text-border">|</span>
|
||||
)}
|
||||
{schemas.length > 1 && (
|
||||
{(schemas.length > 1 || schemaTreeLoading) && (
|
||||
<SelectDropdown
|
||||
value={currentSchema ?? ""}
|
||||
onChange={setCurrentSchema}
|
||||
options={schemas.map((s) => ({
|
||||
value: s,
|
||||
label: s,
|
||||
}))}
|
||||
placeholder="Select schema"
|
||||
value={schemaTreeLoading ? "" : (currentSchema ?? "")}
|
||||
onChange={schemaTreeLoading ? () => {} : setCurrentSchema}
|
||||
options={
|
||||
schemaTreeLoading
|
||||
? [{ value: "", label: "Loading…" }]
|
||||
: schemas.map((s) => ({ value: s, label: s }))
|
||||
}
|
||||
placeholder={schemaTreeLoading ? "Loading…" : "Select schema"}
|
||||
aria-label="Select schema"
|
||||
variant="ghost"
|
||||
disabled={schemaTreeLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { EditConnectionModal } from "./EditConnectionModal";
|
||||
import type { Connection } from "../../lib/types";
|
||||
|
||||
const { updateConnection, loadAll } = vi.hoisted(() => ({
|
||||
updateConnection: vi.fn().mockResolvedValue({}),
|
||||
loadAll: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../stores/connectionStore", () => ({
|
||||
useConnectionStore: (sel: (s: any) => any) =>
|
||||
sel({ updateConnection, loadAll, folders: [], tags: [] }),
|
||||
}));
|
||||
vi.mock("../../lib/commands", () => ({
|
||||
updateConnection: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
saveConnectionPassword: vi.fn(),
|
||||
saveConnectionSshPassword: vi.fn(),
|
||||
saveConnectionSshPassphrase: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../stores/notificationStore", () => ({
|
||||
useNotificationStore: (sel: (s: any) => any) => sel({ notify: vi.fn() }),
|
||||
}));
|
||||
|
||||
const baseConn: Connection = {
|
||||
id: "c1",
|
||||
name: "Prod",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "u",
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
database: "db",
|
||||
};
|
||||
|
||||
describe("EditConnectionModal", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("renders the Connection Label field and General/SSH tabs", () => {
|
||||
render(
|
||||
<EditConnectionModal
|
||||
connection={baseConn}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^general$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /ssh \/ ssl/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the Supabase SSL hint when editing a Supabase-host connection", () => {
|
||||
const supa = { ...baseConn, host: "db.abcdefghijklmnopqrst.supabase.co" };
|
||||
render(
|
||||
<EditConnectionModal
|
||||
connection={supa}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { updateConnection, testConnection, saveConnectionPassword, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
|
||||
import { detectProviderFromHost } from "../../lib/connectionString";
|
||||
import type { Connection, ConnectionInput } from "../../lib/types";
|
||||
import type { ConnectionFormData } from "../connections/connectionFormData";
|
||||
|
||||
@@ -21,6 +22,8 @@ export function EditConnectionModal({
|
||||
onClose,
|
||||
onSaved,
|
||||
}: EditConnectionModalProps) {
|
||||
const managedPreset = detectProviderFromHost(connection.host);
|
||||
|
||||
const [form, setForm] = useState<ConnectionFormData>(() => ({
|
||||
name: connection.name,
|
||||
environment: (connection.environment as ConnectionFormData["environment"]) ?? null,
|
||||
@@ -132,7 +135,7 @@ export function EditConnectionModal({
|
||||
<AnimatedModal open={open} onClose={onClose}>
|
||||
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<h3 className="font-heading text-text text-lg mb-4">Edit Connection</h3>
|
||||
<DetailedConnectionForm form={form} onChange={(updates) => setForm((prev) => ({ ...prev, ...updates }))} />
|
||||
<DetailedConnectionForm form={form} onChange={(updates) => setForm((prev) => ({ ...prev, ...updates }))} managedPreset={managedPreset} />
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="ghost" onClick={handleTest} disabled={testing}>
|
||||
{testing ? "Testing..." : "Test"}
|
||||
|
||||
@@ -70,4 +70,18 @@ describe("TableTree", () => {
|
||||
expect(state.tabs).toHaveLength(1);
|
||||
expect(state.tabs[0]).toMatchObject({ schema: "public", table: "users" });
|
||||
});
|
||||
|
||||
it("shows Loading when schema tree is loading and no tables are present", () => {
|
||||
useDbViewerStore.setState({ schemaTreeLoading: true });
|
||||
render(<TableTree />);
|
||||
expect(screen.getByText("Loading…")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No tables")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows No tables when not loading and no tables are present", () => {
|
||||
useDbViewerStore.setState({ schemaTreeLoading: false });
|
||||
render(<TableTree />);
|
||||
expect(screen.getByText("No tables")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import * as cmd from "../../lib/commands";
|
||||
export function TableTree({ searchQuery }: { searchQuery?: string }) {
|
||||
const tables = useDbViewerStore((s) => s.tables);
|
||||
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
||||
const schemaTreeLoading = useDbViewerStore((s) => s.schemaTreeLoading);
|
||||
const openTab = useDbViewerStore((s) => s.openTab);
|
||||
const connectionId = useUiStore((s) => s.activeConnectionId);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
@@ -63,7 +64,7 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
|
||||
<div>
|
||||
{filteredTables.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-text-muted">
|
||||
No tables
|
||||
{schemaTreeLoading ? "Loading…" : "No tables"}
|
||||
</div>
|
||||
)}
|
||||
{filteredTables.map((table) => {
|
||||
|
||||
Reference in New Issue
Block a user