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 { 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 { 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 () => {
|
||||
@@ -23,8 +20,6 @@ describe("ConnectionMetadataRow", () => {
|
||||
return (
|
||||
<ConnectionMetadataRow
|
||||
form={form}
|
||||
folders={folders}
|
||||
tags={tags}
|
||||
onChange={(updates) => {
|
||||
onChange(updates);
|
||||
setForm((prev) => ({ ...prev, ...updates }));
|
||||
@@ -34,37 +29,16 @@ describe("ConnectionMetadataRow", () => {
|
||||
}
|
||||
render(<Wrapper />);
|
||||
const label = screen.getByLabelText(/connection label/i);
|
||||
expect(label).toBeInTheDocument();
|
||||
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" }));
|
||||
it("does not render tag/env/folder controls", () => {
|
||||
render(<ConnectionMetadataRow form={BASE_FORM} onChange={vi.fn()} />);
|
||||
expect(screen.queryByRole("button", { name: /add tags/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /set env/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("environment-section")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("folder-section")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,14 @@
|
||||
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));
|
||||
|
||||
export function ConnectionMetadataRow({ form, onChange }: ConnectionMetadataRowProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Connection Label</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
@@ -30,52 +17,5 @@ export function ConnectionMetadataRow({ form, folders, tags, onChange }: Connect
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -55,11 +55,11 @@ describe("DetailedConnectionForm", () => {
|
||||
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()} />);
|
||||
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.queryByRole("button", { name: /tags & env/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the metadata row (Connection Label) above the tabs", () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 {
|
||||
@@ -12,20 +12,21 @@ export interface DetailedConnectionFormProps {
|
||||
}
|
||||
|
||||
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);
|
||||
const [activeTab, setActiveTab] = useState<"general" | "tagsEnv" | "ssh">("general");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ConnectionMetadataRow form={form} folders={folders ?? []} tags={tags ?? []} onChange={onChange} />
|
||||
<ConnectionMetadataRow form={form} 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("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>
|
||||
</div>
|
||||
{activeTab === "general" ? (
|
||||
<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} />
|
||||
)}
|
||||
|
||||
@@ -21,12 +21,13 @@ export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
|
||||
{isSqlite ? (
|
||||
<SqlitePathInput value={form.host} onChange={(value) => onChange({ host: value })} />
|
||||
) : (
|
||||
<input
|
||||
<textarea
|
||||
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`}
|
||||
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 && (
|
||||
@@ -50,7 +51,7 @@ export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
|
||||
</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" />
|
||||
<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>
|
||||
|
||||
@@ -294,14 +294,15 @@ export function NewConnectionScreen({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
<textarea
|
||||
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 ${
|
||||
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"
|
||||
}`}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user