fix(ui): connection URI scroll, Tags & Env tab, hide number-input spinners
This commit is contained in:
@@ -1,18 +1,15 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { ConnectionMetadataRow } from "./ConnectionMetadataRow";
|
import { ConnectionMetadataRow } from "./ConnectionMetadataRow";
|
||||||
import type { ConnectionFormData } from "./connectionFormData";
|
import type { ConnectionFormData } from "./connectionFormData";
|
||||||
import type { Folder, Tag } from "../../lib/types";
|
|
||||||
|
|
||||||
const BASE_FORM: ConnectionFormData = {
|
const BASE_FORM: ConnectionFormData = {
|
||||||
name: "", environment: null, folder_id: null, tag_ids: [],
|
name: "", environment: null, folder_id: null, tag_ids: [],
|
||||||
connection_string: "", db_type: "postgresql", host: "", port: 5432,
|
connection_string: "", db_type: "postgresql", host: "", port: 5432,
|
||||||
username: null, password: null, database: null, use_keychain: false, ssh_password: null,
|
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", () => {
|
describe("ConnectionMetadataRow", () => {
|
||||||
it("renders a Connection Label input and emits name changes", async () => {
|
it("renders a Connection Label input and emits name changes", async () => {
|
||||||
@@ -23,8 +20,6 @@ describe("ConnectionMetadataRow", () => {
|
|||||||
return (
|
return (
|
||||||
<ConnectionMetadataRow
|
<ConnectionMetadataRow
|
||||||
form={form}
|
form={form}
|
||||||
folders={folders}
|
|
||||||
tags={tags}
|
|
||||||
onChange={(updates) => {
|
onChange={(updates) => {
|
||||||
onChange(updates);
|
onChange(updates);
|
||||||
setForm((prev) => ({ ...prev, ...updates }));
|
setForm((prev) => ({ ...prev, ...updates }));
|
||||||
@@ -34,37 +29,16 @@ describe("ConnectionMetadataRow", () => {
|
|||||||
}
|
}
|
||||||
render(<Wrapper />);
|
render(<Wrapper />);
|
||||||
const label = screen.getByLabelText(/connection label/i);
|
const label = screen.getByLabelText(/connection label/i);
|
||||||
|
expect(label).toBeInTheDocument();
|
||||||
await user.type(label, "My DB");
|
await user.type(label, "My DB");
|
||||||
expect(onChange).toHaveBeenLastCalledWith({ name: "My DB" });
|
expect(onChange).toHaveBeenLastCalledWith({ name: "My DB" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles the tag picker via + Add Tags and selects a tag", async () => {
|
it("does not render tag/env/folder controls", () => {
|
||||||
const user = userEvent.setup();
|
render(<ConnectionMetadataRow form={BASE_FORM} onChange={vi.fn()} />);
|
||||||
const onChange = vi.fn();
|
expect(screen.queryByRole("button", { name: /add tags/i })).not.toBeInTheDocument();
|
||||||
render(<ConnectionMetadataRow form={BASE_FORM} folders={folders} tags={tags} onChange={onChange} />);
|
expect(screen.queryByRole("button", { name: /set env/i })).not.toBeInTheDocument();
|
||||||
await user.click(screen.getByRole("button", { name: /add tags/i }));
|
expect(screen.queryByTestId("environment-section")).not.toBeInTheDocument();
|
||||||
await user.click(screen.getByRole("button", { name: /prod/i }));
|
expect(screen.queryByTestId("folder-section")).not.toBeInTheDocument();
|
||||||
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" }));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,81 +1,21 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Input } from "../ui/Input";
|
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 { ConnectionFormData } from "./connectionFormData";
|
||||||
import type { Folder, Tag } from "../../lib/types";
|
|
||||||
|
|
||||||
interface ConnectionMetadataRowProps {
|
interface ConnectionMetadataRowProps {
|
||||||
form: ConnectionFormData;
|
form: ConnectionFormData;
|
||||||
folders: Folder[];
|
|
||||||
tags: Tag[];
|
|
||||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConnectionMetadataRow({ form, folders, tags, onChange }: ConnectionMetadataRowProps) {
|
export function ConnectionMetadataRow({ form, onChange }: ConnectionMetadataRowProps) {
|
||||||
const [openPanel, setOpenPanel] = useState<"tags" | "env" | null>(null);
|
|
||||||
const toggle = (panel: "tags" | "env") =>
|
|
||||||
setOpenPanel((cur) => (cur === panel ? null : panel));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<label className="block text-sm text-text mb-1.5">Connection Label</label>
|
||||||
<label className="block text-sm text-text mb-1.5">Connection Label</label>
|
<Input
|
||||||
<Input
|
value={form.name}
|
||||||
value={form.name}
|
onChange={(value) => onChange({ name: value })}
|
||||||
onChange={(value) => onChange({ name: value })}
|
placeholder="My Production Database"
|
||||||
placeholder="My Production Database"
|
aria-label="Connection Label"
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -55,11 +55,11 @@ describe("DetailedConnectionForm", () => {
|
|||||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ port: 5432 }));
|
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ port: 5432 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders only General and SSH / SSL tabs (no Tags & Env)", () => {
|
it("renders General, Tags & Env, and SSH / SSL tabs", () => {
|
||||||
render(<StatefulForm folders={[]} tags={[]} onChange={vi.fn()} />);
|
render(<StatefulForm folders={[]} tags={[]} onChange={vi.fn()} />);
|
||||||
expect(screen.getByRole("button", { name: /^general$/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /^general$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /tags & env/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: /ssh \/ ssl/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", () => {
|
it("renders the metadata row (Connection Label) above the tabs", () => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { GeneralTab } from "./GeneralTab";
|
import { GeneralTab } from "./GeneralTab";
|
||||||
import { SshSslTab } from "./SshSslTab";
|
import { SshSslTab } from "./SshSslTab";
|
||||||
|
import { TagsEnvTab } from "./TagsEnvTab";
|
||||||
import { ConnectionMetadataRow } from "./ConnectionMetadataRow";
|
import { ConnectionMetadataRow } from "./ConnectionMetadataRow";
|
||||||
import { useConnectionStore } from "../../stores/connectionStore";
|
|
||||||
import type { ConnectionFormData } from "./connectionFormData";
|
import type { ConnectionFormData } from "./connectionFormData";
|
||||||
|
|
||||||
export interface DetailedConnectionFormProps {
|
export interface DetailedConnectionFormProps {
|
||||||
@@ -12,20 +12,21 @@ export interface DetailedConnectionFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) {
|
export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) {
|
||||||
const [activeTab, setActiveTab] = useState<"general" | "ssh">("general");
|
const [activeTab, setActiveTab] = useState<"general" | "tagsEnv" | "ssh">("general");
|
||||||
const folders = useConnectionStore((s) => s.folders);
|
|
||||||
const tags = useConnectionStore((s) => s.tags);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ConnectionMetadataRow form={form} folders={folders ?? []} tags={tags ?? []} onChange={onChange} />
|
<ConnectionMetadataRow form={form} onChange={onChange} />
|
||||||
<div>
|
<div>
|
||||||
<div className="flex gap-6 border-b border-border mb-4">
|
<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("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("tagsEnv")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "tagsEnv" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>Tags & Env</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("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>
|
</div>
|
||||||
{activeTab === "general" ? (
|
{activeTab === "general" ? (
|
||||||
<GeneralTab form={form} onChange={onChange} managedPreset={managedPreset} />
|
<GeneralTab form={form} onChange={onChange} managedPreset={managedPreset} />
|
||||||
|
) : activeTab === "tagsEnv" ? (
|
||||||
|
<TagsEnvTab form={form} onChange={onChange} />
|
||||||
) : (
|
) : (
|
||||||
<SshSslTab form={form as unknown as Record<string, unknown>} onChange={onChange as (u: Record<string, unknown>) => void} />
|
<SshSslTab form={form as unknown as Record<string, unknown>} onChange={onChange as (u: Record<string, unknown>) => void} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,12 +21,13 @@ export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
|
|||||||
{isSqlite ? (
|
{isSqlite ? (
|
||||||
<SqlitePathInput value={form.host} onChange={(value) => onChange({ host: value })} />
|
<SqlitePathInput value={form.host} onChange={(value) => onChange({ host: value })} />
|
||||||
) : (
|
) : (
|
||||||
<input
|
<textarea
|
||||||
value={form.connection_string}
|
value={form.connection_string}
|
||||||
onChange={(e) => onChange({ connection_string: e.target.value })}
|
onChange={(e) => onChange({ connection_string: e.target.value })}
|
||||||
placeholder="postgresql://user:password@host:5432/database"
|
placeholder="postgresql://user:password@host:5432/database"
|
||||||
aria-label="Connection URI"
|
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`}
|
rows={1}
|
||||||
|
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 resize-none overflow-x-auto whitespace-nowrap`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{managedPreset && (
|
{managedPreset && (
|
||||||
@@ -50,7 +51,7 @@ export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-28">
|
<div className="w-28">
|
||||||
<label className="block text-sm text-text mb-1.5">Port</label>
|
<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" />
|
<Input type="number" value={form.port?.toString() ?? ""} onChange={(value) => onChange({ port: value === "" ? null : Number(value) })} placeholder="5432" aria-label="Port" className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -294,14 +294,15 @@ export function NewConnectionScreen({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<textarea
|
||||||
value={form.connection_string}
|
value={form.connection_string}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleConnectionStringChange(e.target.value)
|
handleConnectionStringChange(e.target.value)
|
||||||
}
|
}
|
||||||
placeholder="postgresql://user:password@host:5432/database"
|
placeholder="postgresql://user:password@host:5432/database"
|
||||||
aria-label={isEntry ? "Connection URI" : undefined}
|
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 ${
|
rows={1}
|
||||||
|
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 resize-none overflow-x-auto whitespace-nowrap ${
|
||||||
isEntry ? "" : "sr-only"
|
isEntry ? "" : "sr-only"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user