diff --git a/frontend/src/components/ConnectionDialogs.tsx b/frontend/src/components/ConnectionDialogs.tsx new file mode 100644 index 0000000..948faed --- /dev/null +++ b/frontend/src/components/ConnectionDialogs.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react'; +import { T } from '../lib/tokens'; + +// 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 ( +
+
+ {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 DeleteConfirmDialog({ + 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..0830345 --- /dev/null +++ b/frontend/src/components/ConnectionForm.tsx @@ -0,0 +1,420 @@ +import { Loader, Wifi } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { type ConnectionFormState, getConnectionFormValidity } from '../lib/connectionForm'; +import { T } from '../lib/tokens'; +import type { Datasource } from '../types'; +import { FormRow, SelectInput } from './ConnectionFormFields'; +import { ConnectionGeneralTab } from './ConnectionGeneralTab'; + +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 initialForm: ConnectionFormState = { + 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 [form, setForm] = useState(initialForm); + 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(initialForm); + + 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 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 && ( + setShowPass((s) => !s)} + isEdit={!!initial.id} + /> + )} + + {/* 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..5dd7e92 --- /dev/null +++ b/frontend/src/components/ConnectionFormFields.tsx @@ -0,0 +1,142 @@ +import { ChevronDown } from 'lucide-react'; +import { T } from '../lib/tokens'; + +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/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.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..d0a80b0 100644 --- a/frontend/src/components/ConnectionManager.tsx +++ b/frontend/src/components/ConnectionManager.tsx @@ -1,17 +1,16 @@ -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 { T } from '../lib/tokens'; import type { Datasource, Project } from '../types'; +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'; -interface TestResult { - success: boolean; - message: string; -} - export interface ConnectionManagerProps { projects: Project[]; datasources: Datasource[]; @@ -27,973 +26,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 ( -
-
- {children} -
-
-
- ); -} - -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, @@ -1247,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.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..a7c2d99 --- /dev/null +++ b/frontend/src/lib/connectionForm.ts @@ -0,0 +1,37 @@ +import type { Datasource } from '../types'; + +// 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', + 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, + }; +}