From 6b471349c81a4cbc602226019f2853f54ad56189 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 4 Jul 2026 16:57:32 +1000 Subject: [PATCH 1/2] refactor: decompose ConnectionManager into form/fields/dialogs/validation modules (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the 1464-line ConnectionManager.tsx into focused modules: - lib/connectionForm.ts — makeEmptyForm + pure getConnectionFormValidity - components/ConnectionFormFields.tsx — FieldLabel/FieldInput/SelectInput/FormRow/ElephantIcon - components/ConnectionDialogs.tsx — UnsavedChangesDialog/ConfirmDialog - components/ConnectionForm.tsx — the ConnectionForm component (+ TestResult) ConnectionManager.tsx (514 lines) now owns only the manager shell. Inline field-presence validation is lifted into getConnectionFormValidity and unit-tested. Tests split to match the new modules. No behavior change; 456 unit + 91 e2e pass. Note: FieldLabel and _SectionLabel were already dead code before this change and are preserved untouched (per repo convention). Part of M2 code-review finding. --- frontend/package.json.md5 | 2 +- frontend/src/components/ConnectionDialogs.tsx | 185 ++++ .../src/components/ConnectionForm.test.tsx | 283 ++++++ frontend/src/components/ConnectionForm.tsx | 604 +++++++++++ .../components/ConnectionFormFields.test.tsx | 57 ++ .../src/components/ConnectionFormFields.tsx | 159 +++ .../src/components/ConnectionManager.test.tsx | 358 +------ frontend/src/components/ConnectionManager.tsx | 960 +----------------- frontend/src/lib/connectionForm.test.ts | 43 + frontend/src/lib/connectionForm.ts | 34 + 10 files changed, 1372 insertions(+), 1313 deletions(-) create mode 100644 frontend/src/components/ConnectionDialogs.tsx create mode 100644 frontend/src/components/ConnectionForm.test.tsx create mode 100644 frontend/src/components/ConnectionForm.tsx create mode 100644 frontend/src/components/ConnectionFormFields.test.tsx create mode 100644 frontend/src/components/ConnectionFormFields.tsx create mode 100644 frontend/src/lib/connectionForm.test.ts create mode 100644 frontend/src/lib/connectionForm.ts diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 3408cae..665a8c9 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -cf6eded41d035b667c5da328142de08b \ No newline at end of file +efb05f170350b5f5ae34f295564d7b4b \ No newline at end of file diff --git a/frontend/src/components/ConnectionDialogs.tsx b/frontend/src/components/ConnectionDialogs.tsx new file mode 100644 index 0000000..aebd3e2 --- /dev/null +++ b/frontend/src/components/ConnectionDialogs.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react'; +import { T } from '../lib/tokens'; + +// ── Unsaved changes dialog ─────────────────────────────────────────────────── +export function UnsavedChangesDialog({ + onDiscard, + onCancel, + onSave, +}: { + onDiscard: () => void; + onCancel: () => void; + onSave: () => Promise; +}) { + const [saving, setSaving] = useState(false); + const handleSave = async () => { + setSaving(true); + try { + await onSave(); + } finally { + setSaving(false); + } + }; + return ( +
+
+
+ You have unsaved changes. What would you like to do? +
+
+ + + +
+
+
+ ); +} + +// ── Confirm delete dialog ──────────────────────────────────────────────────── +export function ConfirmDialog({ + message, + onConfirm, + onCancel, +}: { + message: string; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( +
+
+
+ {message} +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/ConnectionForm.test.tsx b/frontend/src/components/ConnectionForm.test.tsx new file mode 100644 index 0000000..193b1cc --- /dev/null +++ b/frontend/src/components/ConnectionForm.test.tsx @@ -0,0 +1,283 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Datasource } from '../types'; +import { ConnectionForm } from './ConnectionForm'; + +describe('ConnectionForm', () => { + const defaultProps = { + initial: {}, + projectId: 'p1', + onSave: vi.fn().mockResolvedValue(undefined), + onCancel: vi.fn(), + onTest: vi.fn().mockResolvedValue({ success: true, message: 'ok' }), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders all General tab fields', () => { + render(); + expect(screen.getByTestId('field-name')).toBeInTheDocument(); + expect(screen.getByTestId('field-host')).toBeInTheDocument(); + expect(screen.getByTestId('field-port')).toBeInTheDocument(); + expect(screen.getByTestId('field-database')).toBeInTheDocument(); + expect(screen.getByTestId('field-username')).toBeInTheDocument(); + expect(screen.getByTestId('field-password')).toBeInTheDocument(); + expect(screen.getByTestId('field-env')).toBeInTheDocument(); + // SSL mode is in the SSL tab, not the General tab + }); + + it('pre-populates from initial props', () => { + render( + + ); + expect(screen.getByTestId('field-name')).toHaveValue('prod-db'); + expect(screen.getByTestId('field-host')).toHaveValue('db.example.com'); + expect(screen.getByTestId('field-database')).toHaveValue('proddb'); + expect(screen.getByTestId('field-env')).toHaveValue('prod'); + // sslMode is stored in form state but field-ssl is on SSL tab + }); + + it('Save button disabled when name empty', () => { + render(); + expect(screen.getByTestId('btn-save')).toBeDisabled(); + }); + + it('Save button enabled when name + host + database filled', async () => { + render(); + await userEvent.type(screen.getByTestId('field-name'), 'myconn'); + await userEvent.type(screen.getByTestId('field-database'), 'mydb'); + expect(screen.getByTestId('btn-save')).not.toBeDisabled(); + }); + + it('Cancel calls onCancel', async () => { + const onCancel = vi.fn(); + render(); + await userEvent.click(screen.getByTestId('btn-cancel')); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it('Save calls onSave with complete datasource', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render( + + ); + await userEvent.click(screen.getByTestId('btn-save')); + await waitFor(() => expect(onSave).toHaveBeenCalledOnce()); + const saved = onSave.mock.calls[0][0] as Datasource; + expect(saved.projectId).toBe('p1'); + expect(saved.name).toBe('myconn'); + expect(saved.database).toBe('mydb'); + }); + + it('shows save error when onSave rejects', async () => { + const onSave = vi.fn().mockRejectedValue(new Error('save failed')); + render( + + ); + await userEvent.click(screen.getByTestId('btn-save')); + await waitFor(() => expect(screen.getByTestId('save-error')).toBeInTheDocument()); + expect(screen.getByTestId('save-error')).toHaveTextContent('save failed'); + }); + + it('Test button calls onTest with form values', async () => { + const onTest = vi.fn().mockResolvedValue({ success: true, message: '12ms' }); + render( + + ); + await userEvent.click(screen.getByTestId('btn-test')); + await waitFor(() => expect(onTest).toHaveBeenCalledOnce()); + const arg = onTest.mock.calls[0][0] as Partial; + expect(arg.host).toBe('myhost'); + expect(arg.database).toBe('mydb'); + expect(arg.sslMode).toBe('require'); + }); + + it('shows success test result', async () => { + const onTest = vi.fn().mockResolvedValue({ success: true, message: '8ms' }); + render( + + ); + await userEvent.click(screen.getByTestId('btn-test')); + await waitFor(() => expect(screen.getByTestId('test-result')).toBeInTheDocument()); + expect(screen.getByTestId('test-result')).toHaveTextContent('Connection succeeded'); + }); + + it('shows failure test result', async () => { + const onTest = vi.fn().mockResolvedValue({ success: false, message: 'refused' }); + render( + + ); + await userEvent.click(screen.getByTestId('btn-test')); + await waitFor(() => expect(screen.getByTestId('test-result')).toBeInTheDocument()); + expect(screen.getByTestId('test-result')).toHaveTextContent('Connection failed'); + }); + + it('toggles password visibility', async () => { + render(); + const pw = screen.getByTestId('field-password'); + expect(pw).toHaveAttribute('type', 'password'); + await userEvent.click(screen.getByTestId('toggle-password')); + expect(pw).toHaveAttribute('type', 'text'); + await userEvent.click(screen.getByTestId('toggle-password')); + expect(pw).toHaveAttribute('type', 'password'); + }); + + it('Test button disabled when host or database empty', () => { + render(); + expect(screen.getByTestId('btn-test')).toBeDisabled(); + }); + + it('shows connection name in name field for existing datasource', () => { + render(); + expect(screen.getByTestId('field-name')).toHaveValue('existing'); + }); + + it('shows New Data Source heading for new datasource', () => { + render(); + expect(screen.getByText('New Data Source')).toBeInTheDocument(); + }); + + it('Options tab shows placeholder; SSL tab shows ssl field', async () => { + render(); + await userEvent.click(screen.getByRole('tab', { name: 'Options' })); + expect(screen.getByText(/Not yet implemented/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('tab', { name: 'SSL' })); + expect(screen.getByTestId('field-ssl')).toBeInTheDocument(); + }); + + it('URL preview updates as host/port/db change', async () => { + render(); + await userEvent.clear(screen.getByTestId('field-host')); + await userEvent.type(screen.getByTestId('field-host'), 'myserver'); + expect(screen.getByText(/myserver/)).toBeInTheDocument(); + }); + + it('Apply button calls onApply with current form values', async () => { + const onApply = vi.fn().mockResolvedValue(undefined); + render( + + ); + await userEvent.click(screen.getByTestId('btn-apply')); + await waitFor(() => expect(onApply).toHaveBeenCalledOnce()); + const applied = onApply.mock.calls[0][0] as Datasource; + expect(applied.name).toBe('myconn'); + expect(applied.database).toBe('mydb'); + }); + + it('Cancel and Apply disabled initially when editing existing datasource', () => { + render( + + ); + expect(screen.getByTestId('btn-cancel')).toBeDisabled(); + expect(screen.getByTestId('btn-apply')).toBeDisabled(); + }); + + it('Cancel and Apply enabled after editing a field', async () => { + render( + + ); + await userEvent.clear(screen.getByTestId('field-name')); + await userEvent.type(screen.getByTestId('field-name'), 'changed'); + expect(screen.getByTestId('btn-cancel')).not.toBeDisabled(); + expect(screen.getByTestId('btn-apply')).not.toBeDisabled(); + }); + + it('Apply disables Cancel and Apply again after success', async () => { + const onApply = vi.fn().mockResolvedValue(undefined); + render( + + ); + await userEvent.clear(screen.getByTestId('field-name')); + await userEvent.type(screen.getByTestId('field-name'), 'changed'); + await userEvent.click(screen.getByTestId('btn-apply')); + await waitFor(() => expect(onApply).toHaveBeenCalledOnce()); + expect(screen.getByTestId('btn-cancel')).toBeDisabled(); + expect(screen.getByTestId('btn-apply')).toBeDisabled(); + }); + + it('Cancel always enabled for new datasource (isNew)', () => { + render( + + ); + expect(screen.getByTestId('btn-cancel')).not.toBeDisabled(); + }); + + it('calls onDirtyChange(true) when a field is edited on existing ds', async () => { + const onDirtyChange = vi.fn(); + render( + + ); + await userEvent.clear(screen.getByTestId('field-name')); + await userEvent.type(screen.getByTestId('field-name'), 'x'); + await waitFor(() => expect(onDirtyChange).toHaveBeenCalledWith(true)); + }); + + it('calls onDirtyChange(false) after Apply resets the baseline', async () => { + const onDirtyChange = vi.fn(); + const onApply = vi.fn().mockResolvedValue(undefined); + render( + + ); + await userEvent.clear(screen.getByTestId('field-name')); + await userEvent.type(screen.getByTestId('field-name'), 'changed'); + await waitFor(() => expect(onDirtyChange).toHaveBeenCalledWith(true)); + await userEvent.click(screen.getByTestId('btn-apply')); + await waitFor(() => expect(onDirtyChange).toHaveBeenCalledWith(false)); + }); +}); diff --git a/frontend/src/components/ConnectionForm.tsx b/frontend/src/components/ConnectionForm.tsx new file mode 100644 index 0000000..d732195 --- /dev/null +++ b/frontend/src/components/ConnectionForm.tsx @@ -0,0 +1,604 @@ +import { Eye, EyeOff, Loader, Wifi } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { getConnectionFormValidity, makeEmptyForm } from '../lib/connectionForm'; +import { T } from '../lib/tokens'; +import type { Datasource } from '../types'; +import { FieldInput, FormRow, SelectInput } from './ConnectionFormFields'; + +export interface TestResult { + success: boolean; + message: string; +} + +export function ConnectionForm({ + initial, + projectId, + onSave, + onCancel, + onApply, + onTest, + onDirtyChange, + onFormChange, +}: { + initial: Partial; + projectId: string; + onSave: (ds: Datasource) => Promise; + onCancel: () => void; + onApply?: (ds: Datasource) => Promise; + onTest: (ds: Partial) => Promise; + onDirtyChange?: (dirty: boolean) => void; + onFormChange?: (ds: Datasource) => void; +}) { + const [form, setForm] = useState>({ + ...makeEmptyForm(), + name: initial.name ?? '', + host: initial.host ?? 'localhost', + port: initial.port ?? 5432, + database: initial.database ?? '', + username: initial.username ?? '', + password: initial.password ?? '', + env: initial.env ?? 'local', + sslMode: initial.sslMode ?? 'require', + }); + const [showPass, setShowPass] = useState(false); + const [activeTab, setActiveTab] = useState(0); + const [testResult, setTestResult] = useState(null); + const [testing, setTesting] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const stableId = useRef(initial.id ?? `${Date.now()}`); + const [cleanValues, setCleanValues] = useState>({ + name: initial.name ?? '', + host: initial.host ?? 'localhost', + port: initial.port ?? 5432, + database: initial.database ?? '', + username: initial.username ?? '', + password: initial.password ?? '', + env: initial.env ?? 'local', + sslMode: initial.sslMode ?? 'require', + }); + + const isDirty = useMemo( + () => + (Object.keys(cleanValues) as Array).some( + (k) => form[k] !== cleanValues[k] + ), + [form, cleanValues] + ); + + useEffect(() => { + onDirtyChange?.(isDirty); + }, [isDirty, onDirtyChange]); + + useEffect(() => { + onFormChange?.({ id: stableId.current, projectId, ...form }); + }, [form, onFormChange, projectId]); + + useEffect( + () => () => { + onDirtyChange?.(false); + }, + [onDirtyChange] + ); + + const set = (k: K, v: (typeof form)[K]) => + setForm((f) => ({ ...f, [k]: v })); + + const derivedUrl = `postgres://${form.username}@${form.host}:${form.port}/${form.database}?sslmode=${form.sslMode}`; + + const buildDs = (): Datasource => ({ + id: stableId.current, + projectId, + ...form, + }); + + const handleTest = async () => { + setTesting(true); + setTestResult(null); + const r = await onTest(buildDs()); + setTestResult(r); + setTesting(false); + }; + + const handleSave = async () => { + setError(null); + setSaving(true); + try { + await onSave(buildDs()); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + }; + + const handleApply = async () => { + setError(null); + setSaving(true); + try { + await onApply?.(buildDs()); + setCleanValues({ ...form }); + onDirtyChange?.(false); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + }; + + const tabs = ['General', 'Options', 'SSL']; + const validity = getConnectionFormValidity(form); + const canSave = !saving && validity.canSave; + const isNew = !initial.id; + const canApplyOrCancel = isNew || isDirty; + + return ( +
+ {/* New / Edit header strip */} +
+ + {isNew ? 'New Data Source' : form.name || 'Untitled'} + +
+ + {/* Tabs */} +
+ {tabs.map((tab, i) => ( +
setActiveTab(i)} + onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && setActiveTab(i)} + style={{ + padding: '10px 14px', + fontSize: 12, + color: i === activeTab ? T.text : T.textSec, + fontWeight: i === activeTab ? 600 : 400, + borderBottom: i === activeTab ? `2px solid ${T.accent}` : '2px solid transparent', + marginBottom: -0.5, + cursor: 'pointer', + }} + > + {tab} +
+ ))} +
+ + {/* General tab — DataGrip row layout */} + {activeTab === 0 && ( +
+ {/* Name + Environment */} + +
+
+ set('name', v)} + placeholder="e.g. postgres@localhost" + data-testid="field-name" + /> +
+ Environment: +
+ set('env', v)} + data-testid="field-env" + options={[ + { value: 'local', label: 'Local' }, + { value: 'dev', label: 'Development' }, + { value: 'stg', label: 'Staging' }, + { value: 'prod', label: 'Production' }, + ]} + /> +
+
+
+ + {/* Driver */} + + {}} + options={[{ value: 'postgresql', label: 'PostgreSQL' }]} + /> + + + {/* Host + Port */} + +
+
+ set('host', v)} + mono + placeholder="localhost" + data-testid="field-host" + /> +
+ Port: +
+ set('port', Number(v))} + mono + type="number" + data-testid="field-port" + /> +
+
+
+ + {/* Authentication */} + + {}} + options={[{ value: 'password', label: 'User & Password' }]} + /> + + + {/* User */} + + set('username', v)} + mono + placeholder="postgres" + data-testid="field-username" + /> + + + {/* Password */} + +
+ set('password', e.target.value)} + placeholder={ + initial.id ? 'stored in macOS Keychain — type to change' : 'enter password' + } + style={{ + background: T.panelAlt, + border: `0.5px solid ${T.border}`, + borderRadius: 4, + padding: '7px 30px 7px 10px', + fontSize: 12, + color: T.text, + fontFamily: T.mono, + outline: 'none', + width: '100%', + boxSizing: 'border-box' as const, + }} + /> + +
+
+ + {/* Database */} + + set('database', v)} + mono + placeholder="postgres" + data-testid="field-database" + /> + + + {/* URL (read-only) */} + +
+
+ {derivedUrl} +
+
+ Overrides settings above +
+
+
+
+ )} + + {/* Options tab */} + {activeTab === 1 && ( +
+ Not yet implemented +
+ )} + + {/* SSL tab */} + {activeTab === 2 && ( +
+ + set('sslMode', v)} + data-testid="field-ssl" + options={[ + { value: 'disable', label: 'Disable' }, + { value: 'require', label: 'Require' }, + { value: 'verify-ca', label: 'Verify CA' }, + { value: 'verify-full', label: 'Verify Full' }, + ]} + /> + +
+ )} + + {/* Test result */} + {testResult && ( +
+
+ {testResult.success ? '✓' : '✕'} +
+ + {testResult.success ? 'Connection succeeded' : 'Connection failed'} + + + {testResult.message} + +
+ )} + + {error && ( +
+ {error} +
+ )} + + {/* Footer */} +
+ + PostgreSQL +
+ + {onApply && ( + + )} + +
+
+ ); +} diff --git a/frontend/src/components/ConnectionFormFields.test.tsx b/frontend/src/components/ConnectionFormFields.test.tsx new file mode 100644 index 0000000..6fd6614 --- /dev/null +++ b/frontend/src/components/ConnectionFormFields.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { FieldInput, SelectInput } from './ConnectionFormFields'; + +describe('FieldInput', () => { + it('renders with value', () => { + render(); + expect(screen.getByDisplayValue('hello')).toBeInTheDocument(); + }); + + it('calls onChange with new value', async () => { + const onChange = vi.fn(); + render(); + await userEvent.type(screen.getByTestId('fi'), 'x'); + expect(onChange).toHaveBeenCalledWith('x'); + }); + + it('is readonly when readOnly=true', () => { + render(); + expect(screen.getByTestId('fi')).toHaveAttribute('readonly'); + }); +}); + +describe('SelectInput', () => { + it('renders all options', () => { + render( + + ); + expect(screen.getByRole('option', { name: 'A' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'B' })).toBeInTheDocument(); + }); + + it('calls onChange when changed', async () => { + const onChange = vi.fn(); + render( + + ); + await userEvent.selectOptions(screen.getByTestId('sel'), 'b'); + expect(onChange).toHaveBeenCalledWith('b'); + }); +}); diff --git a/frontend/src/components/ConnectionFormFields.tsx b/frontend/src/components/ConnectionFormFields.tsx new file mode 100644 index 0000000..92fe3e2 --- /dev/null +++ b/frontend/src/components/ConnectionFormFields.tsx @@ -0,0 +1,159 @@ +import { ChevronDown } from 'lucide-react'; +import { T } from '../lib/tokens'; + +export function FieldLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function FieldInput({ + value, + onChange, + type = 'text', + mono = false, + readOnly = false, + placeholder, + 'data-testid': testId, +}: { + value: string | number; + onChange?: (v: string) => void; + type?: string; + mono?: boolean; + readOnly?: boolean; + placeholder?: string; + 'data-testid'?: string; +}) { + return ( + onChange?.(e.target.value)} + style={{ + background: T.panelAlt, + border: `0.5px solid ${T.border}`, + borderRadius: 4, + padding: '7px 10px', + fontSize: 12, + color: readOnly ? T.textSec : T.text, + fontFamily: mono ? T.mono : T.ui, + outline: 'none', + width: '100%', + boxSizing: 'border-box' as const, + }} + onFocus={(e) => { + if (!readOnly) e.target.style.borderColor = T.accent; + }} + onBlur={(e) => { + e.target.style.borderColor = T.border; + }} + /> + ); +} + +export function SelectInput({ + value, + onChange, + options, + 'data-testid': testId, +}: { + value: string; + onChange: (v: string) => void; + options: { value: string; label: string }[]; + 'data-testid'?: string; +}) { + return ( +
+ +
+ +
+
+ ); +} + +// ── FormRow — DataGrip-style label:value row ───────────────────────────────── +export function FormRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {label}: +
+
{children}
+
+ ); +} + +export function ElephantIcon({ color, size = 16 }: { color: string; size?: number }) { + return ( + + + + ); +} diff --git a/frontend/src/components/ConnectionManager.test.tsx b/frontend/src/components/ConnectionManager.test.tsx index 1dcb6a1..dbce2e0 100644 --- a/frontend/src/components/ConnectionManager.test.tsx +++ b/frontend/src/components/ConnectionManager.test.tsx @@ -2,13 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Datasource, Project } from '../types'; -import { - ConnectionForm, - ConnectionManager, - FieldInput, - makeEmptyForm, - SelectInput, -} from './ConnectionManager'; +import { ConnectionManager } from './ConnectionManager'; vi.mock('../../wailsjs/go/main/App', () => ({ TestDatasource: vi.fn().mockResolvedValue({ Success: true, Message: 'ok' }), @@ -75,356 +69,6 @@ function renderManager( return { onConnect, onSaveAll, onUpdateDs }; } -// ── Helpers ─────────────────────────────────────────────────────────────────── - -describe('makeEmptyForm', () => { - it('returns default values', () => { - const f = makeEmptyForm(); - expect(f.host).toBe('localhost'); - expect(f.port).toBe(5432); - expect(f.env).toBe('local'); - expect(f.sslMode).toBe('require'); - expect(f.name).toBe(''); - }); -}); - -// ── FieldInput ──────────────────────────────────────────────────────────────── - -describe('FieldInput', () => { - it('renders with value', () => { - render(); - expect(screen.getByDisplayValue('hello')).toBeInTheDocument(); - }); - - it('calls onChange with new value', async () => { - const onChange = vi.fn(); - render(); - await userEvent.type(screen.getByTestId('fi'), 'x'); - expect(onChange).toHaveBeenCalledWith('x'); - }); - - it('is readonly when readOnly=true', () => { - render(); - expect(screen.getByTestId('fi')).toHaveAttribute('readonly'); - }); -}); - -// ── SelectInput ─────────────────────────────────────────────────────────────── - -describe('SelectInput', () => { - it('renders all options', () => { - render( - - ); - expect(screen.getByRole('option', { name: 'A' })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: 'B' })).toBeInTheDocument(); - }); - - it('calls onChange when changed', async () => { - const onChange = vi.fn(); - render( - - ); - await userEvent.selectOptions(screen.getByTestId('sel'), 'b'); - expect(onChange).toHaveBeenCalledWith('b'); - }); -}); - -// ── ConnectionForm ───────────────────────────────────────────────────────────── - -describe('ConnectionForm', () => { - const defaultProps = { - initial: {}, - projectId: 'p1', - onSave: vi.fn().mockResolvedValue(undefined), - onCancel: vi.fn(), - onTest: vi.fn().mockResolvedValue({ success: true, message: 'ok' }), - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('renders all General tab fields', () => { - render(); - expect(screen.getByTestId('field-name')).toBeInTheDocument(); - expect(screen.getByTestId('field-host')).toBeInTheDocument(); - expect(screen.getByTestId('field-port')).toBeInTheDocument(); - expect(screen.getByTestId('field-database')).toBeInTheDocument(); - expect(screen.getByTestId('field-username')).toBeInTheDocument(); - expect(screen.getByTestId('field-password')).toBeInTheDocument(); - expect(screen.getByTestId('field-env')).toBeInTheDocument(); - // SSL mode is in the SSL tab, not the General tab - }); - - it('pre-populates from initial props', () => { - render( - - ); - expect(screen.getByTestId('field-name')).toHaveValue('prod-db'); - expect(screen.getByTestId('field-host')).toHaveValue('db.example.com'); - expect(screen.getByTestId('field-database')).toHaveValue('proddb'); - expect(screen.getByTestId('field-env')).toHaveValue('prod'); - // sslMode is stored in form state but field-ssl is on SSL tab - }); - - it('Save button disabled when name empty', () => { - render(); - expect(screen.getByTestId('btn-save')).toBeDisabled(); - }); - - it('Save button enabled when name + host + database filled', async () => { - render(); - await userEvent.type(screen.getByTestId('field-name'), 'myconn'); - await userEvent.type(screen.getByTestId('field-database'), 'mydb'); - expect(screen.getByTestId('btn-save')).not.toBeDisabled(); - }); - - it('Cancel calls onCancel', async () => { - const onCancel = vi.fn(); - render(); - await userEvent.click(screen.getByTestId('btn-cancel')); - expect(onCancel).toHaveBeenCalledOnce(); - }); - - it('Save calls onSave with complete datasource', async () => { - const onSave = vi.fn().mockResolvedValue(undefined); - render( - - ); - await userEvent.click(screen.getByTestId('btn-save')); - await waitFor(() => expect(onSave).toHaveBeenCalledOnce()); - const saved = onSave.mock.calls[0][0] as Datasource; - expect(saved.projectId).toBe('p1'); - expect(saved.name).toBe('myconn'); - expect(saved.database).toBe('mydb'); - }); - - it('shows save error when onSave rejects', async () => { - const onSave = vi.fn().mockRejectedValue(new Error('save failed')); - render( - - ); - await userEvent.click(screen.getByTestId('btn-save')); - await waitFor(() => expect(screen.getByTestId('save-error')).toBeInTheDocument()); - expect(screen.getByTestId('save-error')).toHaveTextContent('save failed'); - }); - - it('Test button calls onTest with form values', async () => { - const onTest = vi.fn().mockResolvedValue({ success: true, message: '12ms' }); - render( - - ); - await userEvent.click(screen.getByTestId('btn-test')); - await waitFor(() => expect(onTest).toHaveBeenCalledOnce()); - const arg = onTest.mock.calls[0][0] as Partial; - expect(arg.host).toBe('myhost'); - expect(arg.database).toBe('mydb'); - expect(arg.sslMode).toBe('require'); - }); - - it('shows success test result', async () => { - const onTest = vi.fn().mockResolvedValue({ success: true, message: '8ms' }); - render( - - ); - await userEvent.click(screen.getByTestId('btn-test')); - await waitFor(() => expect(screen.getByTestId('test-result')).toBeInTheDocument()); - expect(screen.getByTestId('test-result')).toHaveTextContent('Connection succeeded'); - }); - - it('shows failure test result', async () => { - const onTest = vi.fn().mockResolvedValue({ success: false, message: 'refused' }); - render( - - ); - await userEvent.click(screen.getByTestId('btn-test')); - await waitFor(() => expect(screen.getByTestId('test-result')).toBeInTheDocument()); - expect(screen.getByTestId('test-result')).toHaveTextContent('Connection failed'); - }); - - it('toggles password visibility', async () => { - render(); - const pw = screen.getByTestId('field-password'); - expect(pw).toHaveAttribute('type', 'password'); - await userEvent.click(screen.getByTestId('toggle-password')); - expect(pw).toHaveAttribute('type', 'text'); - await userEvent.click(screen.getByTestId('toggle-password')); - expect(pw).toHaveAttribute('type', 'password'); - }); - - it('Test button disabled when host or database empty', () => { - render(); - expect(screen.getByTestId('btn-test')).toBeDisabled(); - }); - - it('shows connection name in name field for existing datasource', () => { - render(); - expect(screen.getByTestId('field-name')).toHaveValue('existing'); - }); - - it('shows New Data Source heading for new datasource', () => { - render(); - expect(screen.getByText('New Data Source')).toBeInTheDocument(); - }); - - it('Options tab shows placeholder; SSL tab shows ssl field', async () => { - render(); - await userEvent.click(screen.getByRole('tab', { name: 'Options' })); - expect(screen.getByText(/Not yet implemented/)).toBeInTheDocument(); - await userEvent.click(screen.getByRole('tab', { name: 'SSL' })); - expect(screen.getByTestId('field-ssl')).toBeInTheDocument(); - }); - - it('URL preview updates as host/port/db change', async () => { - render(); - await userEvent.clear(screen.getByTestId('field-host')); - await userEvent.type(screen.getByTestId('field-host'), 'myserver'); - expect(screen.getByText(/myserver/)).toBeInTheDocument(); - }); - - it('Apply button calls onApply with current form values', async () => { - const onApply = vi.fn().mockResolvedValue(undefined); - render( - - ); - await userEvent.click(screen.getByTestId('btn-apply')); - await waitFor(() => expect(onApply).toHaveBeenCalledOnce()); - const applied = onApply.mock.calls[0][0] as Datasource; - expect(applied.name).toBe('myconn'); - expect(applied.database).toBe('mydb'); - }); - - it('Cancel and Apply disabled initially when editing existing datasource', () => { - render( - - ); - expect(screen.getByTestId('btn-cancel')).toBeDisabled(); - expect(screen.getByTestId('btn-apply')).toBeDisabled(); - }); - - it('Cancel and Apply enabled after editing a field', async () => { - render( - - ); - await userEvent.clear(screen.getByTestId('field-name')); - await userEvent.type(screen.getByTestId('field-name'), 'changed'); - expect(screen.getByTestId('btn-cancel')).not.toBeDisabled(); - expect(screen.getByTestId('btn-apply')).not.toBeDisabled(); - }); - - it('Apply disables Cancel and Apply again after success', async () => { - const onApply = vi.fn().mockResolvedValue(undefined); - render( - - ); - await userEvent.clear(screen.getByTestId('field-name')); - await userEvent.type(screen.getByTestId('field-name'), 'changed'); - await userEvent.click(screen.getByTestId('btn-apply')); - await waitFor(() => expect(onApply).toHaveBeenCalledOnce()); - expect(screen.getByTestId('btn-cancel')).toBeDisabled(); - expect(screen.getByTestId('btn-apply')).toBeDisabled(); - }); - - it('Cancel always enabled for new datasource (isNew)', () => { - render( - - ); - expect(screen.getByTestId('btn-cancel')).not.toBeDisabled(); - }); - - it('calls onDirtyChange(true) when a field is edited on existing ds', async () => { - const onDirtyChange = vi.fn(); - render( - - ); - await userEvent.clear(screen.getByTestId('field-name')); - await userEvent.type(screen.getByTestId('field-name'), 'x'); - await waitFor(() => expect(onDirtyChange).toHaveBeenCalledWith(true)); - }); - - it('calls onDirtyChange(false) after Apply resets the baseline', async () => { - const onDirtyChange = vi.fn(); - const onApply = vi.fn().mockResolvedValue(undefined); - render( - - ); - await userEvent.clear(screen.getByTestId('field-name')); - await userEvent.type(screen.getByTestId('field-name'), 'changed'); - await waitFor(() => expect(onDirtyChange).toHaveBeenCalledWith(true)); - await userEvent.click(screen.getByTestId('btn-apply')); - await waitFor(() => expect(onDirtyChange).toHaveBeenCalledWith(false)); - }); -}); - // ── ConnectionManager ────────────────────────────────────────────────────────── describe('ConnectionManager', () => { diff --git a/frontend/src/components/ConnectionManager.tsx b/frontend/src/components/ConnectionManager.tsx index dff5fc5..3260d55 100644 --- a/frontend/src/components/ConnectionManager.tsx +++ b/frontend/src/components/ConnectionManager.tsx @@ -1,17 +1,15 @@ -import { ChevronDown, Copy, Eye, EyeOff, Loader, Plus, Wifi, X } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { Copy, Plus, X } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; import * as GoApp from '../../wailsjs/go/main/App'; import { ENV_COLORS, T } from '../lib/tokens'; import type { Datasource, Project } from '../types'; +import { ConfirmDialog, UnsavedChangesDialog } from './ConnectionDialogs'; +import { ConnectionForm, type TestResult } from './ConnectionForm'; +import { ElephantIcon } from './ConnectionFormFields'; // ── Types ──────────────────────────────────────────────────────────────────── type FormMode = null | 'add' | 'edit'; -interface TestResult { - success: boolean; - message: string; -} - export interface ConnectionManagerProps { projects: Project[]; datasources: Datasource[]; @@ -27,144 +25,6 @@ export interface ConnectionManagerProps { appBuildDate?: string; } -// ── Helpers ────────────────────────────────────────────────────────────────── -export function makeEmptyForm(): Omit { - return { - name: '', - host: 'localhost', - port: 5432, - database: '', - username: '', - password: '', - env: 'local', - sslMode: 'require', - }; -} - -// ── Sub-components ─────────────────────────────────────────────────────────── -export function FieldLabel({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} - -export function FieldInput({ - value, - onChange, - type = 'text', - mono = false, - readOnly = false, - placeholder, - 'data-testid': testId, -}: { - value: string | number; - onChange?: (v: string) => void; - type?: string; - mono?: boolean; - readOnly?: boolean; - placeholder?: string; - 'data-testid'?: string; -}) { - return ( - onChange?.(e.target.value)} - style={{ - background: T.panelAlt, - border: `0.5px solid ${T.border}`, - borderRadius: 4, - padding: '7px 10px', - fontSize: 12, - color: readOnly ? T.textSec : T.text, - fontFamily: mono ? T.mono : T.ui, - outline: 'none', - width: '100%', - boxSizing: 'border-box' as const, - }} - onFocus={(e) => { - if (!readOnly) e.target.style.borderColor = T.accent; - }} - onBlur={(e) => { - e.target.style.borderColor = T.border; - }} - /> - ); -} - -export function SelectInput({ - value, - onChange, - options, - 'data-testid': testId, -}: { - value: string; - onChange: (v: string) => void; - options: { value: string; label: string }[]; - 'data-testid'?: string; -}) { - return ( -
- -
- -
-
- ); -} - function _SectionLabel({ children }: { children: React.ReactNode }) { return (
@@ -184,816 +44,6 @@ function _SectionLabel({ children }: { children: React.ReactNode }) { ); } -function ElephantIcon({ color, size = 16 }: { color: string; size?: number }) { - return ( - - - - ); -} - -// ── Unsaved changes dialog ─────────────────────────────────────────────────── -function UnsavedChangesDialog({ - onDiscard, - onCancel, - onSave, -}: { - onDiscard: () => void; - onCancel: () => void; - onSave: () => Promise; -}) { - const [saving, setSaving] = useState(false); - const handleSave = async () => { - setSaving(true); - try { - await onSave(); - } finally { - setSaving(false); - } - }; - return ( -
-
-
- You have unsaved changes. What would you like to do? -
-
- - - -
-
-
- ); -} - -// ── Confirm delete dialog ──────────────────────────────────────────────────── -function ConfirmDialog({ - message, - onConfirm, - onCancel, -}: { - message: string; - onConfirm: () => void; - onCancel: () => void; -}) { - return ( -
-
-
- {message} -
-
- - -
-
-
- ); -} - -// ── FormRow — DataGrip-style label:value row ───────────────────────────────── -function FormRow({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
-
- {label}: -
-
{children}
-
- ); -} - -// ── Connection form ────────────────────────────────────────────────────────── -export function ConnectionForm({ - initial, - projectId, - onSave, - onCancel, - onApply, - onTest, - onDirtyChange, - onFormChange, -}: { - initial: Partial; - projectId: string; - onSave: (ds: Datasource) => Promise; - onCancel: () => void; - onApply?: (ds: Datasource) => Promise; - onTest: (ds: Partial) => Promise; - onDirtyChange?: (dirty: boolean) => void; - onFormChange?: (ds: Datasource) => void; -}) { - const [form, setForm] = useState>({ - ...makeEmptyForm(), - name: initial.name ?? '', - host: initial.host ?? 'localhost', - port: initial.port ?? 5432, - database: initial.database ?? '', - username: initial.username ?? '', - password: initial.password ?? '', - env: initial.env ?? 'local', - sslMode: initial.sslMode ?? 'require', - }); - const [showPass, setShowPass] = useState(false); - const [activeTab, setActiveTab] = useState(0); - const [testResult, setTestResult] = useState(null); - const [testing, setTesting] = useState(false); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - const stableId = useRef(initial.id ?? `${Date.now()}`); - const [cleanValues, setCleanValues] = useState>({ - name: initial.name ?? '', - host: initial.host ?? 'localhost', - port: initial.port ?? 5432, - database: initial.database ?? '', - username: initial.username ?? '', - password: initial.password ?? '', - env: initial.env ?? 'local', - sslMode: initial.sslMode ?? 'require', - }); - - const isDirty = useMemo( - () => - (Object.keys(cleanValues) as Array).some( - (k) => form[k] !== cleanValues[k] - ), - [form, cleanValues] - ); - - useEffect(() => { - onDirtyChange?.(isDirty); - }, [isDirty, onDirtyChange]); - - useEffect(() => { - onFormChange?.({ id: stableId.current, projectId, ...form }); - }, [form, onFormChange, projectId]); - - useEffect( - () => () => { - onDirtyChange?.(false); - }, - [onDirtyChange] - ); - - const set = (k: K, v: (typeof form)[K]) => - setForm((f) => ({ ...f, [k]: v })); - - const derivedUrl = `postgres://${form.username}@${form.host}:${form.port}/${form.database}?sslmode=${form.sslMode}`; - - const buildDs = (): Datasource => ({ - id: stableId.current, - projectId, - ...form, - }); - - const handleTest = async () => { - setTesting(true); - setTestResult(null); - const r = await onTest(buildDs()); - setTestResult(r); - setTesting(false); - }; - - const handleSave = async () => { - setError(null); - setSaving(true); - try { - await onSave(buildDs()); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setSaving(false); - } - }; - - const handleApply = async () => { - setError(null); - setSaving(true); - try { - await onApply?.(buildDs()); - setCleanValues({ ...form }); - onDirtyChange?.(false); - } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setSaving(false); - } - }; - - const tabs = ['General', 'Options', 'SSL']; - const canSave = !saving && !!form.name && !!form.host && !!form.database; - const isNew = !initial.id; - const canApplyOrCancel = isNew || isDirty; - - return ( -
- {/* New / Edit header strip */} -
- - {isNew ? 'New Data Source' : form.name || 'Untitled'} - -
- - {/* Tabs */} -
- {tabs.map((tab, i) => ( -
setActiveTab(i)} - onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && setActiveTab(i)} - style={{ - padding: '10px 14px', - fontSize: 12, - color: i === activeTab ? T.text : T.textSec, - fontWeight: i === activeTab ? 600 : 400, - borderBottom: i === activeTab ? `2px solid ${T.accent}` : '2px solid transparent', - marginBottom: -0.5, - cursor: 'pointer', - }} - > - {tab} -
- ))} -
- - {/* General tab — DataGrip row layout */} - {activeTab === 0 && ( -
- {/* Name + Environment */} - -
-
- set('name', v)} - placeholder="e.g. postgres@localhost" - data-testid="field-name" - /> -
- Environment: -
- set('env', v)} - data-testid="field-env" - options={[ - { value: 'local', label: 'Local' }, - { value: 'dev', label: 'Development' }, - { value: 'stg', label: 'Staging' }, - { value: 'prod', label: 'Production' }, - ]} - /> -
-
-
- - {/* Driver */} - - {}} - options={[{ value: 'postgresql', label: 'PostgreSQL' }]} - /> - - - {/* Host + Port */} - -
-
- set('host', v)} - mono - placeholder="localhost" - data-testid="field-host" - /> -
- Port: -
- set('port', Number(v))} - mono - type="number" - data-testid="field-port" - /> -
-
-
- - {/* Authentication */} - - {}} - options={[{ value: 'password', label: 'User & Password' }]} - /> - - - {/* User */} - - set('username', v)} - mono - placeholder="postgres" - data-testid="field-username" - /> - - - {/* Password */} - -
- set('password', e.target.value)} - placeholder={ - initial.id ? 'stored in macOS Keychain — type to change' : 'enter password' - } - style={{ - background: T.panelAlt, - border: `0.5px solid ${T.border}`, - borderRadius: 4, - padding: '7px 30px 7px 10px', - fontSize: 12, - color: T.text, - fontFamily: T.mono, - outline: 'none', - width: '100%', - boxSizing: 'border-box' as const, - }} - /> - -
-
- - {/* Database */} - - set('database', v)} - mono - placeholder="postgres" - data-testid="field-database" - /> - - - {/* URL (read-only) */} - -
-
- {derivedUrl} -
-
- Overrides settings above -
-
-
-
- )} - - {/* Options tab */} - {activeTab === 1 && ( -
- Not yet implemented -
- )} - - {/* SSL tab */} - {activeTab === 2 && ( -
- - set('sslMode', v)} - data-testid="field-ssl" - options={[ - { value: 'disable', label: 'Disable' }, - { value: 'require', label: 'Require' }, - { value: 'verify-ca', label: 'Verify CA' }, - { value: 'verify-full', label: 'Verify Full' }, - ]} - /> - -
- )} - - {/* Test result */} - {testResult && ( -
-
- {testResult.success ? '✓' : '✕'} -
- - {testResult.success ? 'Connection succeeded' : 'Connection failed'} - - - {testResult.message} - -
- )} - - {error && ( -
- {error} -
- )} - - {/* Footer */} -
- - PostgreSQL -
- - {onApply && ( - - )} - -
-
- ); -} - // ── ConnectionManager ──────────────────────────────────────────────────────── export function ConnectionManager({ projects, diff --git a/frontend/src/lib/connectionForm.test.ts b/frontend/src/lib/connectionForm.test.ts new file mode 100644 index 0000000..fad1056 --- /dev/null +++ b/frontend/src/lib/connectionForm.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { getConnectionFormValidity, makeEmptyForm } from './connectionForm'; + +describe('makeEmptyForm', () => { + it('returns default values', () => { + const f = makeEmptyForm(); + expect(f.host).toBe('localhost'); + expect(f.port).toBe(5432); + expect(f.env).toBe('local'); + expect(f.sslMode).toBe('require'); + expect(f.name).toBe(''); + }); +}); + +describe('getConnectionFormValidity', () => { + it('canSave is true only when name, host, and database are all present', () => { + expect(getConnectionFormValidity({ name: 'c', host: 'h', database: 'db' }).canSave).toBe(true); + }); + + it('canSave is false when name is empty', () => { + expect(getConnectionFormValidity({ name: '', host: 'h', database: 'db' }).canSave).toBe(false); + }); + + it('canSave is false when host is empty', () => { + expect(getConnectionFormValidity({ name: 'c', host: '', database: 'db' }).canSave).toBe(false); + }); + + it('canSave is false when database is empty', () => { + expect(getConnectionFormValidity({ name: 'c', host: 'h', database: '' }).canSave).toBe(false); + }); + + it('canTest is true when host and database are present, even without a name', () => { + expect(getConnectionFormValidity({ name: '', host: 'h', database: 'db' }).canTest).toBe(true); + }); + + it('canTest is false when host is empty', () => { + expect(getConnectionFormValidity({ name: 'c', host: '', database: 'db' }).canTest).toBe(false); + }); + + it('canTest is false when database is empty', () => { + expect(getConnectionFormValidity({ name: 'c', host: 'h', database: '' }).canTest).toBe(false); + }); +}); diff --git a/frontend/src/lib/connectionForm.ts b/frontend/src/lib/connectionForm.ts new file mode 100644 index 0000000..4d768f2 --- /dev/null +++ b/frontend/src/lib/connectionForm.ts @@ -0,0 +1,34 @@ +import type { Datasource } from '../types'; + +export function makeEmptyForm(): Omit { + return { + name: '', + host: 'localhost', + port: 5432, + database: '', + username: '', + password: '', + env: 'local', + sslMode: 'require', + }; +} + +export interface ConnectionFormValidity { + /** Required fields for saving are present (name, host, database). */ + canSave: boolean; + /** Required fields for a test connection are present (host, database). */ + canTest: boolean; +} + +// Pure field-presence validation for the connection form. UI state such as +// in-flight saving/testing is combined by the caller, keeping this testable. +export function getConnectionFormValidity( + form: Pick +): ConnectionFormValidity { + const hasHost = !!form.host; + const hasDatabase = !!form.database; + return { + canSave: !!form.name && hasHost && hasDatabase, + canTest: hasHost && hasDatabase, + }; +} From db062d9740631484db01d3424366d7e32bc87bbd Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 4 Jul 2026 17:25:48 +1000 Subject: [PATCH 2/2] refactor: address code-review findings on ConnectionManager decomposition (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split ConnectionForm General tab into ConnectionGeneralTab (604→420 lines) - Extract DatasourceListItem from ConnectionManager (514→453 lines); both now <500 - Dedupe dialog scaffolding via a shared ModalShell; rename ConfirmDialog to DeleteConfirmDialog to end the name collision with the unused Dialog.tsx - Collapse the duplicated form/cleanValues initializer into one initialForm - Remove pre-existing dead code (FieldLabel, _SectionLabel) - Drop the unrelated package.json.md5 churn No behavior change; 456 unit + 91 e2e pass, tsc + biome clean. --- frontend/package.json.md5 | 2 +- frontend/src/components/ConnectionDialogs.tsx | 270 +++++++++--------- frontend/src/components/ConnectionForm.tsx | 214 +------------- .../src/components/ConnectionFormFields.tsx | 17 -- .../src/components/ConnectionGeneralTab.tsx | 204 +++++++++++++ frontend/src/components/ConnectionManager.tsx | 103 ++----- .../src/components/DatasourceListItem.tsx | 67 +++++ frontend/src/lib/connectionForm.ts | 5 +- 8 files changed, 441 insertions(+), 441 deletions(-) create mode 100644 frontend/src/components/ConnectionGeneralTab.tsx create mode 100644 frontend/src/components/DatasourceListItem.tsx diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 665a8c9..3408cae 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -efb05f170350b5f5ae34f295564d7b4b \ No newline at end of file +cf6eded41d035b667c5da328142de08b \ No newline at end of file diff --git a/frontend/src/components/ConnectionDialogs.tsx b/frontend/src/components/ConnectionDialogs.tsx index aebd3e2..948faed 100644 --- a/frontend/src/components/ConnectionDialogs.tsx +++ b/frontend/src/components/ConnectionDialogs.tsx @@ -1,28 +1,13 @@ import { useState } from 'react'; import { T } from '../lib/tokens'; -// ── Unsaved changes dialog ─────────────────────────────────────────────────── -export function UnsavedChangesDialog({ - onDiscard, - onCancel, - onSave, -}: { - onDiscard: () => void; - onCancel: () => void; - onSave: () => Promise; -}) { - const [saving, setSaving] = useState(false); - const handleSave = async () => { - setSaving(true); - try { - await onSave(); - } finally { - setSaving(false); - } - }; +// Shared backdrop + centered panel used by the connection-manager dialogs. +// (Distinct from the portal-based Dialog.tsx primitives, which have different +// z-index/opacity/focus behaviour and are not used here.) +function ModalShell({ testId, children }: { testId: string; children: React.ReactNode }) { return (
-
- You have unsaved changes. What would you like to do? -
-
- - - -
+ {children}
); } +// ── Unsaved changes dialog ─────────────────────────────────────────────────── +export function UnsavedChangesDialog({ + onDiscard, + onCancel, + onSave, +}: { + onDiscard: () => void; + onCancel: () => void; + onSave: () => Promise; +}) { + const [saving, setSaving] = useState(false); + const handleSave = async () => { + setSaving(true); + try { + await onSave(); + } finally { + setSaving(false); + } + }; + return ( + +
+ You have unsaved changes. What would you like to do? +
+
+ + + +
+
+ ); +} + // ── Confirm delete dialog ──────────────────────────────────────────────────── -export function ConfirmDialog({ +export function DeleteConfirmDialog({ message, onConfirm, onCancel, @@ -118,68 +129,45 @@ export function ConfirmDialog({ onCancel: () => void; }) { return ( -
-
-
- {message} -
-
- - -
+ +
+ {message}
-
+
+ + +
+ ); } diff --git a/frontend/src/components/ConnectionForm.tsx b/frontend/src/components/ConnectionForm.tsx index d732195..0830345 100644 --- a/frontend/src/components/ConnectionForm.tsx +++ b/frontend/src/components/ConnectionForm.tsx @@ -1,9 +1,10 @@ -import { Eye, EyeOff, Loader, Wifi } from 'lucide-react'; +import { Loader, Wifi } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { getConnectionFormValidity, makeEmptyForm } from '../lib/connectionForm'; +import { type ConnectionFormState, getConnectionFormValidity } from '../lib/connectionForm'; import { T } from '../lib/tokens'; import type { Datasource } from '../types'; -import { FieldInput, FormRow, SelectInput } from './ConnectionFormFields'; +import { FormRow, SelectInput } from './ConnectionFormFields'; +import { ConnectionGeneralTab } from './ConnectionGeneralTab'; export interface TestResult { success: boolean; @@ -29,8 +30,7 @@ export function ConnectionForm({ onDirtyChange?: (dirty: boolean) => void; onFormChange?: (ds: Datasource) => void; }) { - const [form, setForm] = useState>({ - ...makeEmptyForm(), + const initialForm: ConnectionFormState = { name: initial.name ?? '', host: initial.host ?? 'localhost', port: initial.port ?? 5432, @@ -39,7 +39,8 @@ export function ConnectionForm({ password: initial.password ?? '', env: initial.env ?? 'local', sslMode: initial.sslMode ?? 'require', - }); + }; + const [form, setForm] = useState(initialForm); const [showPass, setShowPass] = useState(false); const [activeTab, setActiveTab] = useState(0); const [testResult, setTestResult] = useState(null); @@ -48,16 +49,7 @@ export function ConnectionForm({ const [error, setError] = useState(null); const stableId = useRef(initial.id ?? `${Date.now()}`); - const [cleanValues, setCleanValues] = useState>({ - name: initial.name ?? '', - host: initial.host ?? 'localhost', - port: initial.port ?? 5432, - database: initial.database ?? '', - username: initial.username ?? '', - password: initial.password ?? '', - env: initial.env ?? 'local', - sslMode: initial.sslMode ?? 'require', - }); + const [cleanValues, setCleanValues] = useState(initialForm); const isDirty = useMemo( () => @@ -85,8 +77,6 @@ export function ConnectionForm({ const set = (k: K, v: (typeof form)[K]) => setForm((f) => ({ ...f, [k]: v })); - const derivedUrl = `postgres://${form.username}@${form.host}:${form.port}/${form.database}?sslmode=${form.sslMode}`; - const buildDs = (): Datasource => ({ id: stableId.current, projectId, @@ -206,187 +196,13 @@ export function ConnectionForm({ {/* General tab — DataGrip row layout */} {activeTab === 0 && ( -
- {/* Name + Environment */} - -
-
- set('name', v)} - placeholder="e.g. postgres@localhost" - data-testid="field-name" - /> -
- Environment: -
- set('env', v)} - data-testid="field-env" - options={[ - { value: 'local', label: 'Local' }, - { value: 'dev', label: 'Development' }, - { value: 'stg', label: 'Staging' }, - { value: 'prod', label: 'Production' }, - ]} - /> -
-
-
- - {/* Driver */} - - {}} - options={[{ value: 'postgresql', label: 'PostgreSQL' }]} - /> - - - {/* Host + Port */} - -
-
- set('host', v)} - mono - placeholder="localhost" - data-testid="field-host" - /> -
- Port: -
- set('port', Number(v))} - mono - type="number" - data-testid="field-port" - /> -
-
-
- - {/* Authentication */} - - {}} - options={[{ value: 'password', label: 'User & Password' }]} - /> - - - {/* User */} - - set('username', v)} - mono - placeholder="postgres" - data-testid="field-username" - /> - - - {/* Password */} - -
- set('password', e.target.value)} - placeholder={ - initial.id ? 'stored in macOS Keychain — type to change' : 'enter password' - } - style={{ - background: T.panelAlt, - border: `0.5px solid ${T.border}`, - borderRadius: 4, - padding: '7px 30px 7px 10px', - fontSize: 12, - color: T.text, - fontFamily: T.mono, - outline: 'none', - width: '100%', - boxSizing: 'border-box' as const, - }} - /> - -
-
- - {/* Database */} - - set('database', v)} - mono - placeholder="postgres" - data-testid="field-database" - /> - - - {/* URL (read-only) */} - -
-
- {derivedUrl} -
-
- Overrides settings above -
-
-
-
+ setShowPass((s) => !s)} + isEdit={!!initial.id} + /> )} {/* Options tab */} diff --git a/frontend/src/components/ConnectionFormFields.tsx b/frontend/src/components/ConnectionFormFields.tsx index 92fe3e2..5dd7e92 100644 --- a/frontend/src/components/ConnectionFormFields.tsx +++ b/frontend/src/components/ConnectionFormFields.tsx @@ -1,23 +1,6 @@ import { ChevronDown } from 'lucide-react'; import { T } from '../lib/tokens'; -export function FieldLabel({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} - export function FieldInput({ value, onChange, diff --git a/frontend/src/components/ConnectionGeneralTab.tsx b/frontend/src/components/ConnectionGeneralTab.tsx new file mode 100644 index 0000000..38d42e9 --- /dev/null +++ b/frontend/src/components/ConnectionGeneralTab.tsx @@ -0,0 +1,204 @@ +import { Eye, EyeOff } from 'lucide-react'; +import type { ConnectionFormState } from '../lib/connectionForm'; +import { T } from '../lib/tokens'; +import { FieldInput, FormRow, SelectInput } from './ConnectionFormFields'; + +interface ConnectionGeneralTabProps { + form: ConnectionFormState; + set: (key: K, value: ConnectionFormState[K]) => void; + showPass: boolean; + onToggleShowPass: () => void; + isEdit: boolean; +} + +export function ConnectionGeneralTab({ + form, + set, + showPass, + onToggleShowPass, + isEdit, +}: ConnectionGeneralTabProps) { + const derivedUrl = `postgres://${form.username}@${form.host}:${form.port}/${form.database}?sslmode=${form.sslMode}`; + + return ( +
+ {/* Name + Environment */} + +
+
+ set('name', v)} + placeholder="e.g. postgres@localhost" + data-testid="field-name" + /> +
+ Environment: +
+ set('env', v)} + data-testid="field-env" + options={[ + { value: 'local', label: 'Local' }, + { value: 'dev', label: 'Development' }, + { value: 'stg', label: 'Staging' }, + { value: 'prod', label: 'Production' }, + ]} + /> +
+
+
+ + {/* Driver */} + + {}} + options={[{ value: 'postgresql', label: 'PostgreSQL' }]} + /> + + + {/* Host + Port */} + +
+
+ set('host', v)} + mono + placeholder="localhost" + data-testid="field-host" + /> +
+ Port: +
+ set('port', Number(v))} + mono + type="number" + data-testid="field-port" + /> +
+
+
+ + {/* Authentication */} + + {}} + options={[{ value: 'password', label: 'User & Password' }]} + /> + + + {/* User */} + + set('username', v)} + mono + placeholder="postgres" + data-testid="field-username" + /> + + + {/* Password */} + +
+ set('password', e.target.value)} + placeholder={isEdit ? 'stored in macOS Keychain — type to change' : 'enter password'} + style={{ + background: T.panelAlt, + border: `0.5px solid ${T.border}`, + borderRadius: 4, + padding: '7px 30px 7px 10px', + fontSize: 12, + color: T.text, + fontFamily: T.mono, + outline: 'none', + width: '100%', + boxSizing: 'border-box' as const, + }} + /> + +
+
+ + {/* Database */} + + set('database', v)} + mono + placeholder="postgres" + data-testid="field-database" + /> + + + {/* URL (read-only) */} + +
+
+ {derivedUrl} +
+
+ Overrides settings above +
+
+
+
+ ); +} diff --git a/frontend/src/components/ConnectionManager.tsx b/frontend/src/components/ConnectionManager.tsx index 3260d55..d0a80b0 100644 --- a/frontend/src/components/ConnectionManager.tsx +++ b/frontend/src/components/ConnectionManager.tsx @@ -1,11 +1,12 @@ import { Copy, Plus, X } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import * as GoApp from '../../wailsjs/go/main/App'; -import { ENV_COLORS, T } from '../lib/tokens'; +import { T } from '../lib/tokens'; import type { Datasource, Project } from '../types'; -import { ConfirmDialog, UnsavedChangesDialog } from './ConnectionDialogs'; +import { DeleteConfirmDialog, UnsavedChangesDialog } from './ConnectionDialogs'; import { ConnectionForm, type TestResult } from './ConnectionForm'; import { ElephantIcon } from './ConnectionFormFields'; +import { DatasourceListItem } from './DatasourceListItem'; // ── Types ──────────────────────────────────────────────────────────────────── type FormMode = null | 'add' | 'edit'; @@ -25,25 +26,6 @@ export interface ConnectionManagerProps { appBuildDate?: string; } -function _SectionLabel({ children }: { children: React.ReactNode }) { - return ( -
-
- {children} -
-
-
- ); -} - // ── ConnectionManager ──────────────────────────────────────────────────────── export function ConnectionManager({ projects, @@ -297,66 +279,23 @@ export function ConnectionManager({
{[...datasources] .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) - .map((ds) => { - const envColor = ENV_COLORS[ds.env] ?? T.textSec; - const selected = ds.id === selectedDsId && formMode !== 'add'; - return ( -
{ - if (formIsDirty) { - setPendingSwitch({ dsId: ds.id, toMode: 'edit' }); - } else { - setSelectedDsId(ds.id); - setFormMode('edit'); - } - }} - onDoubleClick={() => handleDoubleClickConnect(ds.id)} - style={{ - display: 'flex', - alignItems: 'center', - gap: 8, - padding: '7px 10px', - background: selected ? T.selected : 'transparent', - borderLeft: `2px solid ${selected ? T.selectedBorder : 'transparent'}`, - borderRadius: 4, - marginBottom: 1, - cursor: 'pointer', - userSelect: 'none' as const, - }} - > - -
-
- {ds.name} -
-
- {ds.host} -
-
-
-
- ); - })} + .map((ds) => ( + { + if (formIsDirty) { + setPendingSwitch({ dsId: ds.id, toMode: 'edit' }); + } else { + setSelectedDsId(ds.id); + setFormMode('edit'); + } + }} + onDoubleClick={() => handleDoubleClickConnect(ds.id)} + /> + ))} {datasources.length === 0 && (
setConfirmDelete(null)} diff --git a/frontend/src/components/DatasourceListItem.tsx b/frontend/src/components/DatasourceListItem.tsx new file mode 100644 index 0000000..3d58abe --- /dev/null +++ b/frontend/src/components/DatasourceListItem.tsx @@ -0,0 +1,67 @@ +import { ENV_COLORS, T } from '../lib/tokens'; +import type { Datasource } from '../types'; +import { ElephantIcon } from './ConnectionFormFields'; + +interface DatasourceListItemProps { + ds: Datasource; + selected: boolean; + isActive: boolean; + onSelect: () => void; + onDoubleClick: () => void; +} + +export function DatasourceListItem({ + ds, + selected, + isActive, + onSelect, + onDoubleClick, +}: DatasourceListItemProps) { + const envColor = ENV_COLORS[ds.env] ?? T.textSec; + return ( +
+ +
+
+ {ds.name} +
+
{ds.host}
+
+
+
+ ); +} diff --git a/frontend/src/lib/connectionForm.ts b/frontend/src/lib/connectionForm.ts index 4d768f2..a7c2d99 100644 --- a/frontend/src/lib/connectionForm.ts +++ b/frontend/src/lib/connectionForm.ts @@ -1,6 +1,9 @@ import type { Datasource } from '../types'; -export function makeEmptyForm(): Omit { +// The editable subset of a Datasource held in the connection form's local state. +export type ConnectionFormState = Omit; + +export function makeEmptyForm(): ConnectionFormState { return { name: '', host: 'localhost',