From 20ae1c70bdeb54b6e44e97a3b29bf8a3a6991311 Mon Sep 17 00:00:00 2001 From: busya Date: Thu, 11 Jun 2026 19:12:54 +0300 Subject: [PATCH] feat(ui): add ConnectionsTab and InsertMethodSelector for direct DB enhancement - ConnectionsTab.svelte: Settings tab for DB connection CRUD with list, add/edit form, test button, delete with dependency check - InsertMethodSelector.svelte: radio group for insert method choice (SQL Lab / Direct DB) with filtered connection dropdown - InsertMethodSelector.test.ts: component tests for all UX states - settings/+page.svelte, settings-utils.ts: register Connections tab --- .../translate/InsertMethodSelector.svelte | 186 +++++++++ .../__tests__/InsertMethodSelector.test.ts | 144 +++++++ frontend/src/routes/settings/+page.svelte | 16 + .../src/routes/settings/ConnectionsTab.svelte | 385 ++++++++++++++++++ .../src/routes/settings/settings-utils.ts | 1 + 5 files changed, 732 insertions(+) create mode 100644 frontend/src/lib/components/translate/InsertMethodSelector.svelte create mode 100644 frontend/src/lib/components/translate/__tests__/InsertMethodSelector.test.ts create mode 100644 frontend/src/routes/settings/ConnectionsTab.svelte diff --git a/frontend/src/lib/components/translate/InsertMethodSelector.svelte b/frontend/src/lib/components/translate/InsertMethodSelector.svelte new file mode 100644 index 00000000..92234b3c --- /dev/null +++ b/frontend/src/lib/components/translate/InsertMethodSelector.svelte @@ -0,0 +1,186 @@ + + + + + + + + + + + + +
+ + + + + + + {#if insertMethod === 'sqllab'} +
+ + + {#if databasesLoading} +

{_t.translate?.config?.loading_databases || 'Loading databases...'}

+ {:else if !environmentId} +

{_t.translate?.config?.select_environment || 'Select environment first'}

+ {:else} +

{_t.translate?.config?.target_database_hint || ''}

+ {/if} +
+ {/if} + + + + + + {#if insertMethod === "direct_db"} +
+ {#if isLoading} +
+ {:else if connections.length === 0} +
+ {_t.translate?.config?.insert_method_no_conns || 'No database connections configured.'} + {_t.translate?.config?.insert_method_go_settings || 'Go to Settings → Connections'} +
+ {:else} + + + + {#if connectionId} + + {/if} + + {#if testResult} +

{testResult}

+ {/if} + {/if} +
+ {/if} +
+ \ No newline at end of file diff --git a/frontend/src/lib/components/translate/__tests__/InsertMethodSelector.test.ts b/frontend/src/lib/components/translate/__tests__/InsertMethodSelector.test.ts new file mode 100644 index 00000000..8d8ce671 --- /dev/null +++ b/frontend/src/lib/components/translate/__tests__/InsertMethodSelector.test.ts @@ -0,0 +1,144 @@ +// #region Test.Translate.InsertMethodSelector [C:2] [TYPE Module] [SEMANTICS test, translate, insert-method, ui] +// @BRIEF Vitest tests for InsertMethodSelector component states and transitions. +// @RELATION BINDS_TO -> [Translate.InsertMethodSelector] +// @TEST_INVARIANT: default state -> sqllab_selected +// @TEST_INVARIANT: direct_db with connections -> dropdown visible +// @TEST_INVARIANT: direct_db no connections -> Settings link visible +// @TEST_EDGE: empty connections list -> "Go to Settings" link shown +// @TEST_EDGE: dialect mismatch -> connections filtered out +// @TEST_INVARIANT: test_connection success/failure -> result displayed + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import InsertMethodSelector from '$lib/components/translate/InsertMethodSelector.svelte'; +import { api } from '$lib/api'; + +vi.mock('$lib/api', () => ({ + api: { + fetchConnections: vi.fn(), + testConnection: vi.fn(), + }, +})); + +vi.mock('$lib/i18n/index.svelte.js', () => ({ + getT: () => ({ + translate: { config: {} }, + settings: {}, + }), +})); + +const mockConnections = [ + { id: 'c1', name: 'Products DB', dialect: 'postgresql', host: 'db.prod', port: 5432 }, + { id: 'c2', name: 'Reports DB', dialect: 'postgresql', host: 'db.rpt', port: 5432 }, + { id: 'c3', name: 'ClickHouse', dialect: 'clickhouse', host: 'ch.anal', port: 9000 }, +]; + +describe('InsertMethodSelector', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // #region default_state [C:2] [TYPE Function] + // @BRIEF Default state shows sqllab selected + // @TEST_CONTRACT: insertMethod="sqllab" -> SQL Lab radio checked + it('defaults to sqllab insert method', () => { + const { container } = render(InsertMethodSelector, { + props: { insertMethod: 'sqllab', connectionId: null, jobDialect: 'postgresql' }, + }); + + const radios = container.querySelectorAll('input[type="radio"]'); + expect(radios[0].checked).toBe(true); + expect(radios[1].checked).toBe(false); + }); + // #endregion default_state + + // #region switch_to_direct_db [C:2] [TYPE Function] + // @BRIEF Switching to direct_db with connections shows dropdown + // @TEST_INVARIANT: click direct_db radio -> connections loaded -> dropdown visible + it('shows connection dropdown when switching to direct_db with connections', async () => { + vi.mocked(api.fetchConnections).mockResolvedValue(mockConnections); + + const { container } = render(InsertMethodSelector, { + props: { insertMethod: 'sqllab', connectionId: null, jobDialect: 'postgresql' }, + }); + + const radios = container.querySelectorAll('input[type="radio"]'); + fireEvent.click(radios[1]); + + await waitFor(() => { + expect(api.fetchConnections).toHaveBeenCalled(); + const select = container.querySelector('select'); + expect(select).toBeTruthy(); + }); + }); + // #endregion switch_to_direct_db + + // #region empty_connections [C:2] [TYPE Function] + // @BRIEF Empty connections shows Settings link + // @TEST_EDGE: no connections -> "Go to Settings" link rendered + it('shows settings link when no connections exist', async () => { + vi.mocked(api.fetchConnections).mockResolvedValue([]); + + const { container } = render(InsertMethodSelector, { + props: { insertMethod: 'sqllab', connectionId: null, jobDialect: 'postgresql' }, + }); + + const radios = container.querySelectorAll('input[type="radio"]'); + fireEvent.click(radios[1]); + + await waitFor(() => { + expect(container.textContent).toContain('Go to Settings'); + }); + }); + // #endregion empty_connections + + // #region dialect_filtering [C:2] [TYPE Function] + // @BRIEF Connections filtered by dialect matching jobDialect + // @TEST_EDGE: clickhouse dialect -> only clickhouse connections shown + it('filters connections by dialect', async () => { + vi.mocked(api.fetchConnections).mockResolvedValue(mockConnections); + + const { container } = render(InsertMethodSelector, { + props: { insertMethod: 'sqllab', connectionId: null, jobDialect: 'clickhouse' }, + }); + + const radios = container.querySelectorAll('input[type="radio"]'); + fireEvent.click(radios[1]); + + await waitFor(() => { + const select = container.querySelector('select'); + const options = select?.querySelectorAll('option'); + const optionTexts = Array.from(options || []).map(o => o.textContent); + // Should only have clickhouse connections. PostgreSQL ones filtered out + expect(optionTexts.some(t => t?.includes('ClickHouse'))).toBe(true); + // Default option "Select connection" counts too + expect(optionTexts.some(t => t?.includes('Products DB'))).toBe(false); + }); + }); + // #endregion dialect_filtering + + // #region test_connection_result [C:2] [TYPE Function] + // @BRIEF Connection test success shows result + // @TEST_EDGE: test_connection success -> green success message + it('calls testConnection API when Test button clicked', async () => { + vi.mocked(api.fetchConnections).mockResolvedValue(mockConnections); + vi.mocked(api.testConnection).mockResolvedValue({ success: true, latency_ms: 12 }); + + render(InsertMethodSelector, { + props: { insertMethod: 'direct_db', connectionId: 'c1', jobDialect: 'postgresql' }, + }); + + // The button text is the i18n fallback "Test Connection" + await waitFor(() => { + expect(screen.getByText('Test Connection')).toBeTruthy(); + }); + + await fireEvent.click(screen.getByText('Test Connection')); + + await waitFor(() => { + expect(api.testConnection).toHaveBeenCalledWith('c1'); + }); + }); + // #endregion test_connection_result +}); +// #endregion Test.Translate.InsertMethodSelector diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 0d49f6e7..67deb22e 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -44,6 +44,7 @@ import GitSettingsPage from "./git/+page.svelte"; import AutomationSettingsPage from "./automation/+page.svelte"; import LanguagesSettings from "./LanguagesSettings.svelte"; + import ConnectionsTab from "./ConnectionsTab.svelte"; let activeTab = $state("environments"); let settings = $state(null); @@ -159,6 +160,19 @@ > {$t.settings?.environments} + + + + {#if screenState === "loading"} +
+ {#each [1, 2, 3] as _} +
+ {/each} +
+ + {:else if screenState === "empty" && !showForm} +
+

{_t.settings?.connections_empty || 'No database connections configured.'}

+

{_t.settings?.connections_empty_hint || 'Add a connection to enable direct database inserts for translation jobs.'}

+ +
+ + {:else if screenState === "populated" && !showForm} +
+ + + + + + + + + + + + + {#each connections as conn (conn.id)} + + + + + + + + + {/each} + +
{_t.settings?.connections_name || 'Name'}{_t.settings?.connections_host || 'Host:Port'}{_t.settings?.connections_database || 'Database'}{_t.settings?.connections_dialect || 'Dialect'}{_t.settings?.connections_used_by || 'Used By'}{_t.settings?.connections_actions || 'Actions'}
{conn.name}{conn.host}:{conn.port}{conn.database} + + {conn.dialect} + + {conn.used_by ?? 0} {_t.settings?.connections_used_by?.toLowerCase() || 'jobs'} + + + +
+
+ {/if} + + {#if screenState === "delete_blocked"} +
+

{_t.settings?.connections_delete_blocked_title || 'Cannot delete connection'}

+

{_t.settings?.connections_delete_blocked_hint?.replace('{count}', blockDeleteJobs.length) || `This connection is used by ${blockDeleteJobs.length} active job(s):`}

+ {#if blockDeleteJobs.length > 0} + + {/if} +

{_t.settings?.connections_delete_blocked_help || 'Detach or reassign these jobs before deleting.'}

+ +
+ {/if} + + {#if showForm} +
+

{editingId ? (_t.settings?.connections_edit_title || 'Edit Connection') : (_t.settings?.connections_add_title || 'Add Connection')}

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + {#if testResult} +
+ {#if testResult.success} + ✅ {_t.settings?.connections_test_success?.replace('{latency}', testResult.latency_ms) || `Connected — ${testResult.latency_ms}ms`} · {testResult.db_version} + {:else} + ❌ {testResult.error} + {/if} +
+ {/if} + +
+ + + +
+
+ {/if} + + diff --git a/frontend/src/routes/settings/settings-utils.ts b/frontend/src/routes/settings/settings-utils.ts index 83850fa4..1c19ec28 100644 --- a/frontend/src/routes/settings/settings-utils.ts +++ b/frontend/src/routes/settings/settings-utils.ts @@ -10,6 +10,7 @@ export const SETTINGS_TABS = [ "environments", + "connections", "logging", "git", "llm",