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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -73,6 +73,31 @@ describe("useDbConnection", () => {
|
||||
mockConnection.ssh_auth_method = null;
|
||||
});
|
||||
|
||||
it("sets schemaTreeLoading around the connect fetch", async () => {
|
||||
mockCommands.getSchemas.mockImplementation(
|
||||
() => new Promise((res) => setTimeout(() => res(["public"]), 50)),
|
||||
);
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("connect"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(true),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears schemaTreeLoading when connect throws", async () => {
|
||||
mockCommands.getSchemas.mockRejectedValue(new Error("boom"));
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("connect"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
it("connects and smart-selects the public schema when available", async () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("connect"));
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { ConnectionInput, TableInfo } from "../lib/types";
|
||||
export function useDbConnection(connectionId: string) {
|
||||
const reset = useDbViewerStore((s) => s.reset);
|
||||
const populate = useDbViewerStore((s) => s.populate);
|
||||
const setSchemaTreeLoading = useDbViewerStore((s) => s.setSchemaTreeLoading);
|
||||
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
|
||||
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
|
||||
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
||||
@@ -74,34 +75,39 @@ export function useDbConnection(connectionId: string) {
|
||||
: (input.database ?? "postgres");
|
||||
|
||||
// Load initial data, smart-selecting the default schema (e.g. `public`)
|
||||
const databases = await cmd
|
||||
.getDatabases(connectionId)
|
||||
.catch(() => [] as string[]);
|
||||
const schemas = await cmd
|
||||
.getSchemas(connectionId)
|
||||
.catch(() => [] as string[]);
|
||||
const defaultSchema = pickDefaultSchema(schemas);
|
||||
const tables = await cmd.getTables(
|
||||
connectionId,
|
||||
defaultSchema ?? undefined,
|
||||
);
|
||||
populate(databases, schemas, tables);
|
||||
if (databases.length > 0) {
|
||||
// Prefer the connection's configured database, fall back to the first
|
||||
// available one so the dropdown matches what the pool is connected to.
|
||||
const preferred =
|
||||
input.database && databases.includes(input.database)
|
||||
? input.database
|
||||
: databases[0];
|
||||
setCurrentDatabase(preferred);
|
||||
setSchemaTreeLoading(true);
|
||||
try {
|
||||
const databases = await cmd
|
||||
.getDatabases(connectionId)
|
||||
.catch(() => [] as string[]);
|
||||
const schemas = await cmd
|
||||
.getSchemas(connectionId)
|
||||
.catch(() => [] as string[]);
|
||||
const defaultSchema = pickDefaultSchema(schemas);
|
||||
const tables = await cmd.getTables(
|
||||
connectionId,
|
||||
defaultSchema ?? undefined,
|
||||
);
|
||||
populate(databases, schemas, tables);
|
||||
if (databases.length > 0) {
|
||||
// Prefer the connection's configured database, fall back to the first
|
||||
// available one so the dropdown matches what the pool is connected to.
|
||||
const preferred =
|
||||
input.database && databases.includes(input.database)
|
||||
? input.database
|
||||
: databases[0];
|
||||
setCurrentDatabase(preferred);
|
||||
}
|
||||
setCurrentSchema(defaultSchema);
|
||||
} finally {
|
||||
setSchemaTreeLoading(false);
|
||||
}
|
||||
setCurrentSchema(defaultSchema);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setConnectionError(msg);
|
||||
notify(`Failed to connect: ${msg}`, "error");
|
||||
}
|
||||
}, [connectionId, populate, setCurrentDatabase, setCurrentSchema, notify]);
|
||||
}, [connectionId, populate, setCurrentDatabase, setCurrentSchema, setSchemaTreeLoading, notify]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
@@ -124,6 +130,7 @@ export function useDbConnection(connectionId: string) {
|
||||
const reconnect = async () => {
|
||||
const input = { ...inputRef.current!, database: currentDatabase };
|
||||
try {
|
||||
setSchemaTreeLoading(true);
|
||||
await cmd.dbConnect(connectionId, input);
|
||||
if (cancelled) return;
|
||||
connectedDbRef.current = currentDatabase;
|
||||
@@ -143,13 +150,15 @@ export function useDbConnection(connectionId: string) {
|
||||
// Revert the selection so the dropdown matches the live connection
|
||||
setCurrentDatabase(connectedDbRef.current);
|
||||
notify(`Failed to switch database: ${msg}`, "error");
|
||||
} finally {
|
||||
if (!cancelled) setSchemaTreeLoading(false);
|
||||
}
|
||||
};
|
||||
reconnect();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentDatabase, connectionId, populate, setCurrentSchema, notify]);
|
||||
}, [currentDatabase, connectionId, populate, setCurrentSchema, setSchemaTreeLoading, notify]);
|
||||
|
||||
return { connectionError, connect };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseConnectionString, looksLikeConnectionString } from "./connectionString";
|
||||
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost } from "./connectionString";
|
||||
|
||||
describe("parseConnectionString", () => {
|
||||
it("parses a PostgreSQL URL", () => {
|
||||
@@ -38,15 +38,27 @@ describe("parseConnectionString", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a SQLite file URL", () => {
|
||||
it("parses a SQLite file URL — path maps to host (v0.7.0 fix)", () => {
|
||||
const result = parseConnectionString("sqlite:///path/to/db.sqlite");
|
||||
expect(result).toEqual({
|
||||
db_type: "sqlite",
|
||||
host: "localhost",
|
||||
host: "/path/to/db.sqlite",
|
||||
port: null,
|
||||
username: null,
|
||||
password: null,
|
||||
database: "/path/to/db.sqlite",
|
||||
database: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a SQLite file: URL — path maps to host", () => {
|
||||
const result = parseConnectionString("file:///Users/me/data.db");
|
||||
expect(result).toEqual({
|
||||
db_type: "sqlite",
|
||||
host: "/Users/me/data.db",
|
||||
port: null,
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,3 +136,17 @@ describe("looksLikeConnectionString", () => {
|
||||
expect(looksLikeConnectionString("production database")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectProviderFromHost", () => {
|
||||
it("detects Supabase by host suffix", () => {
|
||||
expect(detectProviderFromHost("db.abcdefghijklmnopqrst.supabase.co")).toBe("supabase");
|
||||
expect(detectProviderFromHost("aws-us-east-1.pooler.supabase.com")).toBeNull();
|
||||
});
|
||||
it("detects NeonDB by host suffix", () => {
|
||||
expect(detectProviderFromHost("ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tech")).toBe("neon");
|
||||
});
|
||||
it("returns null for plain Postgres hosts", () => {
|
||||
expect(detectProviderFromHost("localhost")).toBeNull();
|
||||
expect(detectProviderFromHost("prod.example.com")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,14 +40,24 @@ export function parseConnectionString(input: string): ParsedConnectionString | n
|
||||
const db_type = DB_PROTOCOLS[url.protocol];
|
||||
if (!db_type) return null;
|
||||
|
||||
const host = url.hostname || "localhost";
|
||||
const port = url.port ? Number(url.port) : (DEFAULT_PORTS[db_type] ?? null);
|
||||
const username = url.username || null;
|
||||
const password = url.password || null;
|
||||
const pathname = url.pathname;
|
||||
const database = db_type === "sqlite"
|
||||
? (pathname || null)
|
||||
: (pathname.replace(/^\//, "") || null);
|
||||
|
||||
let host: string;
|
||||
let database: string | null;
|
||||
if (db_type === "sqlite") {
|
||||
// SQLite: the file path lives in the URL pathname. Map it to `host`
|
||||
// (the field the backend opens) and leave `database` null. `url.pathname`
|
||||
// always starts with "/"; the fallback covers `sqlite://path` (no
|
||||
// pathname) where the URL's hostname holds the relative path.
|
||||
host = pathname || url.hostname || "";
|
||||
database = null;
|
||||
} else {
|
||||
host = url.hostname || "localhost";
|
||||
database = pathname.replace(/^\//, "") || null;
|
||||
}
|
||||
|
||||
return {
|
||||
db_type,
|
||||
@@ -69,3 +79,13 @@ export function looksLikeConnectionString(input: string): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect a managed-PostgreSQL provider from a connection host, purely for
|
||||
* UI highlighting. Returns `"supabase"` / `"neon"` / `null`. db_type is
|
||||
* unaffected (both presets persist as `postgresql`). */
|
||||
export function detectProviderFromHost(host: string): "supabase" | "neon" | null {
|
||||
const h = host.toLowerCase();
|
||||
if (h.endsWith(".supabase.co")) return "supabase";
|
||||
if (h.endsWith(".neon.tech")) return "neon";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { DB_CAPABILITIES, getCapabilities, type DbCapabilities } from "./dbCapabilities";
|
||||
|
||||
describe("dbCapabilities", () => {
|
||||
it("gives PostgreSQL every capability", () => {
|
||||
const c = DB_CAPABILITIES.postgresql;
|
||||
expect(c).toEqual<DbCapabilities>({
|
||||
explorer: true, queries: true, objects: true, visualizer: true,
|
||||
tools: true, editing: true, import: true, ddl: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives MySQL explorer/queries/editing/import/ddl but not objects/visualizer/tools", () => {
|
||||
const c = DB_CAPABILITIES.mysql;
|
||||
expect(c.explorer).toBe(true);
|
||||
expect(c.queries).toBe(true);
|
||||
expect(c.editing).toBe(true);
|
||||
expect(c.import).toBe(true);
|
||||
expect(c.ddl).toBe(true);
|
||||
expect(c.objects).toBe(false);
|
||||
expect(c.visualizer).toBe(false);
|
||||
expect(c.tools).toBe(false);
|
||||
});
|
||||
|
||||
it("gives SQLite explorer/queries/visualizer/editing/import/ddl but not objects/tools", () => {
|
||||
const c = DB_CAPABILITIES.sqlite;
|
||||
expect(c.explorer).toBe(true);
|
||||
expect(c.queries).toBe(true);
|
||||
expect(c.visualizer).toBe(true);
|
||||
expect(c.editing).toBe(true);
|
||||
expect(c.import).toBe(true);
|
||||
expect(c.ddl).toBe(true);
|
||||
expect(c.objects).toBe(false);
|
||||
expect(c.tools).toBe(false);
|
||||
});
|
||||
|
||||
it("gives Redis nothing (connection+test only)", () => {
|
||||
expect(DB_CAPABILITIES.redis).toEqual<DbCapabilities>({
|
||||
explorer: false, queries: false, objects: false, visualizer: false,
|
||||
tools: false, editing: false, import: false, ddl: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("getCapabilities returns all-false for an unknown type", () => {
|
||||
const c = getCapabilities("postgres" as never);
|
||||
expect(Object.values(c).every((v) => v === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("getCapabilities returns the matrix entry for known types", () => {
|
||||
expect(getCapabilities("postgresql")).toBe(DB_CAPABILITIES.postgresql);
|
||||
expect(getCapabilities("redis")).toBe(DB_CAPABILITIES.redis);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { DbType } from "./types";
|
||||
|
||||
export interface DbCapabilities {
|
||||
/** Table browser + data grid. */
|
||||
explorer: boolean;
|
||||
/** SQL editor + history/saved. */
|
||||
queries: boolean;
|
||||
/** Functions/triggers/sequences/enums/extensions. */
|
||||
objects: boolean;
|
||||
/** ER schema diagram. */
|
||||
visualizer: boolean;
|
||||
/** Backup / restore / sync. */
|
||||
tools: boolean;
|
||||
/** Inline cell edits + changes queue. */
|
||||
editing: boolean;
|
||||
/** CSV/JSON import. */
|
||||
import: boolean;
|
||||
/** Copy table schema (DDL). */
|
||||
ddl: boolean;
|
||||
}
|
||||
|
||||
const ALL_FALSE: DbCapabilities = {
|
||||
explorer: false, queries: false, objects: false, visualizer: false,
|
||||
tools: false, editing: false, import: false, ddl: false,
|
||||
};
|
||||
|
||||
export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = {
|
||||
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true },
|
||||
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true },
|
||||
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true },
|
||||
redis: { ...ALL_FALSE },
|
||||
};
|
||||
|
||||
/** Safe accessor — unknown types get the all-false capability set. */
|
||||
export function getCapabilities(dbType: string): DbCapabilities {
|
||||
return (DB_CAPABILITIES as Record<string, DbCapabilities>)[dbType] ?? ALL_FALSE;
|
||||
}
|
||||
+40
-2
@@ -1,4 +1,4 @@
|
||||
import { siPostgresql, siMysql, siSqlite, siRedis } from "simple-icons";
|
||||
import { siPostgresql, siMysql, siSqlite, siRedis, siSupabase, siNeon } from "simple-icons";
|
||||
import type { DbType } from "./types";
|
||||
|
||||
// Brand colors from simple-icons
|
||||
@@ -56,4 +56,42 @@ export const DB_ICONS: Record<DbType, string> = {
|
||||
mysql: "🐬",
|
||||
redis: "⚡",
|
||||
sqlite: "🗄️",
|
||||
};
|
||||
};
|
||||
|
||||
// ── Managed-PostgreSQL provider icons (Supabase, NeonDB) ──────────
|
||||
// These are NOT DbType values; connections persist as db_type="postgresql".
|
||||
type ProviderIconId = "supabase" | "neon";
|
||||
|
||||
const PROVIDER_ICON_DATA: Record<ProviderIconId, { hex: string; path: string }> = {
|
||||
supabase: { hex: `#${siSupabase.hex}`, path: siSupabase.path },
|
||||
neon: { hex: `#${siNeon.hex}`, path: siNeon.path },
|
||||
};
|
||||
|
||||
export const PROVIDER_LABELS: Record<ProviderIconId, string> = {
|
||||
supabase: "Supabase",
|
||||
neon: "NeonDB",
|
||||
};
|
||||
|
||||
interface ProviderIconProps {
|
||||
id: ProviderIconId;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProviderIcon({ id, size = 20, className }: ProviderIconProps) {
|
||||
const data = PROVIDER_ICON_DATA[id];
|
||||
if (!data) return <span className="text-lg">❓</span>;
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
fill={data.hex}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d={data.path} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
|
||||
import agents from "../../AGENTS.md?raw";
|
||||
import readme from "../../README.md?raw";
|
||||
|
||||
describe("v0.6.0 docs coverage", () => {
|
||||
describe("v0.7.0 docs coverage", () => {
|
||||
it("AGENTS.md marks inline cell editing complete", () => {
|
||||
expect(agents).toContain("Inline cell editing");
|
||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||
@@ -24,14 +24,15 @@ describe("v0.6.0 docs coverage", () => {
|
||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||
});
|
||||
it("README declares v0.6.0", () => {
|
||||
expect(readme).toContain("0.6.0");
|
||||
it("README declares v0.7.0", () => {
|
||||
expect(readme).toContain("0.7.0");
|
||||
});
|
||||
it("README marks inline editing complete (not Upcoming)", () => {
|
||||
// Key Features lists inline editing as a shipped feature
|
||||
expect(readme).toMatch(/- \*\*Inline cell editing\*\* —/);
|
||||
// comparison-table row for the stage→commit queue carries the ✅ marker
|
||||
expect(readme).toMatch(/Changes queue \(stage → commit\)\s*\|[^|]*❌[^|]*\|[^|]*❌[^|]*\|\s*\*\*✅ Queue → Commit All\*\*/);
|
||||
// comparison-table row for the stage→commit queue carries the Gridline ✅ marker
|
||||
// (5-column table: Feature | DB Pro | Beekeeper | TablePlus | Gridline)
|
||||
expect(readme).toMatch(/Changes queue \(stage → commit\)\s*\|[^|]*❌[^|]*\|[^|]*\|[^|]*\|\s*\*\*✅ Queue → Commit All\*\*/);
|
||||
expect(readme).not.toMatch(/Inline cell editing.*Upcoming/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PROVIDER_TABS, getProviderById, SETUP_GUIDES } from "./providers";
|
||||
|
||||
describe("providers", () => {
|
||||
it("defines exactly 6 provider tabs in order", () => {
|
||||
expect(PROVIDER_TABS.map((p) => p.id)).toEqual([
|
||||
"postgresql", "mysql", "sqlite", "redis", "supabase", "neon",
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks only supabase and neon as managed presets mapping to postgresql", () => {
|
||||
const pg = getProviderById("postgresql")!;
|
||||
expect(pg.dbType).toBe("postgresql");
|
||||
expect(pg.isManagedPreset).toBe(false);
|
||||
const supa = getProviderById("supabase")!;
|
||||
expect(supa.dbType).toBe("postgresql");
|
||||
expect(supa.isManagedPreset).toBe(true);
|
||||
const neon = getProviderById("neon")!;
|
||||
expect(neon.dbType).toBe("postgresql");
|
||||
expect(neon.isManagedPreset).toBe(true);
|
||||
const redis = getProviderById("redis")!;
|
||||
expect(redis.dbType).toBe("redis");
|
||||
expect(redis.isManagedPreset).toBe(false);
|
||||
});
|
||||
|
||||
it("getProviderById returns undefined for unknown id", () => {
|
||||
expect(getProviderById("nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ships setup guides for supabase and neon only, each with steps", () => {
|
||||
expect(SETUP_GUIDES.supabase.steps.length).toBeGreaterThan(0);
|
||||
expect(SETUP_GUIDES.neon.steps.length).toBeGreaterThan(0);
|
||||
expect(SETUP_GUIDES.supabase.sslRequired).toBe(true);
|
||||
expect(SETUP_GUIDES.neon.sslRequired).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { DbType } from "./types";
|
||||
|
||||
export type ProviderId = "postgresql" | "mysql" | "sqlite" | "redis" | "supabase" | "neon";
|
||||
|
||||
export interface ProviderTab {
|
||||
id: ProviderId;
|
||||
label: string;
|
||||
/** The db_type persisted for a connection made via this tab. Supabase/Neon
|
||||
* are managed PostgreSQL, so they persist as `postgresql`. */
|
||||
dbType: DbType;
|
||||
/** True for managed-PostgreSQL presets (Supabase/NeonDB). */
|
||||
isManagedPreset: boolean;
|
||||
}
|
||||
|
||||
export const PROVIDER_TABS: ProviderTab[] = [
|
||||
{ id: "postgresql", label: "PostgreSQL", dbType: "postgresql", isManagedPreset: false },
|
||||
{ id: "mysql", label: "MySQL", dbType: "mysql", isManagedPreset: false },
|
||||
{ id: "sqlite", label: "SQLite", dbType: "sqlite", isManagedPreset: false },
|
||||
{ id: "redis", label: "Redis", dbType: "redis", isManagedPreset: false },
|
||||
{ id: "supabase", label: "Supabase", dbType: "postgresql", isManagedPreset: true },
|
||||
{ id: "neon", label: "NeonDB", dbType: "postgresql", isManagedPreset: true },
|
||||
];
|
||||
|
||||
export function getProviderById(id: string): ProviderTab | undefined {
|
||||
return PROVIDER_TABS.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
export interface SetupGuideStep {
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface SetupGuide {
|
||||
/** Short tagline shown on the provider card. */
|
||||
blurb: string;
|
||||
sslRequired: boolean;
|
||||
steps: SetupGuideStep[];
|
||||
}
|
||||
|
||||
/** Static, researched setup instructions. No network calls at runtime.
|
||||
* Content reviewed against the official docs (fetched 2026-08-04, see spec §9). */
|
||||
export const SETUP_GUIDES: Record<"supabase" | "neon", SetupGuide> = {
|
||||
supabase: {
|
||||
blurb: "Managed PostgreSQL with a pooled connection string.",
|
||||
sslRequired: true,
|
||||
steps: [
|
||||
{
|
||||
title: "Open the Supabase Dashboard",
|
||||
detail: "Open your project → click the Connect button at the top of the page.",
|
||||
},
|
||||
{
|
||||
title: "Copy a Postgres connection string",
|
||||
detail:
|
||||
"Direct: postgresql://postgres:[YOUR-PASSWORD]@db.[project-ref].supabase.co:5432/postgres " +
|
||||
"(needs IPv6, or the IPv4 add-on). For IPv4-only networks use the shared pooler session mode: " +
|
||||
"postgres://postgres.[project-ref]:[YOUR-PASSWORD]@aws-[REGION].pooler.supabase.com:5432/postgres.",
|
||||
},
|
||||
{
|
||||
title: "Paste it into Gridline's Connection URI",
|
||||
detail: "It auto-detects as PostgreSQL; the Supabase tab highlights. Save stores the password in your OS keychain.",
|
||||
},
|
||||
{
|
||||
title: "Enable SSL",
|
||||
detail: "Supabase requires SSL — under SSH / SSL set the mode to Require (or Verify Full with the project root cert).",
|
||||
},
|
||||
],
|
||||
},
|
||||
neon: {
|
||||
blurb: "Serverless Postgres with pooled or direct endpoints.",
|
||||
sslRequired: true,
|
||||
steps: [
|
||||
{
|
||||
title: "Open the Neon Console",
|
||||
detail: "On your Project Dashboard, click Connect to open the 'Connect to your database' modal.",
|
||||
},
|
||||
{
|
||||
title: "Pick branch, compute, database, role",
|
||||
detail: "A connection string is built for you. The default is the pooled endpoint (host ends in -pooler…neon.tech).",
|
||||
},
|
||||
{
|
||||
title: "Copy the connection string",
|
||||
detail:
|
||||
"postgresql://[role]:[password]@ep-[compute-id]-pooler.[region].aws.neon.tech/[dbname]?sslmode=require " +
|
||||
"— port 5432 for both pooled and direct. If your client can't parse user:pass@host, fill Host/Port/User/Password manually.",
|
||||
},
|
||||
{
|
||||
title: "Paste it into Gridline's Connection URI",
|
||||
detail: "Auto-detects as PostgreSQL; the NeonDB tab highlights. Save stores the password in your OS keychain.",
|
||||
},
|
||||
{
|
||||
title: "Enable SSL",
|
||||
detail: "Neon requires SSL/TLS (strings ship with sslmode=require) — confirm SSH / SSL → mode Require.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { INPUT_ROUNDING } from "./uiConstants";
|
||||
|
||||
describe("uiConstants", () => {
|
||||
it("exposes a rounded (non-pill) radius class for inputs", () => {
|
||||
expect(INPUT_ROUNDING).toBe("rounded-lg");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Shared Tailwind radius class for all form controls (inputs, selects, textareas).
|
||||
* Pill-shaped (`rounded-full`) controls were retired in v0.7.0 in favor of a
|
||||
* rounded rectangle. Import this constant so the radius is defined once. */
|
||||
export const INPUT_ROUNDING = "rounded-lg";
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
describe("version", () => {
|
||||
it("declares v0.6.0 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.6.0");
|
||||
it("declares v0.7.0 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.0");
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ describe("dbViewerStore", () => {
|
||||
expect(state.tables).toEqual([]);
|
||||
expect(state.currentDatabase).toBeNull();
|
||||
expect(state.currentSchema).toBeNull();
|
||||
expect(state.schemaTreeLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("openTab adds a new tab", () => {
|
||||
@@ -232,6 +233,15 @@ describe("dbViewerStore", () => {
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("setSchemaTreeLoading toggles the loading flag", () => {
|
||||
const store = useDbViewerStore.getState();
|
||||
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(false);
|
||||
store.setSchemaTreeLoading(true);
|
||||
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(true);
|
||||
store.setSchemaTreeLoading(false);
|
||||
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("reset clears all state", () => {
|
||||
const store = useDbViewerStore.getState();
|
||||
store.openTab("public", "users");
|
||||
@@ -249,6 +259,7 @@ describe("dbViewerStore", () => {
|
||||
expect(state.tables).toEqual([]);
|
||||
expect(state.currentDatabase).toBeNull();
|
||||
expect(state.currentSchema).toBeNull();
|
||||
expect(state.schemaTreeLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("populate sets databases, schemas, tables", () => {
|
||||
|
||||
@@ -89,6 +89,7 @@ interface DbViewerState {
|
||||
databases: string[];
|
||||
schemas: string[];
|
||||
tables: TableInfo[];
|
||||
schemaTreeLoading: boolean;
|
||||
currentDatabase: string | null;
|
||||
currentSchema: string | null;
|
||||
functions: FunctionInfo[] | null;
|
||||
@@ -97,6 +98,7 @@ interface DbViewerState {
|
||||
enums: EnumInfo[] | null;
|
||||
extensions: ExtensionInfo[] | null;
|
||||
indexes: IndexInfo[] | null;
|
||||
setSchemaTreeLoading: (loading: boolean) => void;
|
||||
constraints: ConstraintInfo[] | null;
|
||||
|
||||
// Actions
|
||||
@@ -184,6 +186,7 @@ const initialState = {
|
||||
extensions: null as ExtensionInfo[] | null,
|
||||
indexes: null as IndexInfo[] | null,
|
||||
constraints: null as ConstraintInfo[] | null,
|
||||
schemaTreeLoading: false,
|
||||
};
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────────────
|
||||
@@ -437,6 +440,7 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
setExtensions: (extensions) => set({ extensions }),
|
||||
setIndexes: (indexes) => set({ indexes }),
|
||||
setConstraints: (constraints) => set({ constraints }),
|
||||
setSchemaTreeLoading: (loading) => set({ schemaTreeLoading: loading }),
|
||||
|
||||
stageCellEdit: (input) => {
|
||||
// Re-staging the same cell replaces the existing pending entry (keeps the
|
||||
|
||||
Reference in New Issue
Block a user