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:
2026-08-04 21:13:20 +08:00
committed by GitHub
parent 002d8345ae
commit 1195d2c3f9
67 changed files with 3150 additions and 649 deletions
@@ -106,4 +106,28 @@ describe("ConnectionCard", () => {
expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument();
expect(screen.getByText("production")).toBeInTheDocument();
});
const manyTags: Tag[] = Array.from({ length: 4 }, (_, i) => ({
id: `t${i + 1}`, name: `tag${i + 1}`, color: "#3b82f6", created_at: "",
}));
const connWith4Tags = { ...conn, tag_ids: manyTags.map((t) => t.id) };
it("renders a scrollable tag row when there are 4+ tags", () => {
const { container } = render(
<ConnectionCard connection={connWith4Tags} tags={manyTags} />,
{ wrapper: Wrapper },
);
const row = container.querySelector('[data-testid="tag-row"]');
expect(row).not.toBeNull();
expect(row?.className).toContain("overflow-x-auto");
});
it("does not scroll the tag row when there are 3 or fewer tags", () => {
const { container } = render(
<ConnectionCard connection={conn} tags={tags} />,
{ wrapper: Wrapper },
);
const row = container.querySelector('[data-testid="tag-row"]');
expect(row?.className).not.toContain("overflow-x-auto");
});
});
@@ -134,9 +134,14 @@ function ConnectionCardBase({
<div className="text-xs text-text-muted mb-2 font-mono truncate">
{hostLabel}
</div>
<div className="flex gap-1 flex-wrap">
<div
data-testid="tag-row"
className={`flex gap-1 ${cardTags.length > 3 ? "overflow-x-auto" : "flex-wrap"} max-h-[28px] whitespace-nowrap`}
>
{cardTags.map((t) => (
<TagBadge key={t.id} tag={t} onToggle={onTagToggle} />
<span key={t.id} className="shrink-0 whitespace-nowrap">
<TagBadge tag={t} onToggle={onTagToggle} />
</span>
))}
</div>
</div>
@@ -1,25 +1,20 @@
import { ChevronLeft } from "lucide-react";
import { Button } from "../ui/Button";
import type { NewConnectionMode } from "../../lib/types";
import type { ReactNode } from "react";
interface ConnectionFormShellProps {
mode: NewConnectionMode;
onBack: () => void;
onTest: () => void;
onSave: () => void;
onToggleMode: () => void;
testLoading?: boolean;
saveLoading?: boolean;
children: ReactNode;
}
export function ConnectionFormShell({
mode,
onBack,
onTest,
onSave,
onToggleMode,
testLoading,
saveLoading,
children,
@@ -54,17 +49,7 @@ export function ConnectionFormShell({
{saveLoading ? "Saving..." : "Save Connection"}
</Button>
</div>
<button
type="button"
onClick={onToggleMode}
className="w-full mt-4 text-sm text-text-muted hover:text-text transition-colors cursor-pointer"
>
{mode === "simple"
? "Configure manually instead →"
: "← Back to connection string"}
</button>
</div>
</div>
);
}
}
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from "vitest";
import { useState } from "react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConnectionMetadataRow } from "./ConnectionMetadataRow";
import type { ConnectionFormData } from "./connectionFormData";
import type { Folder, Tag } from "../../lib/types";
const BASE_FORM: ConnectionFormData = {
name: "", environment: null, folder_id: null, tag_ids: [],
connection_string: "", db_type: "postgresql", host: "", port: 5432,
username: null, password: null, database: null, use_keychain: false, ssh_password: null,
};
const folders: Folder[] = [{ id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }];
const tags: Tag[] = [{ id: "t1", name: "prod", color: "#f00", created_at: "" }];
describe("ConnectionMetadataRow", () => {
it("renders a Connection Label input and emits name changes", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
function Wrapper() {
const [form, setForm] = useState(BASE_FORM);
return (
<ConnectionMetadataRow
form={form}
folders={folders}
tags={tags}
onChange={(updates) => {
onChange(updates);
setForm((prev) => ({ ...prev, ...updates }));
}}
/>
);
}
render(<Wrapper />);
const label = screen.getByLabelText(/connection label/i);
await user.type(label, "My DB");
expect(onChange).toHaveBeenLastCalledWith({ name: "My DB" });
});
it("toggles the tag picker via + Add Tags and selects a tag", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ConnectionMetadataRow form={BASE_FORM} folders={folders} tags={tags} onChange={onChange} />);
await user.click(screen.getByRole("button", { name: /add tags/i }));
await user.click(screen.getByRole("button", { name: /prod/i }));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ tag_ids: ["t1"] }));
});
it("changes the environment via Set Env", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ConnectionMetadataRow form={BASE_FORM} folders={folders} tags={tags} onChange={onChange} />);
await user.click(screen.getByRole("button", { name: /set env/i }));
const envSection = screen.getByTestId("environment-section");
await user.click(within(envSection).getByRole("button"));
await user.click(within(envSection).getByRole("button", { name: "Production" }));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ environment: "production" }));
});
it("changes the folder via the folder select", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ConnectionMetadataRow form={BASE_FORM} folders={folders} tags={tags} onChange={onChange} />);
const folderSection = screen.getByTestId("folder-section");
await user.click(within(folderSection).getByRole("button"));
await user.click(within(folderSection).getByRole("button", { name: "Work" }));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ folder_id: "f1" }));
});
});
@@ -0,0 +1,81 @@
import { useState } from "react";
import { Input } from "../ui/Input";
import { EnvironmentSelect } from "./EnvironmentSelect";
import { FolderSelect } from "./FolderSelect";
import { SearchableTagPicker } from "../tags/SearchableTagPicker";
import { Plus, Layers } from "lucide-react";
import type { ConnectionFormData } from "./connectionFormData";
import type { Folder, Tag } from "../../lib/types";
interface ConnectionMetadataRowProps {
form: ConnectionFormData;
folders: Folder[];
tags: Tag[];
onChange: (updates: Partial<ConnectionFormData>) => void;
}
export function ConnectionMetadataRow({ form, folders, tags, onChange }: ConnectionMetadataRowProps) {
const [openPanel, setOpenPanel] = useState<"tags" | "env" | null>(null);
const toggle = (panel: "tags" | "env") =>
setOpenPanel((cur) => (cur === panel ? null : panel));
return (
<div className="space-y-3">
<div>
<label className="block text-sm text-text mb-1.5">Connection Label</label>
<Input
value={form.name}
onChange={(value) => onChange({ name: value })}
placeholder="My Production Database"
aria-label="Connection Label"
/>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => toggle("tags")}
aria-label="Add Tags"
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg border text-xs cursor-pointer transition-colors ${
openPanel === "tags" ? "border-accent text-text" : "border-border text-text-muted hover:text-text"
}`}
>
<Plus size={12} /> Add Tags
</button>
<button
type="button"
onClick={() => toggle("env")}
aria-label="Set Env"
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg border text-xs cursor-pointer transition-colors ${
openPanel === "env" ? "border-accent text-text" : "border-border text-text-muted hover:text-text"
}`}
>
<Layers size={12} /> Set Env
</button>
</div>
{openPanel === "tags" && (
<SearchableTagPicker
tags={tags}
selectedTagIds={form.tag_ids ?? []}
onToggle={(tagId) => {
const current = form.tag_ids ?? [];
const next = current.includes(tagId) ? current.filter((id) => id !== tagId) : [...current, tagId];
onChange({ tag_ids: next });
}}
/>
)}
{openPanel === "env" && (
<div data-testid="environment-section">
<label className="block text-sm text-text mb-1.5">Environment</label>
<EnvironmentSelect value={form.environment} onChange={(value) => onChange({ environment: value })} />
</div>
)}
<div data-testid="folder-section">
<label className="block text-sm text-text mb-1.5">Folder</label>
<FolderSelect folders={folders} value={form.folder_id ?? null} onChange={(value) => onChange({ folder_id: value })} />
</div>
</div>
);
}
@@ -26,6 +26,8 @@ const BASE_FORM: ConnectionFormData = {
function StatefulForm(
props: Omit<DetailedConnectionFormProps, "form" | "onChange"> & {
onChange?: (updates: Partial<ConnectionFormData>) => void;
folders?: unknown;
tags?: unknown;
},
) {
const [form, setForm] = useState<ConnectionFormData>(BASE_FORM);
@@ -52,4 +54,24 @@ describe("DetailedConnectionForm", () => {
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ host: "localhost" }));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ port: 5432 }));
});
it("renders only General and SSH / SSL tabs (no Tags & Env)", () => {
render(<StatefulForm folders={[]} tags={[]} onChange={vi.fn()} />);
expect(screen.getByRole("button", { name: /^general$/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /ssh \/ ssl/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /tags & env/i })).not.toBeInTheDocument();
});
it("renders the metadata row (Connection Label) above the tabs", () => {
render(<StatefulForm folders={[]} tags={[]} onChange={() => {}} />);
expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument();
});
it("updates host via the General tab", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<StatefulForm folders={[]} tags={[]} onChange={onChange} />);
await user.type(screen.getByLabelText(/host/i), "localhost");
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ host: "localhost" }));
});
});
@@ -1,56 +1,35 @@
import { useState } from "react";
import { GeneralTab } from "./GeneralTab";
import { SshSslTab } from "./SshSslTab";
import { TagsEnvTab } from "./TagsEnvTab";
import { ConnectionMetadataRow } from "./ConnectionMetadataRow";
import { useConnectionStore } from "../../stores/connectionStore";
import type { ConnectionFormData } from "./connectionFormData";
export interface DetailedConnectionFormProps {
form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void;
managedPreset?: "supabase" | "neon" | null;
}
export function DetailedConnectionForm({ form, onChange }: DetailedConnectionFormProps) {
const [activeTab, setActiveTab] = useState<"general" | "ssh" | "tags">("general");
export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) {
const [activeTab, setActiveTab] = useState<"general" | "ssh">("general");
const folders = useConnectionStore((s) => s.folders);
const tags = useConnectionStore((s) => s.tags);
return (
<div>
<div className="flex gap-6 border-b border-border mb-4">
<button
type="button"
onClick={() => setActiveTab("general")}
className={`pb-2 text-sm cursor-pointer transition-colors ${
activeTab === "general" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
}`}
>
General
</button>
<button
type="button"
onClick={() => setActiveTab("ssh")}
className={`pb-2 text-sm cursor-pointer transition-colors ${
activeTab === "ssh" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
}`}
>
SSH / SSL
</button>
<button
type="button"
onClick={() => setActiveTab("tags")}
className={`pb-2 text-sm cursor-pointer transition-colors ${
activeTab === "tags" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
}`}
>
Tags & Env
</button>
<div className="space-y-4">
<ConnectionMetadataRow form={form} folders={folders ?? []} tags={tags ?? []} onChange={onChange} />
<div>
<div className="flex gap-6 border-b border-border mb-4">
<button type="button" onClick={() => setActiveTab("general")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "general" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>General</button>
<button type="button" onClick={() => setActiveTab("ssh")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "ssh" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>SSH / SSL</button>
</div>
{activeTab === "general" ? (
<GeneralTab form={form} onChange={onChange} managedPreset={managedPreset} />
) : (
<SshSslTab form={form as unknown as Record<string, unknown>} onChange={onChange as (u: Record<string, unknown>) => void} />
)}
</div>
{activeTab === "general" ? (
<GeneralTab form={form} onChange={onChange} />
) : activeTab === "ssh" ? (
<SshSslTab form={form as unknown as Record<string, unknown>} onChange={onChange as (updates: Record<string, unknown>) => void} />
) : (
<TagsEnvTab form={form} onChange={onChange} />
)}
</div>
);
}
+34 -29
View File
@@ -4,49 +4,54 @@ import { GeneralTab } from "./GeneralTab";
import type { ConnectionFormData } from "./connectionFormData";
const BASE_FORM: ConnectionFormData = {
name: "",
environment: null,
folder_id: null,
tag_ids: [],
connection_string: "",
db_type: "postgresql",
host: "localhost",
port: 5432,
username: "postgres",
password: "secret",
database: "mydb",
use_keychain: true,
ssh_password: null,
name: "My DB", environment: null, folder_id: null, tag_ids: [],
connection_string: "postgresql://u:p@localhost:5432/db", db_type: "postgresql",
host: "localhost", port: 5432, username: "u", password: "p", database: "db",
use_keychain: false, ssh_password: null,
};
describe("GeneralTab", () => {
it("renders a Name input and passes value to onChange", () => {
const onChange = vi.fn();
render(<GeneralTab form={BASE_FORM} onChange={onChange} />);
const nameInput = screen.getByLabelText("Name");
expect(nameInput).toBeInTheDocument();
expect(nameInput).toHaveValue(BASE_FORM.name);
fireEvent.change(nameInput, { target: { value: "My New Name" } });
expect(onChange).toHaveBeenCalledWith({ name: "My New Name" });
it("renders the Connection URI input (not Name)", () => {
render(<GeneralTab form={BASE_FORM} onChange={() => {}} />);
expect(screen.getByLabelText(/connection uri/i)).toBeInTheDocument();
expect(screen.queryByLabelText("Name")).not.toBeInTheDocument();
});
it("renders host, port, user, password, and database fields", () => {
it("renders the OR divider, host, port, user, password, database, and keychain", () => {
render(<GeneralTab form={BASE_FORM} onChange={() => {}} />);
expect(screen.getByTestId("or-divider")).toBeInTheDocument();
expect(screen.getByLabelText("Host")).toBeInTheDocument();
expect(screen.getByLabelText("Port")).toBeInTheDocument();
expect(screen.getByLabelText("Authentication")).toBeInTheDocument();
expect(screen.getByLabelText("User")).toBeInTheDocument();
expect(screen.getByLabelText("Password")).toBeInTheDocument();
expect(screen.getByLabelText("Database")).toBeInTheDocument();
expect(screen.getByLabelText(/database/i)).toBeInTheDocument();
expect(screen.getByLabelText(/keychain/i)).toBeInTheDocument();
});
it("hides host and port for sqlite but shows database", () => {
render(<GeneralTab form={{ ...BASE_FORM, db_type: "sqlite" }} onChange={() => {}} />);
it("emits host changes", () => {
const onChange = vi.fn();
render(<GeneralTab form={BASE_FORM} onChange={onChange} />);
fireEvent.change(screen.getByLabelText("Host"), { target: { value: "newhost" } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ host: "newhost" }));
});
it("shows SqlitePathInput (File Path) and hides Host/Port for sqlite", () => {
render(<GeneralTab form={{ ...BASE_FORM, db_type: "sqlite", host: "/data/x.db", port: null }} onChange={() => {}} />);
expect(screen.getByLabelText(/file path/i)).toBeInTheDocument();
expect(screen.queryByLabelText("Host")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Port")).not.toBeInTheDocument();
expect(screen.getByLabelText("Database")).toBeInTheDocument();
});
it("emits a connection_string change when the URI field is edited", () => {
const onChange = vi.fn();
render(<GeneralTab form={BASE_FORM} onChange={onChange} />);
fireEvent.change(screen.getByLabelText(/connection uri/i), { target: { value: "postgresql://u@h/db" } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ connection_string: "postgresql://u@h/db" }));
});
it("shows an SSL hint when a managed-PG preset is active", () => {
render(<GeneralTab form={{ ...BASE_FORM, db_type: "postgresql" }} managedPreset="supabase" onChange={() => {}} />);
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
});
});
+60 -77
View File
@@ -1,103 +1,86 @@
import { Input } from "../ui/Input";
import { PasswordInput } from "./PasswordInput";
import { SqlitePathInput } from "./SqlitePathInput";
import { INPUT_ROUNDING } from "../../lib/uiConstants";
import type { ConnectionFormData } from "./connectionFormData";
export interface GeneralTabProps {
form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void;
managedPreset?: "supabase" | "neon" | null;
}
const AUTH_OPTIONS = ["User & Password"];
export function GeneralTab({ form, onChange }: GeneralTabProps) {
export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
const isSqlite = form.db_type === "sqlite";
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-text mb-1.5">Name</label>
<Input
value={form.name}
onChange={(value) => onChange({ name: value })}
placeholder="My Production Database"
aria-label="Name"
/>
<label className="block text-sm text-text mb-1.5">{isSqlite ? "File Path" : "Connection URI"}</label>
{isSqlite ? (
<SqlitePathInput value={form.host} onChange={(value) => onChange({ host: value })} />
) : (
<input
value={form.connection_string}
onChange={(e) => onChange({ connection_string: e.target.value })}
placeholder="postgresql://user:password@host:5432/database"
aria-label="Connection URI"
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-2 text-sm text-text font-mono placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors`}
/>
)}
{managedPreset && (
<p className="text-xs text-accent-muted mt-1.5">
{managedPreset === "supabase" ? "Supabase" : "NeonDB"} requires SSL enable it under SSH / SSL.
</p>
)}
</div>
{!isSqlite && (
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-sm text-text mb-1.5">Host</label>
<Input
value={form.host}
onChange={(value) => onChange({ host: value })}
placeholder="localhost"
aria-label="Host"
/>
<>
<div className="flex items-center gap-3 my-1" data-testid="or-divider">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text-muted">OR</span>
<div className="flex-1 h-px bg-border" />
</div>
<div className="w-28">
<label className="block text-sm text-text mb-1.5">Port</label>
<Input
type="number"
value={form.port?.toString() ?? ""}
onChange={(value) => onChange({ port: value === "" ? null : Number(value) })}
placeholder="5432"
aria-label="Port"
/>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-sm text-text mb-1.5">Host</label>
<Input value={form.host} onChange={(value) => onChange({ host: value })} placeholder="localhost" aria-label="Host" />
</div>
<div className="w-28">
<label className="block text-sm text-text mb-1.5">Port</label>
<Input type="number" value={form.port?.toString() ?? ""} onChange={(value) => onChange({ port: value === "" ? null : Number(value) })} placeholder="5432" aria-label="Port" />
</div>
</div>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Authentication</label>
<select
value={AUTH_OPTIONS[0]}
disabled
aria-label="Authentication"
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-2 text-sm text-text opacity-70 cursor-not-allowed`}
>
{AUTH_OPTIONS.map((opt) => <option key={opt}>{opt}</option>)}
</select>
</div>
<div>
<label className="block text-sm text-text mb-1.5">User</label>
<Input value={form.username ?? ""} onChange={(value) => onChange({ username: value || null })} placeholder="postgres" aria-label="User" />
</div>
<div>
<label className="block text-sm text-text mb-1.5">Password</label>
<PasswordInput value={form.password ?? ""} onChange={(value) => onChange({ password: value || null })} placeholder="••••••••" aria-label="Password" />
</div>
<div>
<label className="block text-sm text-text mb-1.5">Database (optional)</label>
<Input value={form.database ?? ""} onChange={(value) => onChange({ database: value || null })} placeholder="database" aria-label="Database" />
</div>
</>
)}
<div>
<label className="block text-sm text-text mb-1.5">Authentication</label>
<select
value={AUTH_OPTIONS[0]}
disabled
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text opacity-70 cursor-not-allowed"
>
{AUTH_OPTIONS.map((opt) => (
<option key={opt}>{opt}</option>
))}
</select>
</div>
<div>
<label className="block text-sm text-text mb-1.5">User</label>
<Input
value={form.username ?? ""}
onChange={(value) => onChange({ username: value || null })}
placeholder="postgres"
aria-label="User"
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Password</label>
<PasswordInput
value={form.password ?? ""}
onChange={(value) => onChange({ password: value || null })}
placeholder="••••••••"
aria-label="Password"
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Database (optional)</label>
<Input
value={form.database ?? ""}
onChange={(value) => onChange({ database: value || null })}
placeholder="database"
aria-label="Database"
/>
</div>
<label className="flex items-center gap-2 text-sm text-text cursor-pointer">
<input
type="checkbox"
checked={form.use_keychain}
onChange={(e) => onChange({ use_keychain: e.target.checked })}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<input type="checkbox" checked={form.use_keychain} onChange={(e) => onChange({ use_keychain: e.target.checked })} aria-label="Enable keychain" className="rounded border-border bg-surface text-accent focus:ring-accent" />
Enable keychain
</label>
</div>
@@ -4,11 +4,12 @@ import userEvent from "@testing-library/user-event";
import { NewConnectionScreen } from "./NewConnectionScreen";
import { useSettingsStore } from "../../stores/settingsStore";
const { createConnection, notify, testConnection } = vi.hoisted(() => ({
createConnection: vi.fn().mockResolvedValue({}),
notify: vi.fn(),
testConnection: vi.fn().mockResolvedValue({ ok: true }),
}));
const { createConnection, notify, testConnection } = vi.hoisted(() =>
({
createConnection: vi.fn().mockResolvedValue({}),
notify: vi.fn(),
testConnection: vi.fn().mockResolvedValue({ ok: true }),
}));
vi.mock("../../stores/connectionStore", () => ({
createConnection,
@@ -32,148 +33,88 @@ describe("NewConnectionScreen", () => {
useSettingsStore.setState({ settings: null, loading: false, error: null });
});
it("prefills the port from the default_ports setting", async () => {
const user = userEvent.setup();
useSettingsStore.setState({
settings: {
confirm_before_delete: true,
default_folder_id: null,
theme: "dark",
font_size: "medium",
default_ports: { postgresql: 6543, mysql: 3306, sqlite: null, redis: 6379 },
tag_order: null,
table_refresh_rate: 30,
table_page_size: 50,
shortcuts: {},
accent_color: "#2563EB",
editor_font_size: 13,
editor_font_family: "Space Mono",
editor_word_wrap: "off",
editor_minimap: false,
editor_tab_size: 4,
},
});
render(<NewConnectionScreen folders={[]} tags={[]} />);
await user.click(screen.getByText(/configure manually instead/i));
expect(screen.getByLabelText("Port")).toHaveValue(6543);
it("entry stage: renders Connection URI input and provider grid, not the full form", () => {
render(<NewConnectionScreen />);
expect(screen.getByLabelText(/connection uri/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /PostgreSQL/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /MySQL/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Supabase/i })).toBeInTheDocument();
expect(screen.queryByLabelText(/connection label/i)).not.toBeInTheDocument();
});
it("falls back to 5432 when no default port is configured", async () => {
it("paste a recognized URL reveals the form and fills fields", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen folders={[]} tags={[]} />);
await user.click(screen.getByText(/configure manually instead/i));
expect(screen.getByLabelText("Port")).toHaveValue(5432);
});
it("switches to detailed mode and back", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen folders={[]} tags={[]} />);
await user.click(screen.getByText(/configure manually instead/i));
expect(screen.getByText(/general/i)).toBeInTheDocument();
await user.click(screen.getByText(/back to connection string/i));
expect(screen.getByLabelText(/connection string/i)).toBeInTheDocument();
});
it("parses prefilled connection string and populates fields", async () => {
const user = userEvent.setup();
render(
<NewConnectionScreen
prefilledConnectionString="postgresql://u:p@localhost:5432/db"
folders={[]}
tags={[]}
/>,
);
expect(screen.getByLabelText(/connection string/i)).toHaveValue(
"postgresql://u:p@localhost:5432/db",
);
await user.click(screen.getByText(/configure manually instead/i));
render(<NewConnectionScreen />);
await user.type(screen.getByLabelText(/connection uri/i), "postgresql://u:p@localhost:5432/db");
expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument();
expect(screen.getByLabelText("Host")).toHaveValue("localhost");
expect(screen.getByLabelText("Port")).toHaveValue(5432);
expect(screen.getByLabelText("User")).toHaveValue("u");
expect(screen.getByLabelText("Database")).toHaveValue("db");
});
it("shows validation error and does not call createConnection when saving empty form", async () => {
it("clicking a provider tab reveals the form with that db_type", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen folders={[]} tags={[]} />);
await user.click(screen.getByText("Save Connection"));
render(<NewConnectionScreen />);
await user.click(screen.getByRole("button", { name: /MySQL/i }));
expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument();
expect(screen.getByLabelText("Port")).toHaveValue(3306);
});
it("SQLite tab swaps the URI input for a File Path input", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.click(screen.getByRole("button", { name: /SQLite/i }));
expect(screen.getByLabelText(/file path/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/connection uri/i)).not.toBeInTheDocument();
});
it("shows validation error when saving an empty configured form", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.click(screen.getByRole("button", { name: /PostgreSQL/i }));
await user.click(screen.getByText("Save Connection"));
expect(notify).toHaveBeenCalledWith("name is required", "error");
expect(createConnection).not.toHaveBeenCalled();
});
it("saves a connection and invokes onSaved when required fields are filled", async () => {
it("saves a connection with label + parsed URL", async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
render(<NewConnectionScreen folders={[]} tags={[]} onSaved={onSaved} />);
await user.type(screen.getByLabelText("Connection Label"), "Local DB");
await user.type(
screen.getByLabelText("Connection String"),
"postgresql://u:p@localhost:5432/db",
);
render(<NewConnectionScreen onSaved={onSaved} />);
await user.type(screen.getByLabelText(/connection uri/i), "postgresql://u:p@localhost:5432/db");
await user.type(screen.getByLabelText(/connection label/i), "Local DB");
await user.click(screen.getByText("Save Connection"));
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
expect(createConnection).toHaveBeenCalledWith(
expect.objectContaining({
name: "Local DB",
db_type: "postgresql",
host: "localhost",
port: 5432,
username: "u",
password: "p",
database: "db",
connection_string: "postgresql://u:p@localhost:5432/db",
folder_id: null,
tag_ids: [],
environment: null,
use_keychain: false,
}),
);
expect(createConnection).toHaveBeenCalledWith(expect.objectContaining({
name: "Local DB", db_type: "postgresql", host: "localhost", port: 5432,
username: "u", password: "p", database: "db",
}));
expect(notify).toHaveBeenCalledWith("Connection saved", "success");
expect(onSaved).toHaveBeenCalled();
});
it("calls testConnection when Test Connection is clicked", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen folders={[]} tags={[]} />);
await user.type(screen.getByLabelText("Connection Label"), "Local DB");
await user.type(
screen.getByLabelText("Connection String"),
"postgresql://u:p@localhost:5432/db",
);
render(<NewConnectionScreen />);
await user.type(screen.getByLabelText(/connection uri/i), "postgresql://u:p@localhost:5432/db");
await user.type(screen.getByLabelText(/connection label/i), "Local DB");
await user.click(screen.getByText("Test Connection"));
await waitFor(() => expect(testConnection).toHaveBeenCalledTimes(1));
expect(testConnection).toHaveBeenCalledWith(
expect.objectContaining({
name: "Local DB",
db_type: "postgresql",
host: "localhost",
port: 5432,
username: "u",
password: "p",
database: "db",
}),
);
expect(notify).toHaveBeenCalledWith("Connection successful", "success");
});
it("invokes onCancel when Back is clicked", async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(<NewConnectionScreen folders={[]} tags={[]} onCancel={onCancel} />);
render(<NewConnectionScreen onCancel={onCancel} />);
await user.click(screen.getByRole("button", { name: "Back" }));
expect(onCancel).toHaveBeenCalled();
});
it("prefills from prefilledConnectionString and reveals the form", async () => {
render(<NewConnectionScreen prefilledConnectionString="postgresql://u:p@localhost:5432/db" />);
expect(screen.getByLabelText(/connection uri/i)).toHaveValue("postgresql://u:p@localhost:5432/db");
expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument();
expect(screen.getByLabelText("Host")).toHaveValue("localhost");
});
});
@@ -3,28 +3,37 @@ import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { useSettingsStore } from "../../stores/settingsStore";
import { ConnectionFormShell } from "./ConnectionFormShell";
import { SimpleConnectionForm } from "./SimpleConnectionForm";
import { DetailedConnectionForm } from "./DetailedConnectionForm";
import { parseConnectionString } from "../../lib/connectionString";
import { ProviderTabsGrid } from "./ProviderTabsGrid";
import { ProviderSetupGuide } from "./ProviderSetupGuide";
import {
parseConnectionString,
detectProviderFromHost,
} from "../../lib/connectionString";
import { getProviderById, type ProviderId } from "../../lib/providers";
import { validateConnectionInput } from "../../lib/utils";
import { testConnection } from "../../lib/commands";
import type {
Folder,
Tag,
NewConnectionMode,
ConnectionInput,
} from "../../lib/types";
import { INPUT_ROUNDING } from "../../lib/uiConstants";
import type { ConnectionInput, Folder, Tag } from "../../lib/types";
import type { ConnectionFormData } from "./connectionFormData";
interface NewConnectionScreenProps {
defaultFolderId?: string | null;
prefilledConnectionString?: string;
folders: Folder[];
tags: Tag[];
folders?: Folder[];
tags?: Tag[];
onSaved?: () => void;
onCancel?: () => void;
}
type Stage = "entry" | "configured";
const FALLBACK_PORTS: Record<string, number> = {
postgresql: 5432,
mysql: 3306,
redis: 6379,
};
function createEmptyForm(
defaultFolderId: string | null = null,
defaultPorts?: Record<string, number | null>,
@@ -48,19 +57,24 @@ function createEmptyForm(
function getDefaultPort(dbType: string): number {
return (
useSettingsStore.getState().settings?.default_ports?.[dbType] ?? 5432
useSettingsStore.getState().settings?.default_ports?.[dbType] ??
FALLBACK_PORTS[dbType] ??
5432
);
}
export function NewConnectionScreen({
defaultFolderId = null,
prefilledConnectionString = "",
folders,
tags,
folders: _folders,
tags: _tags,
onSaved,
onCancel,
}: NewConnectionScreenProps) {
const [mode, setMode] = useState<NewConnectionMode>("simple");
const [stage, setStage] = useState<Stage>("entry");
const [managedPreset, setManagedPreset] = useState<
"supabase" | "neon" | null
>(null);
const [form, setForm] = useState<ConnectionFormData>(() =>
createEmptyForm(
defaultFolderId,
@@ -72,11 +86,23 @@ export function NewConnectionScreen({
const createConnection = useConnectionStore((s) => s.createConnection);
const notify = useNotificationStore((s) => s.notify);
const revealConfigured = useCallback(
(updates: Partial<ConnectionFormData>) => {
setForm((prev) => ({ ...prev, ...updates }));
setStage("configured");
},
[],
);
const handleConnectionStringChange = useCallback((value: string) => {
setForm((prev) => {
const parsed = parseConnectionString(value);
if (!parsed) return { ...prev, connection_string: value };
return {
const parsed = parseConnectionString(value);
if (parsed) {
const provider =
parsed.db_type === "postgresql"
? detectProviderFromHost(parsed.host)
: null;
setManagedPreset(provider);
setForm((prev) => ({
...prev,
connection_string: value,
db_type: parsed.db_type,
@@ -85,19 +111,82 @@ export function NewConnectionScreen({
username: parsed.username,
password: parsed.password,
database: parsed.database,
};
});
}));
} else {
setManagedPreset(null);
setForm((prev) => ({ ...prev, connection_string: value }));
}
}, []);
useEffect(() => {
if (
stage === "entry" &&
form.connection_string &&
parseConnectionString(form.connection_string)
) {
setStage("configured");
}
}, [form.connection_string, stage]);
useEffect(() => {
if (prefilledConnectionString) {
handleConnectionStringChange(prefilledConnectionString);
}
}, [prefilledConnectionString, handleConnectionStringChange]);
const updateForm = useCallback((updates: Partial<ConnectionFormData>) => {
setForm((prev) => ({ ...prev, ...updates }));
}, []);
const handleSelectProvider = useCallback(
(id: ProviderId) => {
const provider = getProviderById(id)!;
setManagedPreset(
provider.isManagedPreset ? (id as "supabase" | "neon") : null,
);
revealConfigured({
db_type: provider.dbType,
port:
provider.dbType === "sqlite"
? null
: getDefaultPort(provider.dbType),
...(provider.dbType === "sqlite" ? { host: "" } : {}),
});
},
[revealConfigured],
);
const updateForm = useCallback(
(updates: Partial<ConnectionFormData>) => {
setForm((prev) => {
if (
"connection_string" in updates &&
updates.connection_string !== undefined
) {
const value = updates.connection_string;
const parsed = parseConnectionString(value);
if (parsed) {
const provider =
parsed.db_type === "postgresql"
? detectProviderFromHost(parsed.host)
: null;
setManagedPreset(provider);
return {
...prev,
...updates,
db_type: parsed.db_type,
host: parsed.host,
port:
parsed.port ??
getDefaultPort(parsed.db_type),
username: parsed.username,
password: parsed.password,
database: parsed.database,
};
}
return { ...prev, ...updates };
}
return { ...prev, ...updates };
});
},
[],
);
const buildPayload = useCallback((): ConnectionInput => {
return {
@@ -169,44 +258,82 @@ export function NewConnectionScreen({
}
}, [validate, notify, testConnection, buildPayload]);
const onSimpleChange = useCallback(
(updates: Partial<ConnectionFormData>) => {
if (
"connection_string" in updates &&
updates.connection_string !== undefined
) {
handleConnectionStringChange(updates.connection_string);
} else {
updateForm(updates);
}
},
[handleConnectionStringChange, updateForm],
);
const onToggleMode = useCallback(() => {
setMode((m) => (m === "simple" ? "detailed" : "simple"));
}, []);
const isEntry = stage === "entry";
const showEntryUri = isEntry || form.db_type !== "sqlite";
return (
<ConnectionFormShell
mode={mode}
onBack={() => onCancel?.()}
onTest={handleTest}
onSave={handleSave}
onToggleMode={onToggleMode}
testLoading={testLoading}
saveLoading={saveLoading}
>
{mode === "simple" ? (
<SimpleConnectionForm
form={form}
folders={folders}
tags={tags}
onChange={onSimpleChange}
/>
<div>
{isEntry && showEntryUri && (
<label className="block text-sm text-text mb-1.5">
Connection URI
</label>
)}
{isEntry && form.db_type === "sqlite" ? (
<div>
<label className="block text-sm text-text mb-1.5">
File Path
</label>
<input
value={form.host}
onChange={(e) =>
setForm((prev) => ({
...prev,
host: e.target.value,
}))
}
placeholder="/path/to/database.sqlite"
aria-label="File Path"
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-3 text-sm text-text font-mono placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors`}
/>
</div>
) : (
<input
value={form.connection_string}
onChange={(e) =>
handleConnectionStringChange(e.target.value)
}
placeholder="postgresql://user:password@host:5432/database"
aria-label={isEntry ? "Connection URI" : undefined}
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-3 text-sm text-text font-mono placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors ${
isEntry ? "" : "sr-only"
}`}
/>
)}
{isEntry && showEntryUri && (
<p className="text-xs text-text-muted mt-1.5">
Paste a connection string to auto-detect, or pick a
provider below.
</p>
)}
</div>
{isEntry ? (
<>
<div className="flex items-center gap-3 my-1">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text-muted">OR</span>
<div className="flex-1 h-px bg-border" />
</div>
<ProviderTabsGrid onSelect={handleSelectProvider} />
</>
) : (
<DetailedConnectionForm form={form} onChange={updateForm} />
<>
{managedPreset && (
<ProviderSetupGuide provider={managedPreset} />
)}
<DetailedConnectionForm
form={form}
onChange={updateForm}
/>
</>
)}
</ConnectionFormShell>
);
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState, forwardRef } from "react";
import { Eye, EyeOff } from "lucide-react";
import type { KeyboardEvent } from "react";
import { INPUT_ROUNDING } from "../../lib/uiConstants";
interface PasswordInputProps {
value?: string;
@@ -18,7 +19,7 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
<input
ref={ref}
type={visible ? "text" : "password"}
className={`w-full rounded-full bg-surface border border-border px-4 py-2 pr-10 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer ${className}`}
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-2 pr-10 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer ${className}`}
onChange={(e) => onChange?.(e.target.value)}
onKeyDown={(e) => onKeyDown?.(e)}
{...rest}
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProviderSetupGuide } from "./ProviderSetupGuide";
describe("ProviderSetupGuide", () => {
it("renders Supabase SSL note immediately and steps after expanding", async () => {
const user = userEvent.setup();
render(<ProviderSetupGuide provider="supabase" />);
expect(screen.getByText(/SSL is required by Supabase/i)).toBeInTheDocument();
expect(screen.queryByText(/Open the Supabase Dashboard/i)).not.toBeInTheDocument();
const toggle = screen.getByRole("button", { name: /how to connect/i });
await user.click(toggle);
expect(screen.getByText(/Open the Supabase Dashboard/i)).toBeInTheDocument();
});
it("renders Neon SSL note immediately and steps after expanding", async () => {
const user = userEvent.setup();
render(<ProviderSetupGuide provider="neon" />);
expect(screen.getByText(/Neon requires SSL/i)).toBeInTheDocument();
expect(screen.queryByText(/Open the Neon Console/i)).not.toBeInTheDocument();
const toggle = screen.getByRole("button", { name: /how to connect/i });
await user.click(toggle);
expect(screen.getByText(/Open the Neon Console/i)).toBeInTheDocument();
});
it("starts collapsed and expands on toggle", async () => {
const user = userEvent.setup();
render(<ProviderSetupGuide provider="supabase" />);
const toggle = screen.getByRole("button", { name: /how to connect/i });
expect(screen.queryByText(/Open the Supabase Dashboard/i)).not.toBeInTheDocument();
await user.click(toggle);
expect(screen.getByText(/Open the Supabase Dashboard/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { SETUP_GUIDES } from "../../lib/providers";
interface ProviderSetupGuideProps {
provider: "supabase" | "neon";
}
const SSL_NOTE: Record<"supabase" | "neon", string> = {
supabase: "SSL is required by Supabase.",
neon: "Neon requires SSL.",
};
export function ProviderSetupGuide({ provider }: ProviderSetupGuideProps) {
const [open, setOpen] = useState(false);
const guide = SETUP_GUIDES[provider];
return (
<div className="mt-2 border border-border rounded-lg bg-surface">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label="How to connect"
className="w-full flex items-center justify-between px-3 py-2 text-xs text-text-muted hover:text-text cursor-pointer transition-colors"
>
<span>How to connect</span>
<ChevronDown
size={14}
className={open ? "rotate-180 transition-transform" : "transition-transform"}
aria-hidden="true"
/>
</button>
{guide.sslRequired && (
<p className="px-3 pb-2 text-xs text-accent-muted">{SSL_NOTE[provider]}</p>
)}
{open && (
<ol className="px-3 pb-3 space-y-2 list-decimal list-inside text-xs text-text-muted">
{guide.steps.map((step, i) => (
<li key={i} className="space-y-0.5">
<span className="text-text font-medium">{step.title}</span>
<p className="text-text-muted">{step.detail}</p>
</li>
))}
</ol>
)}
</div>
);
}
@@ -0,0 +1,42 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProviderTabsGrid } from "./ProviderTabsGrid";
describe("ProviderTabsGrid", () => {
it("renders exactly 6 provider cards in order", () => {
render(<ProviderTabsGrid onSelect={() => {}} />);
expect(screen.getByRole("button", { name: /PostgreSQL/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /MySQL/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /SQLite/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Redis/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Supabase/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /NeonDB/i })).toBeInTheDocument();
});
it("applies the 2-column grid layout class", () => {
const { container } = render(<ProviderTabsGrid onSelect={() => {}} />);
const grid = container.querySelector('[data-testid="provider-grid"]');
expect(grid?.className).toContain("grid-cols-2");
});
it("calls onSelect with the provider id when a card is clicked", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<ProviderTabsGrid onSelect={onSelect} />);
await user.click(screen.getByRole("button", { name: /MySQL/i }));
expect(onSelect).toHaveBeenCalledWith("mysql");
});
it("highlights the selected provider", () => {
render(<ProviderTabsGrid onSelect={() => {}} selectedId="mysql" />);
const mysqlBtn = screen.getByRole("button", { name: /MySQL/i });
expect(mysqlBtn.className).toContain("border-accent");
});
it("renders setup-guide content on Supabase and NeonDB cards", () => {
render(<ProviderTabsGrid onSelect={() => {}} />);
expect(screen.getByText(/managed postgresql/i)).toBeInTheDocument();
expect(screen.getByText(/serverless postgres/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,40 @@
import { PROVIDER_TABS, SETUP_GUIDES, type ProviderId } from "../../lib/providers";
import { DbIcon, ProviderIcon } from "../../lib/dbIcons";
interface ProviderTabsGridProps {
selectedId?: ProviderId | null;
onSelect: (id: ProviderId) => void;
}
export function ProviderTabsGrid({ selectedId, onSelect }: ProviderTabsGridProps) {
return (
<div data-testid="provider-grid" className="grid grid-cols-2 gap-3">
{PROVIDER_TABS.map((p) => {
const isSelected = selectedId === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => onSelect(p.id)}
aria-label={p.label}
className={`flex flex-col items-center gap-2 p-4 rounded-lg border bg-surface transition-colors cursor-pointer ${
isSelected
? "border-accent text-text"
: "border-border text-text-muted hover:border-accent/50 hover:text-text"
}`}
>
<span className="w-8 h-8 flex items-center justify-center">
{p.isManagedPreset ? <ProviderIcon id={p.id as "supabase" | "neon"} size={24} /> : <DbIcon type={p.dbType} size={24} />}
</span>
<span className="text-sm font-medium">{p.label}</span>
{p.isManagedPreset && (
<span className="text-[10px] text-text-muted text-center leading-tight">
{SETUP_GUIDES[p.id as keyof typeof SETUP_GUIDES].blurb}
</span>
)}
</button>
);
})}
</div>
);
}
@@ -1,82 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { SimpleConnectionForm } from "./SimpleConnectionForm";
import type { ConnectionFormData } from "./connectionFormData";
import type { SimpleConnectionFormProps } from "./SimpleConnectionForm";
const BASE_FORM: ConnectionFormData = {
name: "",
environment: null,
folder_id: null,
tag_ids: [],
connection_string: "",
db_type: "postgresql",
host: "",
port: 5432,
username: null,
password: null,
database: null,
use_keychain: false,
ssh_password: null,
};
function StatefulForm(
props: Omit<SimpleConnectionFormProps, "form" | "onChange"> & {
onChange?: (updates: Partial<ConnectionFormData>) => void;
},
) {
const [form, setForm] = useState<ConnectionFormData>(BASE_FORM);
return (
<SimpleConnectionForm
{...props}
form={form}
onChange={(updates) => {
setForm((prev) => ({ ...prev, ...updates }));
props.onChange?.(updates);
}}
/>
);
}
describe("SimpleConnectionForm", () => {
it("updates the connection string", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<StatefulForm folders={[]} tags={[]} onChange={onChange} />);
const input = screen.getByLabelText(/connection string/i);
await user.type(input, "postgresql://a@b/c");
expect(onChange).toHaveBeenLastCalledWith({
connection_string: "postgresql://a@b/c",
});
expect(input).toHaveValue("postgresql://a@b/c");
});
it("toggles a tag via the SearchableTagPicker", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const tags = [
{
id: "tag-1",
name: "Work",
color: "#ff0000",
created_at: "2024-01-01T00:00:00Z",
},
{
id: "tag-2",
name: "Personal",
color: "#00ff00",
created_at: "2024-01-01T00:00:00Z",
},
];
render(<StatefulForm folders={[]} tags={tags} onChange={onChange} />);
const workTag = screen.getByText("Work");
await user.click(workTag);
expect(onChange).toHaveBeenLastCalledWith({ tag_ids: ["tag-1"] });
await user.click(workTag);
expect(onChange).toHaveBeenLastCalledWith({ tag_ids: [] });
});
});
@@ -1,84 +0,0 @@
import { Input } from "../ui/Input";
import { EnvironmentSelect } from "./EnvironmentSelect";
import { FolderSelect } from "./FolderSelect";
import { ConnectionStringInput } from "./ConnectionStringInput";
import { SearchableTagPicker } from "../tags/SearchableTagPicker";
import type { ConnectionFormData } from "./connectionFormData";
import type { Folder, Tag } from "../../lib/types";
export interface SimpleConnectionFormProps {
form: ConnectionFormData;
folders: Folder[];
tags: Tag[];
onChange: (updates: Partial<ConnectionFormData>) => void;
}
export function SimpleConnectionForm({
form,
folders,
tags,
onChange,
}: SimpleConnectionFormProps) {
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-text mb-1.5">Label</label>
<Input
value={form.name}
onChange={(value) => onChange({ name: value })}
placeholder="My Production Database"
aria-label="Connection Label"
/>
<p className="text-xs text-text-muted mt-1.5">
A friendly name to identify this connection.
</p>
</div>
<div>
<label className="block text-sm text-text mb-1.5">
Environment
</label>
<EnvironmentSelect
value={form.environment}
onChange={(value) => onChange({ environment: value })}
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Folder</label>
<FolderSelect
folders={folders}
value={form.folder_id ?? null}
onChange={(value) => onChange({ folder_id: value })}
/>
</div>
<SearchableTagPicker
tags={tags}
selectedTagIds={form.tag_ids ?? []}
onToggle={(tagId) => {
const current = form.tag_ids ?? [];
const next = current.includes(tagId)
? current.filter((id) => id !== tagId)
: [...current, tagId];
onChange({ tag_ids: next });
}}
/>
<div>
<label className="block text-sm text-text mb-1.5">
Connection String
</label>
<ConnectionStringInput
value={form.connection_string}
onChange={(value) => onChange({ connection_string: value })}
placeholder="postgresql://user:password@host:5432/database"
aria-label="Connection String"
/>
<p className="text-xs text-text-muted mt-1.5">
Paste your connection string to auto-detect database type.
</p>
</div>
</div>
);
}
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { SqlitePathInput } from "./SqlitePathInput";
function StatefulInput(props: Omit<React.ComponentProps<typeof SqlitePathInput>, "onChange"> & {
onChange?: (value: string) => void;
}) {
const [value, setValue] = useState(props.value ?? "");
return (
<SqlitePathInput
{...props}
value={value}
onChange={(v) => {
setValue(v);
props.onChange?.(v);
}}
/>
);
}
const open = vi.hoisted(() => vi.fn());
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: (...args: unknown[]) => open(...args),
}));
describe("SqlitePathInput", () => {
it("emits typed path changes", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<StatefulInput value="" onChange={onChange} />);
const input = screen.getByLabelText(/file path/i);
await user.type(input, "/Users/me/data.db");
expect(onChange).toHaveBeenLastCalledWith("/Users/me/data.db");
expect(input).toHaveValue("/Users/me/data.db");
});
it("opens the file dialog on Browse and emits the chosen path", async () => {
const user = userEvent.setup();
open.mockResolvedValue("/chosen/path.db");
const onChange = vi.fn();
render(<SqlitePathInput value="" onChange={onChange} />);
await user.click(screen.getByRole("button", { name: /browse/i }));
expect(open).toHaveBeenCalledWith({ multiple: false, directory: false });
expect(onChange).toHaveBeenCalledWith("/chosen/path.db");
});
it("does not emit when the dialog is cancelled", async () => {
const user = userEvent.setup();
open.mockResolvedValue(null);
const onChange = vi.fn();
render(<SqlitePathInput value="/existing.db" onChange={onChange} />);
await user.click(screen.getByRole("button", { name: /browse/i }));
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,38 @@
import { open } from "@tauri-apps/plugin-dialog";
import { INPUT_ROUNDING } from "../../lib/uiConstants";
interface SqlitePathInputProps {
value: string;
onChange: (value: string) => void;
}
export function SqlitePathInput({ value, onChange }: SqlitePathInputProps) {
const handleBrowse = async () => {
try {
const path = await open({ multiple: false, directory: false });
if (path) onChange(path as string);
} catch {
// dialog unavailable (e.g., web preview) — no-op; user can type the path
}
};
return (
<div className="flex gap-2">
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="/path/to/database.sqlite"
aria-label="File Path"
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-2 text-sm text-text font-mono placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors`}
/>
<button
type="button"
onClick={handleBrowse}
aria-label="Browse"
className={`shrink-0 ${INPUT_ROUNDING} bg-surface-raised border border-border px-3 py-2 text-sm text-text hover:border-accent/50 cursor-pointer transition-colors`}
>
Browse
</button>
</div>
);
}
@@ -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();
});
});
+20 -4
View File
@@ -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&apos;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();
});
});
+16 -1
View File
@@ -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();
});
});
+14 -11
View File
@@ -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();
});
});
+2 -1
View File
@@ -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) => {
+2 -2
View File
@@ -24,7 +24,7 @@ export function SearchableTagPicker({ tags, selectedTagIds, onToggle }: Searchab
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search tags..."
className="w-full rounded-full bg-surface border border-border pl-7 pr-3 py-1.5 text-xs text-text placeholder-text-muted/60 focus:outline-none focus:border-accent transition-colors"
className="w-full rounded-lg bg-surface border border-border pl-7 pr-3 py-1.5 text-xs text-text placeholder-text-muted/60 focus:outline-none focus:border-accent transition-colors"
/>
</div>
<div className="max-h-32 overflow-y-auto space-y-1">
@@ -100,7 +100,7 @@ function InlineTagCreator({ onCreated }: { onCreated: (tagId: string) => void })
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Tag name"
className="w-full rounded-full bg-surface border border-border px-3 py-1.5 text-xs text-text placeholder-text-muted/60 focus:outline-none focus:border-accent transition-colors"
className="w-full rounded-lg bg-surface border border-border px-3 py-1.5 text-xs text-text placeholder-text-muted/60 focus:outline-none focus:border-accent transition-colors"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
+7
View File
@@ -14,4 +14,11 @@ describe("Input", () => {
await userEvent.type(screen.getByPlaceholderText("x"), "hi");
expect(fn).toHaveBeenLastCalledWith("hi");
});
it("uses the shared rounded (non-pill) radius", () => {
const { container } = render(<Input value="" onChange={() => {}} aria-label="x" />);
const input = container.querySelector("input")!;
expect(input.className).toContain("rounded-lg");
expect(input.className).not.toContain("rounded-full");
});
});
+2 -1
View File
@@ -1,5 +1,6 @@
import { forwardRef } from "react";
import type { KeyboardEvent } from "react";
import { INPUT_ROUNDING } from "../../lib/uiConstants";
interface InputProps {
value?: string;
@@ -17,7 +18,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
return (
<input
ref={ref}
className={`w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer ${className}`}
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer ${className}`}
onChange={(e) => onChange?.(e.target.value)}
onKeyDown={(e) => onKeyDown?.(e)}
{...rest}
+25
View File
@@ -30,4 +30,29 @@ describe("SelectDropdown", () => {
await user.click(screen.getByRole("button", { name: /Development/i }));
expect(onChange).toHaveBeenCalledWith("development");
});
it("does not open the menu when disabled", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(
<SelectDropdown
value="staging"
onChange={onChange}
options={[
{ value: "production", label: "Production" },
{ value: "staging", label: "Staging" },
]}
placeholder="None"
disabled
/>,
);
const trigger = screen.getByRole("button", { name: /Staging/i });
expect(trigger).toBeDisabled();
await user.click(trigger);
expect(
screen.queryByRole("button", { name: /Production/i }),
).not.toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
});
+9 -2
View File
@@ -13,6 +13,7 @@ interface SelectDropdownProps {
placeholder?: string;
variant?: "pill" | "ghost";
"aria-label"?: string;
disabled?: boolean;
}
export function SelectDropdown({
@@ -22,12 +23,17 @@ export function SelectDropdown({
placeholder = "Select…",
variant = "pill",
"aria-label": ariaLabel,
disabled = false,
}: SelectDropdownProps) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const selectedLabel =
options.find((opt) => opt.value === value)?.label ?? placeholder;
const disabledButtonClass = variant === "ghost"
? " opacity-50 cursor-not-allowed"
: " disabled:opacity-50 disabled:cursor-not-allowed";
useEffect(() => {
if (!open) return;
const handleMouseDown = (e: MouseEvent) => {
@@ -62,9 +68,10 @@ export function SelectDropdown({
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
onClick={() => !disabled && setOpen((o) => !o)}
aria-label={ariaLabel}
className={buttonClass}
disabled={disabled}
className={buttonClass + (disabled ? disabledButtonClass : "")}
>
<span className="truncate">{selectedLabel}</span>
<ChevronDown