From dd115b4c715926b93e2ce36bf519a036e7c72df4 Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Mon, 5 Oct 2026 10:37:53 +0200 Subject: [PATCH 1/4] feat: use superform validation for trino connection settings --- e2e/trino/connection-form.spec.ts | 14 +++ messages/de.json | 5 + messages/en.json | 5 + src/routes/(app)/trino/+page.server.ts | 5 +- src/routes/(app)/trino/+page.svelte | 127 ++++++++++--------------- src/routes/(app)/trino/validation.ts | 11 ++- 6 files changed, 85 insertions(+), 82 deletions(-) diff --git a/e2e/trino/connection-form.spec.ts b/e2e/trino/connection-form.spec.ts index 4e1f2def..29e43496 100644 --- a/e2e/trino/connection-form.spec.ts +++ b/e2e/trino/connection-form.spec.ts @@ -143,6 +143,20 @@ test.describe('Connection form (manual mode)', () => { await expect(page.locator('.text-error')).toBeVisible(); }); + test('invalid URL shows a translated validation message', async ({ page }) => { + await page.goto('/trino'); + await waitForHydration(page); + + await page.getByLabel('Connection').check(); + + await page.getByLabel('URL').fill('not a url'); + await page.getByRole('button', { name: 'Save' }).click(); + + await expect( + page.getByText('Enter a valid URL, e.g. https://trino.example.com:8443.') + ).toBeVisible(); + }); + test('basic auth requires username and password', async ({ page }) => { await page.goto('/trino'); await waitForHydration(page); diff --git a/messages/de.json b/messages/de.json index 6173dea8..69e34fc6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -68,6 +68,11 @@ "trino_auth_password": "Passwort", "trino_save_connection": "Speichern", "trino_connection_saved": "Verbindung gespeichert.", + "trino_connection_url_invalid": "Bitte eine gültige URL eingeben, z. B. https://trino.example.com:8443.", + "trino_auth_username_required": "Für Basic-Authentifizierung ist ein Benutzername erforderlich", + "trino_auth_password_required": "Für Basic-Authentifizierung ist ein Passwort erforderlich", + "trino_connection_test_timeout": "Zeitüberschreitung beim Verbindungstest", + "trino_connection_test_failed": "Verbindung zu Trino fehlgeschlagen – bitte URL und Zugangsdaten prüfen.", "trino_cancel_query": "Abbrechen", "trino_state_submitting": "Wird eingereicht", "trino_state_queued": "Warteschlange", diff --git a/messages/en.json b/messages/en.json index b9216ce0..18b18791 100644 --- a/messages/en.json +++ b/messages/en.json @@ -68,6 +68,11 @@ "trino_auth_password": "Password", "trino_save_connection": "Save", "trino_connection_saved": "Connection saved.", + "trino_connection_url_invalid": "Enter a valid URL, e.g. https://trino.example.com:8443.", + "trino_auth_username_required": "Username is required for basic authentication", + "trino_auth_password_required": "Password is required for basic authentication", + "trino_connection_test_timeout": "Connection test timed out", + "trino_connection_test_failed": "Could not connect to Trino — check the URL and credentials.", "trino_cancel_query": "Cancel", "trino_state_submitting": "Submitting", "trino_state_queued": "Queued", diff --git a/src/routes/(app)/trino/+page.server.ts b/src/routes/(app)/trino/+page.server.ts index 1d7c1252..18a0265b 100644 --- a/src/routes/(app)/trino/+page.server.ts +++ b/src/routes/(app)/trino/+page.server.ts @@ -16,6 +16,7 @@ import { } from '$lib/server/trino/user-clients.js'; import { ConnectionSchema, type ConnectionMessage } from './validation.js'; import type { Actions, PageServerLoad } from './$types'; +import * as m from '$lib/paraglide/messages.js'; export const load: PageServerLoad = async ({ locals }) => { locals.logger.debug('loading Trino page'); @@ -64,8 +65,8 @@ export const actions: Actions = { const name = (err as { name?: string })?.name; const reason = name === 'TimeoutError' || name === 'AbortError' - ? 'Connection test timed out' - : 'Could not connect to Trino — check the URL and credentials.'; + ? m.trino_connection_test_timeout() + : m.trino_connection_test_failed(); log.info({ err, trino_url: connectionUrl }, 'connection test failed'); return message(form, { type: 'error', message: reason } satisfies ConnectionMessage, { status: 400 diff --git a/src/routes/(app)/trino/+page.svelte b/src/routes/(app)/trino/+page.svelte index 4241f9fb..21e4c181 100644 --- a/src/routes/(app)/trino/+page.svelte +++ b/src/routes/(app)/trino/+page.svelte @@ -9,6 +9,7 @@ import { createResizablePanel } from '$lib/components/storage/sidebar/resizable-panel.svelte.js'; import Modal from '$lib/components/Modal.svelte'; import { superForm } from 'sveltekit-superforms'; + import { zod4Client } from 'sveltekit-superforms/adapters'; import { ConnectionSchema, type ConnectionMessage } from './validation.js'; import TabBar from '$lib/components/TabBar.svelte'; import { tabStore, MAX_SQL_LENGTH } from '$lib/stores/tab-store.svelte.js'; @@ -35,11 +36,6 @@ } } - // Connection config - local state persisted to localStorage. - let connectionUrl = $state(''); - let authType = $state<'none' | 'basic'>('none'); - let authUsername = $state(''); - let authPassword = $state(''); let connectionOpen = $state(false); // Drag-to-resize state for the desktop catalog browser panel (width persisted). @@ -92,11 +88,35 @@ })) ); + // Connection form (SuperForms). Fields other than the password are persisted to localStorage. + const { + form: connectionForm, + enhance: connectionEnhance, + errors: connectionErrors, + message: connectionMessage, + submitting: connectionSubmitting + } = superForm( + untrack(() => data.connectionForm), + { + validators: zod4Client(ConnectionSchema), + // Keep the entered connection after a successful save instead of resetting to the defaults. + resetForm: false, + onUpdated({ form }) { + const msg = form.message as ConnectionMessage | undefined; + if (msg?.type === 'success') { + catalogVersion++; + } else if (msg?.type === 'error') { + connectionOpen = true; + } + } + } + ); + onMount(() => { if (!data.trinoConfigured) { - connectionUrl = getStoredValue('trino_url', ''); - authType = getStoredValue('trino_auth_type', 'none') as 'none' | 'basic'; - authUsername = getStoredValue('trino_username', ''); + $connectionForm.connectionUrl = getStoredValue('trino_url', ''); + $connectionForm.authType = getStoredValue('trino_auth_type', 'none') as 'none' | 'basic'; + $connectionForm.authUsername = getStoredValue('trino_username', ''); } defaultCatalog = getStoredValue('trino_default_catalog', ''); defaultSchema = getStoredValue('trino_default_schema', ''); @@ -106,12 +126,12 @@ if (data.trinoConfigured || data.userClientExists) { // Server already has a connection (env-based or per-user); load catalogues. catalogVersion++; - } else if (connectionUrl && authType === 'none') { + } else if ($connectionForm.connectionUrl && $connectionForm.authType === 'none') { // Re-establish server-side connection from localStorage on page reload. // Only possible for unauthenticated connections since the password is not persisted. const body = new FormData(); - body.set('connectionUrl', connectionUrl); - body.set('authType', authType); + body.set('connectionUrl', $connectionForm.connectionUrl); + body.set('authType', 'none'); body.set('authUsername', ''); body.set('authPassword', ''); fetch('?/save', { @@ -125,11 +145,11 @@ .catch(() => { // Server-side connection could not be re-established from localStorage. // Clear stale state so the user is prompted to re-enter. - connectionUrl = ''; - authType = 'none'; - authUsername = ''; + $connectionForm.connectionUrl = ''; + $connectionForm.authType = 'none'; + $connectionForm.authUsername = ''; }); - } else if (connectionUrl && authType === 'basic') { + } else if ($connectionForm.connectionUrl && $connectionForm.authType === 'basic') { // Password is not persisted; prompt the user to re-enter credentials. connectionOpen = true; } @@ -141,48 +161,6 @@ getOrCreateQueryRunner(tabStore.activeTabId).fetchResults(); }); - // Connection form (SuperForms). - const { - enhance: connectionEnhance, - errors: connectionErrors, - message: connectionMessage, - submitting: connectionSubmitting - } = superForm( - untrack(() => data.connectionForm), - { - onSubmit({ cancel }) { - const result = ConnectionSchema.safeParse({ - connectionUrl, - authType, - authUsername, - authPassword - }); - if (!result.success) { - const errors: Record = {}; - for (const issue of result.error.issues) { - const key = String(issue.path[0]); - (errors[key] ??= []).push(issue.message); - } - $connectionErrors = { - connectionUrl: errors.connectionUrl, - authType: errors.authType, - authUsername: errors.authUsername, - authPassword: errors.authPassword - }; - cancel(); - } - }, - onUpdated({ form }) { - const msg = form.message as ConnectionMessage | undefined; - if (msg?.type === 'success') { - catalogVersion++; - } else if (msg?.type === 'error') { - connectionOpen = true; - } - } - } - ); - const isActive = $derived(runner.state !== 'IDLE' && !isTerminal(runner.state)); const skippedStatements = $derived.by(() => { if (isActive || !runner.scriptProgress) return 0; @@ -193,9 +171,9 @@ // Password is intentionally excluded -- credentials should not be stored client-side. $effect(() => { if (!hydrated || data.trinoConfigured) return; - localStorage.setItem('trino_url', connectionUrl); - localStorage.setItem('trino_auth_type', authType); - localStorage.setItem('trino_username', authUsername); + localStorage.setItem('trino_url', $connectionForm.connectionUrl); + localStorage.setItem('trino_auth_type', $connectionForm.authType); + localStorage.setItem('trino_username', $connectionForm.authUsername); }); // Persist settings. @@ -238,8 +216,9 @@ }); const connectionSummary = $derived.by(() => { - const host = connectionUrl ? connectionUrl.replace(/^https?:\/\//, '').replace(/\/$/, '') : '-'; - const auth = authType === 'basic' ? m.trino_auth_basic() : m.trino_auth_none(); + const url = $connectionForm.connectionUrl; + const host = url ? url.replace(/^https?:\/\//, '').replace(/\/$/, '') : '-'; + const auth = $connectionForm.authType === 'basic' ? m.trino_auth_basic() : m.trino_auth_none(); return `${host} \u00b7 ${auth}`; }); @@ -490,11 +469,6 @@ {#if !data.trinoConfigured}
- - - - -
{#if $connectionErrors.connectionUrl}

{$connectionErrors.connectionUrl}

@@ -538,25 +513,25 @@ id="{uid}-auth-none" class="btn join-item btn-sm" type="radio" - name="{uid}-auth" + name="authType" aria-label={m.trino_auth_none()} value="none" - bind:group={authType} + bind:group={$connectionForm.authType} />
- {#if authType === 'basic'} + {#if $connectionForm.authType === 'basic'}
{#if $connectionErrors.authUsername}

{$connectionErrors.authUsername}

@@ -585,11 +561,12 @@ {#if $connectionErrors.authPassword}

{$connectionErrors.authPassword}

diff --git a/src/routes/(app)/trino/validation.ts b/src/routes/(app)/trino/validation.ts index eda60cbe..43d1eefb 100644 --- a/src/routes/(app)/trino/validation.ts +++ b/src/routes/(app)/trino/validation.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import * as m from '$lib/paraglide/messages.js'; export { isPageSize, type PageSize } from '$lib/types/pagination.js'; @@ -6,7 +7,7 @@ export const TabIdSchema = z.uuid(); export const ConnectionSchema = z .object({ - connectionUrl: z.string().url(), + connectionUrl: z.url({ error: () => m.trino_connection_url_invalid() }), authType: z.enum(['none', 'basic']).default('none'), authUsername: z.string().default(''), authPassword: z.string().default('') @@ -15,16 +16,16 @@ export const ConnectionSchema = z if (data.authType === 'basic') { if (!data.authUsername) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: 'custom', path: ['authUsername'], - message: 'Username is required for basic authentication' + message: m.trino_auth_username_required() }); } if (!data.authPassword) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: 'custom', path: ['authPassword'], - message: 'Password is required for basic authentication' + message: m.trino_auth_password_required() }); } } From 3a6950533db5f492bfb8c16f0fdfaad5d3160f51 Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Mon, 5 Oct 2026 10:59:50 +0200 Subject: [PATCH 2/4] feat: use superforms form submit --- e2e/trino/connection-form.spec.ts | 35 ++++++++++++++++++++++++ messages/de.json | 2 ++ messages/en.json | 2 ++ src/routes/(app)/trino/+page.server.ts | 11 +++++--- src/routes/(app)/trino/+page.svelte | 37 +++++++++++--------------- 5 files changed, 62 insertions(+), 25 deletions(-) diff --git a/e2e/trino/connection-form.spec.ts b/e2e/trino/connection-form.spec.ts index 29e43496..d8eeebbb 100644 --- a/e2e/trino/connection-form.spec.ts +++ b/e2e/trino/connection-form.spec.ts @@ -216,4 +216,39 @@ test.describe('Connection form (manual mode)', () => { // The collapse header should show a summary with the host. await expect(page.getByText('localhost:8080')).toBeVisible(); }); + + test('failed reconnect of a stored connection opens the form with the error', async ({ + page + }) => { + await page.addInitScript(() => { + localStorage.setItem('trino_url', 'http://localhost:8080'); + localStorage.setItem('trino_auth_type', 'none'); + }); + + // The server is env-configured, so the automatic reconnect is rejected. + await page.goto('/trino'); + await waitForHydration(page); + + await expect( + page.getByText('The connection is managed via environment variables.') + ).toBeVisible(); + await expect(page.getByLabel('URL')).toHaveValue('http://localhost:8080'); + }); + + test('network error during reconnect opens the form with the error', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('trino_url', 'http://localhost:8080'); + localStorage.setItem('trino_auth_type', 'none'); + }); + await page.route( + (url) => url.pathname === '/trino' && url.search === '?/save', + (route) => route.abort() + ); + + await page.goto('/trino'); + await waitForHydration(page); + + await expect(page.getByText('Could not save the connection. Please try again.')).toBeVisible(); + await expect(page.getByLabel('URL')).toHaveValue('http://localhost:8080'); + }); }); diff --git a/messages/de.json b/messages/de.json index 69e34fc6..e87626b4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -73,6 +73,8 @@ "trino_auth_password_required": "Für Basic-Authentifizierung ist ein Passwort erforderlich", "trino_connection_test_timeout": "Zeitüberschreitung beim Verbindungstest", "trino_connection_test_failed": "Verbindung zu Trino fehlgeschlagen – bitte URL und Zugangsdaten prüfen.", + "trino_connection_save_failed": "Die Verbindung konnte nicht gespeichert werden. Bitte erneut versuchen.", + "trino_connection_env_managed": "Die Verbindung wird über Umgebungsvariablen verwaltet.", "trino_cancel_query": "Abbrechen", "trino_state_submitting": "Wird eingereicht", "trino_state_queued": "Warteschlange", diff --git a/messages/en.json b/messages/en.json index 18b18791..dd6269c9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -73,6 +73,8 @@ "trino_auth_password_required": "Password is required for basic authentication", "trino_connection_test_timeout": "Connection test timed out", "trino_connection_test_failed": "Could not connect to Trino — check the URL and credentials.", + "trino_connection_save_failed": "Could not save the connection. Please try again.", + "trino_connection_env_managed": "The connection is managed via environment variables.", "trino_cancel_query": "Cancel", "trino_state_submitting": "Submitting", "trino_state_queued": "Queued", diff --git a/src/routes/(app)/trino/+page.server.ts b/src/routes/(app)/trino/+page.server.ts index 18a0265b..f614e39c 100644 --- a/src/routes/(app)/trino/+page.server.ts +++ b/src/routes/(app)/trino/+page.server.ts @@ -31,12 +31,17 @@ export const actions: Actions = { save: async ({ request, locals }) => { const log = locals.logger; + const form = await superValidate(request, zod(ConnectionSchema)); + if (trinoConfigured) { - return fail(400, { error: 'Connection is managed via environment variables' }); + log.debug('connection save rejected, Trino is configured via environment variables'); + return message( + form, + { type: 'error', message: m.trino_connection_env_managed() } satisfies ConnectionMessage, + { status: 400 } + ); } - const form = await superValidate(request, zod(ConnectionSchema)); - if (!form.valid) { log.debug({ errors: form.errors }, 'connection form validation failed'); return fail(400, { form }); diff --git a/src/routes/(app)/trino/+page.svelte b/src/routes/(app)/trino/+page.svelte index 21e4c181..75333d96 100644 --- a/src/routes/(app)/trino/+page.svelte +++ b/src/routes/(app)/trino/+page.svelte @@ -1,5 +1,5 @@