feat: initial Gridline implementation (#1)
* chore: configure Tailwind, Vitest, Rust deps, dialog plugin (Task 1)
* feat: shared types and validation/filter utilities (Task 2)
* feat: Rust models, SQLite store, and migrations (Task 3)
* feat: Tauri commands for connections and folders (Task 4)
* feat: tag, settings, import/export Tauri commands (Task 5)
* feat: frontend command wrappers and Zustand stores (Task 6)
* feat: useSearch debounce and useFilteredConnections hooks (Task 7)
* feat: App view router with load-on-mount (Task 8)
* feat: UI primitives Button, Input, Badge, Card (Task 9)
* feat: SearchBar, ActionRow, ImportExportMenu (Task 10)
* feat: ConnectionCard, ConnectionGrid, TagBadge (Task 11)
* fix: resolve TypeScript errors across utils, Input, and test files
* feat: FolderTree, CreateFolderDialog, NewConnectionForm, SettingsPage, HomeScreen (Task 12)
* feat: Tauri file dialog integration for import/export (Task 13)
* feat: error/loading states and full App router integration (Task 14)
* chore: final integration gate — full test + build green (Task 15)
* fix: move folders inline, rearrange ActionRow layout, add borders to buttons
* feat: upgrade to Tailwind v4, apply new color palette
* Landing page restyle, folder explorer, selection/delete, and icon update
- Black-and-white color scheme with blue accent
- Glassmorphic New Folder modal with auto-parent
- Nested folder explorer with breadcrumb navigation
- Folder/connection selection with hover checkboxes
- Select All / Clear Selection / Delete dropdown
- Delete reparents children to parent folder
- Fixed Rust serde camelCase mismatch (models now use snake_case)
- Updated app icons from Apple Icon Composer exports
- Search bar with ⌘K badge
- Button styling: rounded pills, borders, cursor-pointer
- Prevent drag selection across app
* Tag system, folder edit/delete, searchable tag picker, delete confirmation, tag reordering
- Added folder_tags table for folder-tag associations
- Tags now show configured colors on folder and connection cards
- Searchable tag picker with text filtering in New/Edit Folder dialogs
- Edit Folder dialog with name and tag management
- Edit/Delete buttons next to breadcrumb with icons
- Delete confirmation dialog with reparenting notice
- Tag reordering in Settings with up/down buttons, persisted via tag_order setting
- useSortedTags hook for consistent tag ordering
- Cmd+K focuses search input
- Selection dropdown simplified to Select All/Clear/Delete
- Click-outside closes all dropdowns
- cursor-pointer on all interactables
* deps: install motion for animations
* feat(types): add system theme option
* feat(settings): default stored theme to system
* feat(ui): add Toggle primitive
* feat(ui): add Select primitive
* feat(ui): add SettingsRow and SettingsSection primitives
* fix(ui): improve SettingsRow and SettingsSection quality
* feat(ui): add ThemePicker primitive
* feat(ui): add AnimatedModal primitive with motion
* refactor(dialogs): use AnimatedModal for enter/exit animations
* fix(dialogs): ensure exit animations and Escape handling work with AnimatedModal
* feat(settings): redesign page with multi-section layout
* refactor(settings): split tabs, improve accessibility and animation
* fix(tauri): set window background color to dark canvas
* fix(settings): remove fake traffic lights, add header background, transparent title bar
* fix(settings): move Back into sidebar, add Settings heading, dynamic window title
* fix(tauri): show native window title so Home/Settings labels are visible
* fix(ui): disable overscroll on both axes
* test(dialogs): verify CreateFolderDialog shows and saves tags
* chore: ignore .worktrees directory
* refactor: centralize db type icons and labels
* feat: add connection string parser and detection
* test: add edge cases for connection string parser
* fix: preserve absolute paths in SQLite connection strings
* types: add new connection form fields
* feat: add folder path label helper
* test: cover missing folder in path label helper
* feat: add stub connection string parse and test commands
* feat: add stub test connection command
* feat: add new connection form primitives
* feat: add connection form shell
* feat: add shared form type and simple connection form
* fix: keep SimpleConnectionForm fully controlled
* style: format and tighten types for simple connection form
* feat: add detailed connection form
* fix: export DetailedConnectionFormProps and assert port update
* feat: add new connection screen container
* fix: clean up useEffect dependencies in new connection screen
* fix: address quality feedback on new connection screen
* feat: wire new connection screen into app and remove old form
* feat: open new connection screen from pasted db url in search
* test: add home search URL shortcut integration test
* refactor: replace FolderSelect and EnvironmentSelect with shared SelectDropdown
Add a reusable SelectDropdown component that keeps the select-styled
trigger while using the ActionRow-style popover menu. Update both
FolderSelect and EnvironmentSelect to use it.
* refactor: remove db type icon and label from connection form shell
Drop DbTypeHeader and the db_type prop from ConnectionFormShell so the
form header no longer displays the database icon/type. Update
NewConnectionScreen and its test accordingly.
* feat: home screen redesign and connection form updates
- Redesign HomeScreen layout and settings page
- Update connection card, grid, and simple form
- Replace selects with SelectDropdown component
- Remove DbTypeHeader from connection form
- Fix tests to match updated components
* fix: hide folders during search, update placeholder, and set window titles
- ConnectionGrid now hides folders when hasSearch is true so only
matching connections are shown.
- SearchBar placeholder now mentions typing a database URL to create
a new connection.
- App window titles set to Gridline (home), Settings, and New Connection.
* fix: shorter placeholder, dynamic window title, and startup flash
- Shorten SearchBar placeholder to mention DB URL creation concisely.
- Add core:window:allow-set-title permission and set document.title so
the window title updates per view (Gridline, Settings, New Connection).
- Add inline dark background style to index.html to prevent white flash
before CSS loads.
* feat: extend connection models with SSH/SSL/database fields, add db_viewer models (Task 1a)
* feat: extend frontend types with SSH/SSL fields and DB viewer types (Task 1b)
* feat: migrate connections table with SSH/SSL columns (Task 1c)
* chore: add new Rust dependencies (tokio, sqlx, redis, ssh2, indexmap, etc.) for Phase 2
* feat: connection pool manager with LRU eviction (Task 2a)
* feat: schema introspection query builders for PG, MySQL, SQLite (Task 2b)
* feat: test connection command (all 4 DB types) and SSH tunnel manager (Task 2c)
* feat: db viewer Rust commands and AppState refactor (Task 2d)
* feat: frontend command wrappers for DB viewer and updated validation (Task 3a)
* feat: dbViewer store with tabs, changes queue, pagination; uiStore activeConnectionId (Task 3b)
* feat: ConnectionCard navigates to DB Viewer, App routes to DbViewerScreen (Task 3c)
* feat: tooltip UI primitive (Task 4a)
* feat: SSH/SSL form tabs in DetailedConnectionForm (Task 4b)
* feat: DbViewerScreen shell with sidebar navigation (Task 4c)
* feat: toolbar, table tree, and overflow menu (Task 4d)
* fix: align TableInfo interface between frontend types and test files
* feat: tab bar, data grid, and pagination controls (Task 4e)
* feat: changes queue panel with cancel per change (Task 4f)
* feat: wire real IPC connect/disconnect/load in DbViewerScreen (Task 5a)
* feat: error banner, guard dialogs for destructive actions (Task 5b)
* feat: changes queue commit all with per-change IPC and error handling (Task 5c)
* chore: suppress expected dead-code warnings during multi-phase development
* fix: wire ConnectionCard click through to DB Viewer (HomeScreen -> ConnectionGrid -> ConnectionCard -> App route)
* fix: click connection opens DB viewer when nothing selected, toggles selection when items already selected; fix delete connections + generic confirm message
* fix: align ConnectionTestResult field name (ok vs success) and fix testConnection invoke param name (input vs config) to match Rust backend
* feat: implement DB Viewer Tauri commands (db_connect, get_databases, etc.)
* fix: align DB viewer IPC param names (snake_case), implement execute_change + refresh_connection, snake_case Change enum tags
* fix: use camelCase IPC keys for multi-word Tauri command params (Tauri v2 converts snake_case Rust -> camelCase)
* fix: surface full postgres error detail and redact credentials instead of opaque 'db error'; URL-encode pg connection strings
* fix: cache passwords per-session so saved connections can auto-connect; surface full postgres error detail
* fix: auto-fetch table data, full-row click, fill viewport, load columns on expand, database switching
* fix: align ColumnInfo/QueryResult types with Rust backend (columns=ColumnInfo[], rows=unknown[][], field names match)
* feat: truncate cell text, resizable columns with drag handles, tab bar no-wrap horizontal scroll
* fix: QueryResult uses total_rows/page/page_size (match Rust), brighter table borders, pagination NaN fix
* fix: table horizontal scrolling, viewport fills screen height (h-screen), remove clipping overflow-hidden
* fix: constrain layout to viewport with overflow-hidden on content column; flex-1 fills height; sidebar+grid scroll independently
* fix: move overflow-hidden down to grid wrapper so DataGrid scrollbars surface properly
* fix: add min-w-0 to DataGrid wrapper so flexbox allows shrinking for horizontal scroll
* fix: add overflow-hidden to flex row and right column to clip at viewport, DataGrid scrolls inside
* fix: use w-0 on right column to force width:0 flex-basis, preventing any content-based expansion
* fix: overscroll-contain on DataGrid and TableTree scroll areas to prevent bounce
* fix: inline overscroll-behavior:none + WebkitOverflowScrolling:auto for reliable macOS bounce prevention
* feat: page-size selector (50/100/200) in pagination bar, setPageSize resets to page 1 and triggers re-fetch
* fix: column resize with refs, FK cell click opens table with filter, vertical borders, tooltip positioning (right/bottom), sidebar transparent bg
* fix: sidebar bg-canvas, remove overflow-hidden from toolbar parent so dropdown tooltips aren't clipped
* chore: rename sidebar label from DB Viewer to Explorer
* feat: resizable table panel (180-600px) with drag handle on right edge
* feat: double-click panel resize handle resets to default 280px
* feat: column visibility dropdown in filter bar, max column width 800px, double-click resets column width
* fix: enforce column width on th cells, remove min-width:100% so columns stay at set width instead of stretching
* fix: removable resize handle always clickable (remove opacity-0), maxWidth+overflow on th/td to prevent column blowout
* feat: add environment label to connections (production/staging/development) with badge on cards
* feat: OS keychain integration for connection passwords via tauri-plugin-keyring-store
* style: monospace font for table data cells
* style: use Space Mono (font-heading) for table data cells
* feat: unified table controls bar (insert, refresh, auto-refresh, filter modal, sort modal, export, columns, pagination); fix pagination setPage not clearing data
* fix: auto-refresh defaults to off, click opens dropdown to pick interval
* fix: pagination/refresh keeps existing data visible, shows subtle loading bar instead of blank
* fix: new tabs start with loading:true so auto-fetch triggers instead of stuck on 'loading table data'
* feat: checkbox column for row selection (header=all, row=individual, selected state tracks indices)
* feat: selected row count in toolbar with clear button
* feat: bulk actions dropdown on selection (Copy JSON, Copy CSV, Copy SQL INSERT, Delete rows)
* docs: add changes-queue-before-execution rule to AGENTS.md guardrails
* docs: broaden CRUD guardrail to cover all destructive operations (DB data via queue, app entities via confirm dialog)
* feat: auto-create demo SQLite DB on first launch with sample e-commerce schema (users, products, orders, order_items)
* fix: demo DB startup panic (state before manage), add settings: re-add demo, table refresh rate, table page size
* feat: Cmd+W closes tab or navigates home, edit connection modal with update_connection backend, action queue button with count badge and dropdown
* fix: columns button icon-only, wire settings.table_page_size and table_refresh_rate to actual table behavior
* feat: shortcuts settings tab showing all keyboard shortcuts (Cmd+K, Cmd+W, Esc, Enter, Space, click, header checkbox)
* feat: editable keyboard shortcuts - click pencil to record new keybinding, persisted to settings, useShortcut hook for dynamic binding
* feat: add Tags & Env tab to edit connection modal (environment, folder, tag picker)
* feat(db-viewer): UX polish - cursor pointers, icon-only toolbar, top border
- Add cursor-pointer to all interactive elements across db-viewer components
- Simplify TableControls: Filter/Sort/Export show icons only with tooltips
- Add border-t to DbViewerScreen for visual separation from window frame
* fix(db-viewer): style EditConnectionModal, fix test connection, working refresh button
- Match EditConnectionModal styling to home screen modals (AnimatedModal, Button, no dividers)
- Fix test connection sending null password by fetching from keychain first
- Make refresh database button functional with success/error visual feedback and spin animation
* feat(db-viewer): compact ghost-style DB/schema dropdowns in single row
- Add variant prop to SelectDropdown (pill | ghost) for minimal text-only style
- Place DB and schema dropdowns side-by-side on one row with | separator
* fix(db-viewer): proper FK/enum detection and improved schema tree display
Rust backend:
- PostgreSQL: fix column query to detect FKs (was hardcoded false) and map
USER-DEFINED types to udt_name for enum display
- SQLite: use PRAGMA table_info for types/PK/NOT NULL/defaults and
PRAGMA foreign_key_list for FK detection
Frontend:
- FK columns show orange key icon in TableTree
- Data type abbreviations (varchar, int, bool, timestamptz, etc.) with
full type name on hover tooltip
- Column icons use shrink-0 to maintain size
* feat(db-viewer): PK/FK icons and shorthand types in DataGrid headers
* fix(db-viewer): fix NULL values for UUID and timestamp columns, FK underline style
- Add uuid::Uuid, chrono types to pg_value_to_json chain so UUID PKs/FKs
and timestamp columns display correctly instead of NULL
- Enable with-serde_json-1 feature on tokio-postgres
- Change FK cell styling from blue text to dotted underline
* feat(db-viewer): FK preview popover with inline filter
- Replace direct FK navigation with popover showing the referenced row
- New get_fk_preview Rust command fetches single row by column value
- FkPreviewPopover component displays columns with PK/FK icons
- 'Open' button creates filtered tab, visible in existing Filter UI
- Consolidate columnFilter into filterRules (no duplicate filter logic)
* feat(db-viewer): selectable cell text, JSON/JSONB popover with formatted/raw views
- Add select-text to cell contents for copy support
- New JsonCellPopover component with Formatted/Raw tabs and copy button
- JSON/JSONB columns show brief preview ({ N keys } / [ N items ])
- Click JSON cells to open popover with pretty-printed or raw output
* feat(db-viewer): smart default sort adds newest-first sorting automatically
- 12-tier priority system detects recency/ordering columns
- Prefers updated_at, created_at, *_at suffixes, last_* prefixes
- Falls back to timestamp types, numeric IDs, sequence/position cols
- Also covers rank, version, count/quantity columns
- Applied once per tab, visible in Sort dropdown for manual override
* feat(db-viewer): search tables input with slide animation and tree filtering
- Search icon in toolbar toggles animated input with slide-down effect
- Filters TableTree by table name as user types
- Clean border-bottom styling, search icon on left, X clear on right
- Auto-hides on blur when empty, stays when content present
- Search icon highlights when active
* docs: add comprehensive implementation status to AGENTS.md
- Status matrix across 6 areas: connections, home, db viewer, object explorer, query editor, backup/restore, settings, onboarding
- Covers 60+ features with ✅/🟡/❌ markers and details
- Identifies remaining work: query editor, object explorer, virtualized grid, MySQL browsing, SSH tunnels, backup/restore
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConnectionCard } from "./ConnectionCard";
|
||||
import type { Connection, Tag } from "../../lib/types";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
const tags: Tag[] = [
|
||||
{ id: "t1", name: "production", color: "#ef4444", created_at: "" },
|
||||
{ id: "t2", name: "primary", color: "#3b82f6", created_at: "" },
|
||||
];
|
||||
const conn: Connection = {
|
||||
id: "c1", name: "Prod DB", db_type: "postgresql", host: "prod.example.com",
|
||||
port: 5432, username: null, folder_id: null, keychain_ref: null,
|
||||
tag_ids: ["t1", "t2"], created_at: "", updated_at: "",
|
||||
};
|
||||
|
||||
describe("ConnectionCard", () => {
|
||||
beforeEach(() => {
|
||||
useUiStore.setState({ selectedItemIds: [] });
|
||||
});
|
||||
|
||||
it("renders name and host", () => {
|
||||
render(<ConnectionCard connection={conn} tags={tags} />);
|
||||
expect(screen.getByText("Prod DB")).toBeInTheDocument();
|
||||
expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument();
|
||||
});
|
||||
it("renders db type label", () => {
|
||||
render(<ConnectionCard connection={conn} tags={tags} />);
|
||||
expect(screen.getByText(/postgresql/i)).toBeInTheDocument();
|
||||
});
|
||||
it("renders tag badges", () => {
|
||||
render(<ConnectionCard connection={conn} tags={tags} />);
|
||||
expect(screen.getByText("production")).toBeInTheDocument();
|
||||
expect(screen.getByText("primary")).toBeInTheDocument();
|
||||
});
|
||||
it("omits port for sqlite", () => {
|
||||
const sqlite = { ...conn, db_type: "sqlite" as const, host: "/data/x.db", port: null };
|
||||
render(<ConnectionCard connection={sqlite} tags={tags} />);
|
||||
expect(screen.getByText("/data/x.db")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/:5432/)).not.toBeInTheDocument();
|
||||
});
|
||||
it("fires onTagToggle when a tag badge is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fn = vi.fn();
|
||||
render(<ConnectionCard connection={conn} tags={tags} onTagToggle={fn} />);
|
||||
await user.click(screen.getByText("production"));
|
||||
expect(fn).toHaveBeenCalledWith("t1");
|
||||
});
|
||||
it("opens DbViewer on single click when nothing is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fn = vi.fn();
|
||||
render(<ConnectionCard connection={conn} tags={tags} onOpenDbViewer={fn} />);
|
||||
await user.click(screen.getByText("Prod DB"));
|
||||
expect(fn).toHaveBeenCalledWith(conn.id);
|
||||
});
|
||||
|
||||
it("toggles selection on single click when something is already selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
useUiStore.setState({ selectedItemIds: ["other-id"] });
|
||||
const fn = vi.fn();
|
||||
render(<ConnectionCard connection={conn} tags={tags} onOpenDbViewer={fn} />);
|
||||
await user.click(screen.getByText("Prod DB"));
|
||||
// Should NOT open — should toggle selection instead
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(useUiStore.getState().selectedItemIds).toContain(conn.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { memo } from "react";
|
||||
import type { Connection, Tag } from "../../lib/types";
|
||||
import { DB_ICONS, DB_LABELS } from "../../lib/dbIcons";
|
||||
import { ENV_LABELS, ENV_COLORS } from "../../lib/environment";
|
||||
import { TagBadge } from "../tags/TagBadge";
|
||||
import { Check } from "lucide-react";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
interface ConnectionCardProps {
|
||||
connection: Connection;
|
||||
tags: Tag[];
|
||||
onTagToggle?: (id: string) => void;
|
||||
onOpenDbViewer?: (connectionId: string) => void;
|
||||
}
|
||||
|
||||
function ConnectionCardBase({
|
||||
connection,
|
||||
tags,
|
||||
onTagToggle,
|
||||
onOpenDbViewer,
|
||||
}: ConnectionCardProps) {
|
||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
|
||||
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
||||
const cardTags = connection.tag_ids
|
||||
.map((id) => tagMap.get(id))
|
||||
.filter(Boolean) as Tag[];
|
||||
const hostLabel = connection.port
|
||||
? `${connection.host}:${connection.port}`
|
||||
: connection.host;
|
||||
const isSelected = selectedItemIds.includes(connection.id);
|
||||
|
||||
const handleClick = () => {
|
||||
if (selectedItemIds.length > 0) {
|
||||
// Something already selected — toggle this item in the selection
|
||||
toggleItemSelection(connection.id);
|
||||
} else {
|
||||
// Nothing selected — open the connection
|
||||
onOpenDbViewer?.(connection.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={`relative group rounded-xl border transition-colors cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-accent/10 border-accent"
|
||||
: "bg-surface border-border hover:border-border-hover"
|
||||
}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-lg bg-surface-raised border border-border flex items-center justify-center text-xl">
|
||||
{DB_ICONS[connection.db_type] ?? "❓"}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold truncate text-text">
|
||||
{connection.name}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
{DB_LABELS[connection.db_type] ??
|
||||
connection.db_type}
|
||||
</div>
|
||||
</div>
|
||||
{connection.environment && (
|
||||
<span
|
||||
className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium leading-none ${ENV_COLORS[connection.environment] ?? "bg-surface-raised border-border text-text-muted"}`}
|
||||
>
|
||||
{ENV_LABELS[connection.environment] ?? connection.environment}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mb-2 font-mono truncate">
|
||||
{hostLabel}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{cardTags.map((t) => (
|
||||
<TagBadge key={t.id} tag={t} onToggle={onTagToggle} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleItemSelection(connection.id);
|
||||
}}
|
||||
className={`absolute -top-1.5 -left-1.5 w-4 h-4 rounded border flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? "bg-accent border-accent opacity-100"
|
||||
: "border-border bg-surface opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
{isSelected && <Check size={12} className="text-white" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ConnectionCard = memo(ConnectionCardBase);
|
||||
@@ -0,0 +1,70 @@
|
||||
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,
|
||||
}: ConnectionFormShellProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<div className="max-w-lg mx-auto p-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
className="mb-4 -ml-3 justify-start gap-1 px-3"
|
||||
>
|
||||
<ChevronLeft size={16} /> Back
|
||||
</Button>
|
||||
|
||||
<div className="space-y-4">{children}</div>
|
||||
|
||||
<div className="flex gap-3 mt-8">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onTest}
|
||||
disabled={testLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{testLoading ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={saveLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{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,69 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConnectionGrid } from "./ConnectionGrid";
|
||||
import type { Connection, Folder } from "../../lib/types";
|
||||
|
||||
const makeConn = (id: string, folder_id: string | null = null): Connection => ({
|
||||
id, name: `Conn ${id}`, db_type: "postgresql", host: "h", port: 5432,
|
||||
username: null, folder_id, keychain_ref: null, tag_ids: [],
|
||||
created_at: "", updated_at: "",
|
||||
});
|
||||
|
||||
const folders: Folder[] = [
|
||||
{ id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
{ id: "f2", name: "Personal", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
{ id: "f3", name: "Client A", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" },
|
||||
];
|
||||
|
||||
describe("ConnectionGrid", () => {
|
||||
it("renders empty state when no connections and no folders", () => {
|
||||
render(<ConnectionGrid connections={[]} tags={[]} />);
|
||||
expect(screen.getByText(/no connections yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders cards for each connection", () => {
|
||||
const conns = [makeConn("1"), makeConn("2")];
|
||||
render(<ConnectionGrid connections={conns} tags={[]} />);
|
||||
expect(screen.getByText("Conn 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Conn 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no-results state when filtered empty", () => {
|
||||
render(<ConnectionGrid connections={[]} tags={[]} hasSearch />);
|
||||
expect(screen.getByText(/no connections match/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only top-level folders at root", () => {
|
||||
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} />);
|
||||
expect(screen.getByText("Work")).toBeInTheDocument();
|
||||
expect(screen.getByText("Personal")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Client A")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only children of active folder", () => {
|
||||
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} activeFolderId="f1" />);
|
||||
expect(screen.getByText("Client A")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Personal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onFolderSelect with folder id on click", async () => {
|
||||
const fn = vi.fn();
|
||||
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} onFolderSelect={fn} />);
|
||||
await userEvent.click(screen.getByText("Work"));
|
||||
expect(fn).toHaveBeenCalledWith("f1");
|
||||
});
|
||||
|
||||
it("breadcrumb navigates to root", async () => {
|
||||
const fn = vi.fn();
|
||||
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} activeFolderId="f1" onFolderSelect={fn} />);
|
||||
await userEvent.click(screen.getByText(/all connections/i));
|
||||
expect(fn).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("shows folder cards", () => {
|
||||
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} />);
|
||||
expect(screen.getByText("Work")).toBeInTheDocument();
|
||||
expect(screen.getByText("Personal")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { Connection, Folder, Tag } from "../../lib/types";
|
||||
import { Folder as FolderIcon, Check, Pencil, Trash2 } from "lucide-react";
|
||||
import { ConnectionCard } from "./ConnectionCard";
|
||||
import { FolderBreadcrumb } from "../folders/FolderBreadcrumb";
|
||||
import { getChildFolders } from "../../lib/utils";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { TagBadge } from "../tags/TagBadge";
|
||||
|
||||
interface ConnectionGridProps {
|
||||
connections: Connection[];
|
||||
tags: Tag[];
|
||||
folders?: Folder[];
|
||||
activeFolderId?: string | null;
|
||||
onFolderSelect?: (id: string | null) => void;
|
||||
hasSearch?: boolean;
|
||||
onTagToggle?: (id: string) => void;
|
||||
onEditFolder?: (folder: Folder) => void;
|
||||
onDeleteFolder?: (folder: Folder) => void;
|
||||
onOpenDbViewer?: (connectionId: string) => void;
|
||||
}
|
||||
|
||||
export function ConnectionGrid({
|
||||
connections,
|
||||
tags,
|
||||
folders = [],
|
||||
activeFolderId = null,
|
||||
onFolderSelect,
|
||||
hasSearch = false,
|
||||
onTagToggle,
|
||||
onEditFolder,
|
||||
onDeleteFolder,
|
||||
onOpenDbViewer,
|
||||
}: ConnectionGridProps) {
|
||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
|
||||
const clearSelection = useUiStore((s) => s.clearSelection);
|
||||
|
||||
const currentFolderId =
|
||||
activeFolderId !== null && folders.some((f) => f.id === activeFolderId)
|
||||
? activeFolderId
|
||||
: null;
|
||||
const visibleFolders = hasSearch
|
||||
? []
|
||||
: getChildFolders(folders, currentFolderId);
|
||||
const directConnections = connections.filter(
|
||||
(c) => c.folder_id === currentFolderId,
|
||||
);
|
||||
const hasItems = visibleFolders.length > 0 || directConnections.length > 0;
|
||||
const isSelecting = selectedItemIds.length > 0;
|
||||
const activeFolder = currentFolderId
|
||||
? (folders.find((f) => f.id === currentFolderId) ?? null)
|
||||
: null;
|
||||
|
||||
const handleFolderClick = (folderId: string) => {
|
||||
if (isSelecting) {
|
||||
toggleItemSelection(folderId);
|
||||
} else {
|
||||
onFolderSelect?.(folderId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBreadcrumbNavigate = (folderId: string | null) => {
|
||||
clearSelection();
|
||||
onFolderSelect?.(folderId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<FolderBreadcrumb
|
||||
folders={folders}
|
||||
activeFolderId={currentFolderId}
|
||||
onNavigate={handleBreadcrumbNavigate}
|
||||
/>
|
||||
{activeFolder && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onEditFolder?.(activeFolder)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text transition-colors px-2 py-1 rounded-md cursor-pointer"
|
||||
>
|
||||
<Pencil size={12} /> Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteFolder?.(activeFolder)}
|
||||
className="inline-flex items-center gap-1 text-xs !text-red-400 hover:!text-red-300 transition-colors px-2 py-1 rounded-md cursor-pointer"
|
||||
>
|
||||
<Trash2 size={12} /> Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasItems ? (
|
||||
<div className="text-center w-full py-16 text-text-muted">
|
||||
{hasSearch
|
||||
? "No connections match your search."
|
||||
: activeFolderId
|
||||
? "This folder is empty. Add a connection or subfolder."
|
||||
: "No connections yet. Create one to get started."}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
"repeat(auto-fill, minmax(260px, 1fr))",
|
||||
}}
|
||||
>
|
||||
{visibleFolders.map((f) => {
|
||||
const isSelected = selectedItemIds.includes(f.id);
|
||||
const count = directConnections.filter(
|
||||
(c) => c.folder_id === f.id,
|
||||
).length;
|
||||
const subfolderCount = getChildFolders(
|
||||
folders,
|
||||
f.id,
|
||||
).length;
|
||||
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
||||
const folderTags = f.tag_ids
|
||||
.map((id) => tagMap.get(id))
|
||||
.filter(Boolean) as import("../../lib/types").Tag[];
|
||||
return (
|
||||
<div
|
||||
key={f.id}
|
||||
className={`relative group rounded-xl border transition-colors ${
|
||||
isSelected
|
||||
? "bg-accent/10 border-accent"
|
||||
: "bg-surface border-border hover:border-border-hover"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleFolderClick(f.id)}
|
||||
className="w-full p-3 text-left min-w-0 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderIcon
|
||||
size={18}
|
||||
className={
|
||||
isSelected
|
||||
? "text-accent"
|
||||
: "text-text-muted"
|
||||
}
|
||||
/>
|
||||
<span className="font-semibold text-sm truncate text-text">
|
||||
{f.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1">
|
||||
{count > 0 &&
|
||||
`${count} item${count !== 1 ? "s" : ""}`}
|
||||
{count > 0 &&
|
||||
subfolderCount > 0 &&
|
||||
" · "}
|
||||
{subfolderCount > 0 &&
|
||||
`${subfolderCount} subfolder${subfolderCount !== 1 ? "s" : ""}`}
|
||||
{count === 0 &&
|
||||
subfolderCount === 0 &&
|
||||
"Empty folder"}
|
||||
</div>
|
||||
{folderTags.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap mt-2">
|
||||
{folderTags.map((t) => (
|
||||
<TagBadge key={t.id} tag={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleItemSelection(f.id);
|
||||
}}
|
||||
className={`absolute -top-1.5 -left-1.5 w-4 h-4 rounded border flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? "bg-accent border-accent opacity-100"
|
||||
: "border-border bg-surface opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<Check
|
||||
size={12}
|
||||
className="text-white"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{directConnections.map((c) => (
|
||||
<ConnectionCard
|
||||
key={c.id}
|
||||
connection={c}
|
||||
tags={tags}
|
||||
onTagToggle={onTagToggle}
|
||||
onOpenDbViewer={onOpenDbViewer}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
|
||||
interface ConnectionStringInputProps {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const ConnectionStringInput = forwardRef<HTMLInputElement, ConnectionStringInputProps>(
|
||||
function ConnectionStringInput({ onChange, onKeyDown, className = "", ...rest }, ref) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={`w-full rounded-lg bg-surface border border-border px-4 py-3 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 font-mono ${className}`}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
onKeyDown={(e) => onKeyDown?.(e)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
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 { DetailedConnectionForm } from "./DetailedConnectionForm";
|
||||
|
||||
import type { ConnectionFormData } from "./connectionFormData";
|
||||
import type { DetailedConnectionFormProps } from "./DetailedConnectionForm";
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
function StatefulForm(
|
||||
props: Omit<DetailedConnectionFormProps, "form" | "onChange"> & {
|
||||
onChange?: (updates: Partial<ConnectionFormData>) => void;
|
||||
},
|
||||
) {
|
||||
const [form, setForm] = useState<ConnectionFormData>(BASE_FORM);
|
||||
return (
|
||||
<DetailedConnectionForm
|
||||
{...props}
|
||||
form={form}
|
||||
onChange={(updates) => {
|
||||
setForm((prev) => ({ ...prev, ...updates }));
|
||||
props.onChange?.(updates);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("DetailedConnectionForm", () => {
|
||||
it("updates host and port", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<StatefulForm onChange={onChange} />);
|
||||
await user.type(screen.getByLabelText(/host/i), "localhost");
|
||||
await user.clear(screen.getByLabelText(/port/i));
|
||||
await user.type(screen.getByLabelText(/port/i), "5432");
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ host: "localhost" }));
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ port: 5432 }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import { GeneralTab } from "./GeneralTab";
|
||||
import { SshSslTab } from "./SshSslTab";
|
||||
import { TagsEnvTab } from "./TagsEnvTab";
|
||||
import type { ConnectionFormData } from "./connectionFormData";
|
||||
|
||||
export interface DetailedConnectionFormProps {
|
||||
form: ConnectionFormData;
|
||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||
}
|
||||
|
||||
export function DetailedConnectionForm({ form, onChange }: DetailedConnectionFormProps) {
|
||||
const [activeTab, setActiveTab] = useState<"general" | "ssh" | "tags">("general");
|
||||
|
||||
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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
|
||||
export type Environment = "production" | "staging" | "development" | null;
|
||||
|
||||
interface EnvironmentSelectProps {
|
||||
value: Environment;
|
||||
onChange: (value: Environment) => void;
|
||||
}
|
||||
|
||||
const OPTIONS: { value: Environment; label: string }[] = [
|
||||
{ value: null, label: "None" },
|
||||
{ value: "production", label: "Production" },
|
||||
{ value: "staging", label: "Staging" },
|
||||
{ value: "development", label: "Development" },
|
||||
];
|
||||
|
||||
export function EnvironmentSelect({ value, onChange }: EnvironmentSelectProps) {
|
||||
return (
|
||||
<SelectDropdown
|
||||
value={value ?? ""}
|
||||
onChange={(next) =>
|
||||
onChange(next === "" ? null : (next as Environment))
|
||||
}
|
||||
options={OPTIONS.map((opt) => ({
|
||||
value: opt.value ?? "",
|
||||
label: opt.label,
|
||||
}))}
|
||||
placeholder="None"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import type { Folder } from "../../lib/types";
|
||||
import { getFolderPathLabel } from "../../lib/utils";
|
||||
|
||||
interface FolderSelectProps {
|
||||
folders: Folder[];
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
}
|
||||
|
||||
export function FolderSelect({ folders, value, onChange }: FolderSelectProps) {
|
||||
const options = [
|
||||
{ value: "", label: "None" },
|
||||
...folders.map((folder) => ({
|
||||
value: folder.id,
|
||||
label: getFolderPathLabel(folders, folder.id),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<SelectDropdown
|
||||
value={value ?? ""}
|
||||
onChange={(next) => onChange(next === "" ? null : next)}
|
||||
options={options}
|
||||
placeholder="None"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
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,
|
||||
};
|
||||
|
||||
describe("GeneralTab", () => {
|
||||
it("renders host, port, user, password, and database fields", () => {
|
||||
render(<GeneralTab form={BASE_FORM} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByLabelText("Host")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Port")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("User")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Password")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Database")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides host and port for sqlite but shows database", () => {
|
||||
render(<GeneralTab form={{ ...BASE_FORM, db_type: "sqlite" }} onChange={() => {}} />);
|
||||
|
||||
expect(screen.queryByLabelText("Host")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Port")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Database")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Input } from "../ui/Input";
|
||||
import { PasswordInput } from "./PasswordInput";
|
||||
import type { ConnectionFormData } from "./connectionFormData";
|
||||
|
||||
export interface GeneralTabProps {
|
||||
form: ConnectionFormData;
|
||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||
}
|
||||
|
||||
const AUTH_OPTIONS = ["User & Password"];
|
||||
|
||||
export function GeneralTab({ form, onChange }: GeneralTabProps) {
|
||||
const isSqlite = form.db_type === "sqlite";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!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>
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
Enable keychain
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { NewConnectionScreen } from "./NewConnectionScreen";
|
||||
|
||||
const { createConnection, notify, testConnection } = vi.hoisted(() => ({
|
||||
createConnection: vi.fn().mockResolvedValue({}),
|
||||
notify: vi.fn(),
|
||||
testConnection: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
|
||||
vi.mock("../../stores/connectionStore", () => ({
|
||||
createConnection,
|
||||
useConnectionStore: (selector: (s: { createConnection: typeof createConnection }) => unknown) =>
|
||||
selector({ createConnection }),
|
||||
}));
|
||||
|
||||
vi.mock("../../stores/notificationStore", () => ({
|
||||
notify,
|
||||
useNotificationStore: (selector: (s: { notify: typeof notify }) => unknown) =>
|
||||
selector({ notify }),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/commands", () => ({
|
||||
testConnection,
|
||||
}));
|
||||
|
||||
describe("NewConnectionScreen", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen folders={[]} tags={[]} />);
|
||||
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 () => {
|
||||
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",
|
||||
);
|
||||
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(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",
|
||||
);
|
||||
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} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { ConnectionFormShell } from "./ConnectionFormShell";
|
||||
import { SimpleConnectionForm } from "./SimpleConnectionForm";
|
||||
import { DetailedConnectionForm } from "./DetailedConnectionForm";
|
||||
import { parseConnectionString } from "../../lib/connectionString";
|
||||
import { validateConnectionInput } from "../../lib/utils";
|
||||
import { testConnection } from "../../lib/commands";
|
||||
import type {
|
||||
Folder,
|
||||
Tag,
|
||||
NewConnectionMode,
|
||||
ConnectionInput,
|
||||
} from "../../lib/types";
|
||||
import type { ConnectionFormData } from "./connectionFormData";
|
||||
|
||||
interface NewConnectionScreenProps {
|
||||
defaultFolderId?: string | null;
|
||||
prefilledConnectionString?: string;
|
||||
folders: Folder[];
|
||||
tags: Tag[];
|
||||
onSaved?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
function createEmptyForm(
|
||||
defaultFolderId: string | null = null,
|
||||
): ConnectionFormData {
|
||||
return {
|
||||
name: "",
|
||||
environment: null,
|
||||
folder_id: defaultFolderId,
|
||||
tag_ids: [],
|
||||
connection_string: "",
|
||||
db_type: "postgresql",
|
||||
host: "",
|
||||
port: 5432,
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
use_keychain: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function NewConnectionScreen({
|
||||
defaultFolderId = null,
|
||||
prefilledConnectionString = "",
|
||||
folders,
|
||||
tags,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: NewConnectionScreenProps) {
|
||||
const [mode, setMode] = useState<NewConnectionMode>("simple");
|
||||
const [form, setForm] = useState<ConnectionFormData>(() =>
|
||||
createEmptyForm(defaultFolderId),
|
||||
);
|
||||
const [testLoading, setTestLoading] = useState(false);
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const createConnection = useConnectionStore((s) => s.createConnection);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
|
||||
const handleConnectionStringChange = useCallback((value: string) => {
|
||||
setForm((prev) => {
|
||||
const parsed = parseConnectionString(value);
|
||||
if (!parsed) return { ...prev, connection_string: value };
|
||||
return {
|
||||
...prev,
|
||||
connection_string: value,
|
||||
db_type: parsed.db_type,
|
||||
host: parsed.host,
|
||||
port: parsed.port,
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
database: parsed.database,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (prefilledConnectionString) {
|
||||
handleConnectionStringChange(prefilledConnectionString);
|
||||
}
|
||||
}, [prefilledConnectionString, handleConnectionStringChange]);
|
||||
|
||||
const updateForm = useCallback((updates: Partial<ConnectionFormData>) => {
|
||||
setForm((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
const buildPayload = useCallback((): ConnectionInput => {
|
||||
return {
|
||||
name: form.name,
|
||||
db_type: form.db_type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username,
|
||||
folder_id: form.folder_id,
|
||||
tag_ids: form.tag_ids,
|
||||
connection_string: form.connection_string,
|
||||
environment: form.environment,
|
||||
password: form.password,
|
||||
database: form.database,
|
||||
use_keychain: form.use_keychain,
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
const validate = useCallback((): string | null => {
|
||||
const result = validateConnectionInput(buildPayload());
|
||||
return result.ok ? null : result.error;
|
||||
}, [buildPayload]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const error = validate();
|
||||
if (error) {
|
||||
notify(error, "error");
|
||||
return;
|
||||
}
|
||||
setSaveLoading(true);
|
||||
try {
|
||||
await createConnection(buildPayload());
|
||||
notify("Connection saved", "success");
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
notify(`Failed to save connection: ${message}`, "error");
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
}, [validate, notify, createConnection, buildPayload, onSaved]);
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
const error = validate();
|
||||
if (error) {
|
||||
notify(error, "error");
|
||||
return;
|
||||
}
|
||||
setTestLoading(true);
|
||||
try {
|
||||
const result = await testConnection(buildPayload());
|
||||
if (result.ok) {
|
||||
notify("Connection successful", "success");
|
||||
} else {
|
||||
notify(result.error ?? "Connection failed", "error");
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
notify(`Connection test failed: ${message}`, "error");
|
||||
} finally {
|
||||
setTestLoading(false);
|
||||
}
|
||||
}, [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"));
|
||||
}, []);
|
||||
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
<DetailedConnectionForm form={form} onChange={updateForm} />
|
||||
)}
|
||||
</ConnectionFormShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState, forwardRef } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
|
||||
interface PasswordInputProps {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||
function PasswordInput({ onChange, onKeyDown, className = "", ...rest }, ref) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<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}`}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
onKeyDown={(e) => onKeyDown?.(e)}
|
||||
{...rest}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
aria-label={visible ? "Hide password" : "Show password"}
|
||||
>
|
||||
{visible ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,81 @@
|
||||
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,
|
||||
};
|
||||
|
||||
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: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
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,46 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SshFields } from "./SshFields";
|
||||
|
||||
const notify = vi.fn();
|
||||
|
||||
vi.mock("../../stores/notificationStore", () => ({
|
||||
useNotificationStore: (selector: (s: { notify: typeof notify }) => unknown) =>
|
||||
selector({ notify }),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("SshFields", () => {
|
||||
it("renders SSH host, port, and user fields", () => {
|
||||
render(<SshFields values={{}} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByLabelText("SSH Host")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("SSH Port")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("SSH User")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders auth method dropdown", () => {
|
||||
render(<SshFields values={{}} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Auth Method" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows private key and passphrase fields when auth method is key", () => {
|
||||
render(<SshFields values={{ ssh_auth_method: "key" }} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByLabelText("Private Key")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Passphrase")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("SSH Password")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows password field when auth method is password", () => {
|
||||
render(<SshFields values={{ ssh_auth_method: "password" }} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByLabelText("SSH Password")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Private Key")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Passphrase")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Input } from "../ui/Input";
|
||||
import { PasswordInput } from "./PasswordInput";
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
|
||||
export interface SshFieldsProps {
|
||||
values: Record<string, unknown>;
|
||||
onChange: (updates: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const AUTH_METHOD_OPTIONS = [
|
||||
{ value: "password", label: "Password" },
|
||||
{ value: "key", label: "Private Key" },
|
||||
];
|
||||
|
||||
export function SshFields({ values, onChange }: SshFieldsProps) {
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
const authMethod = (values.ssh_auth_method as string) ?? "password";
|
||||
|
||||
const handlePickFile = async (field: string) => {
|
||||
try {
|
||||
const path = await open({ multiple: false, directory: false });
|
||||
if (path) {
|
||||
onChange({ [field]: path });
|
||||
}
|
||||
} catch {
|
||||
notify("File picker not available", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">SSH Host</label>
|
||||
<Input
|
||||
value={(values.ssh_host as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssh_host: value })}
|
||||
placeholder="bastion.example.com"
|
||||
aria-label="SSH Host"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">SSH Port</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={(values.ssh_port as number)?.toString() ?? "22"}
|
||||
onChange={(value) => onChange({ ssh_port: value === "" ? null : Number(value) })}
|
||||
placeholder="22"
|
||||
aria-label="SSH Port"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">SSH User</label>
|
||||
<Input
|
||||
value={(values.ssh_user as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssh_user: value })}
|
||||
placeholder="ssh-user"
|
||||
aria-label="SSH User"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Auth Method</label>
|
||||
<SelectDropdown
|
||||
value={authMethod}
|
||||
onChange={(value) => onChange({ ssh_auth_method: value })}
|
||||
options={AUTH_METHOD_OPTIONS}
|
||||
aria-label="Auth Method"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{authMethod === "key" ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Private Key</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={(values.ssh_private_key as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssh_private_key: value })}
|
||||
placeholder="/path/to/key"
|
||||
aria-label="Private Key"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickFile("ssh_private_key")}
|
||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Passphrase</label>
|
||||
<PasswordInput
|
||||
value={(values.ssh_passphrase as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssh_passphrase: value })}
|
||||
placeholder="••••••••"
|
||||
aria-label="Passphrase"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">SSH Password</label>
|
||||
<PasswordInput
|
||||
value={(values.ssh_password as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssh_password: value })}
|
||||
placeholder="••••••••"
|
||||
aria-label="SSH Password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SshSslTab } from "./SshSslTab";
|
||||
|
||||
vi.mock("../../stores/notificationStore", () => ({
|
||||
useNotificationStore: () => ({
|
||||
notify: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("SshSslTab", () => {
|
||||
it("renders SSH and SSL sub-tab buttons", () => {
|
||||
render(<SshSslTab form={{}} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "SSH" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "SSL" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles between SSH and SSL content", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SshSslTab form={{}} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByLabelText("SSH Host")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "SSL Mode" })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "SSL" }));
|
||||
|
||||
expect(screen.queryByLabelText("SSH Host")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "SSL Mode" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "SSH" }));
|
||||
|
||||
expect(screen.getByLabelText("SSH Host")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "SSL Mode" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from "react";
|
||||
import { SshFields } from "./SshFields";
|
||||
import { SslFields } from "./SslFields";
|
||||
|
||||
export interface SshSslTabProps {
|
||||
form: Record<string, unknown>;
|
||||
onChange: (updates: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function SshSslTab({ form, onChange }: SshSslTabProps) {
|
||||
const [activeSubTab, setActiveSubTab] = useState<"ssh" | "ssl">("ssh");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-4 border-b border-border mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveSubTab("ssh")}
|
||||
className={`pb-2 text-sm cursor-pointer transition-colors ${
|
||||
activeSubTab === "ssh" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
SSH
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveSubTab("ssl")}
|
||||
className={`pb-2 text-sm cursor-pointer transition-colors ${
|
||||
activeSubTab === "ssl" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
SSL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeSubTab === "ssh" ? <SshFields values={form} onChange={onChange} /> : <SslFields values={form} onChange={onChange} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SslFields } from "./SslFields";
|
||||
|
||||
const notify = vi.fn();
|
||||
|
||||
vi.mock("../../stores/notificationStore", () => ({
|
||||
useNotificationStore: (selector: (s: { notify: typeof notify }) => unknown) =>
|
||||
selector({ notify }),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("SslFields", () => {
|
||||
it("renders SSL mode dropdown", () => {
|
||||
render(<SslFields values={{}} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "SSL Mode" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows file pickers for verify-full mode", () => {
|
||||
render(<SslFields values={{ ssl_mode: "verify-full" }} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByLabelText("CA Certificate")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Client Certificate")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Client Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides file pickers for disable mode", () => {
|
||||
render(<SslFields values={{ ssl_mode: "disable" }} onChange={() => {}} />);
|
||||
|
||||
expect(screen.queryByLabelText("CA Certificate")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Client Certificate")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Client Key")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Input } from "../ui/Input";
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
|
||||
export interface SslFieldsProps {
|
||||
values: Record<string, unknown>;
|
||||
onChange: (updates: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const SSL_MODE_OPTIONS = [
|
||||
{ value: "disable", label: "Disable" },
|
||||
{ value: "require", label: "Require" },
|
||||
{ value: "verify-ca", label: "Verify CA" },
|
||||
{ value: "verify-full", label: "Verify Full" },
|
||||
];
|
||||
|
||||
export function SslFields({ values, onChange }: SslFieldsProps) {
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
const mode = (values.ssl_mode as string) ?? "disable";
|
||||
const showCertFields = mode === "verify-ca" || mode === "verify-full";
|
||||
|
||||
const handlePickFile = async (field: string) => {
|
||||
try {
|
||||
const path = await open({ multiple: false, directory: false });
|
||||
if (path) {
|
||||
onChange({ [field]: path });
|
||||
}
|
||||
} catch {
|
||||
notify("File picker not available", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">SSL Mode</label>
|
||||
<SelectDropdown
|
||||
value={mode}
|
||||
onChange={(value) => onChange({ ssl_mode: value })}
|
||||
options={SSL_MODE_OPTIONS}
|
||||
aria-label="SSL Mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === "require" && (
|
||||
<p className="text-sm text-warning bg-warning/10 border border-warning/20 rounded-lg px-3 py-2">
|
||||
Require mode is vulnerable to man-in-the-middle attacks because it does not verify the server certificate.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showCertFields && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">CA Certificate</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={(values.ssl_ca_cert as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_ca_cert: value })}
|
||||
placeholder="/path/to/ca-cert.pem"
|
||||
aria-label="CA Certificate"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickFile("ssl_ca_cert")}
|
||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Client Certificate</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={(values.ssl_client_cert as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_client_cert: value })}
|
||||
placeholder="/path/to/client-cert.pem"
|
||||
aria-label="Client Certificate"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickFile("ssl_client_cert")}
|
||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Client Key</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={(values.ssl_client_key as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_client_key: value })}
|
||||
placeholder="/path/to/client-key.pem"
|
||||
aria-label="Client Key"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickFile("ssl_client_key")}
|
||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { EnvironmentSelect } from "../connections/EnvironmentSelect";
|
||||
import { FolderSelect } from "../connections/FolderSelect";
|
||||
import { SearchableTagPicker } from "../tags/SearchableTagPicker";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import type { ConnectionFormData } from "../connections/connectionFormData";
|
||||
|
||||
interface TagsEnvTabProps {
|
||||
form: ConnectionFormData;
|
||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||
}
|
||||
|
||||
export function TagsEnvTab({ form, onChange }: TagsEnvTabProps) {
|
||||
const folders = useConnectionStore((s) => s.folders);
|
||||
const tags = useConnectionStore((s) => s.tags);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Tags</label>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { DbType } from "../../lib/types";
|
||||
import type { Environment } from "./EnvironmentSelect";
|
||||
|
||||
export interface ConnectionFormData {
|
||||
name: string;
|
||||
environment: Environment;
|
||||
folder_id: string | null;
|
||||
tag_ids: string[];
|
||||
connection_string: string;
|
||||
db_type: DbType;
|
||||
host: string;
|
||||
port: number | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
database: string | null;
|
||||
use_keychain: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user