diff --git a/e2e/storage/connection.spec.ts b/e2e/storage/connection.spec.ts index 2ce6c8f7..d0130613 100644 --- a/e2e/storage/connection.spec.ts +++ b/e2e/storage/connection.spec.ts @@ -46,6 +46,18 @@ test.describe('Storage S3 — Connection', () => { await expect(page.getByText('Access denied — check your credentials.')).toBeVisible(); }); + test('a cleared port field means the default port, not a validation error', async ({ page }) => { + await openConnectForm(page); + const port = page.getByLabel('Port'); + await port.fill('9000'); + await port.fill(''); + await page.getByRole('button', { name: 'Connect', exact: true }).click(); + + // The empty host still fails, so validation has run. + await expect(page.getByText('Host is required')).toBeVisible(); + await expect(page.getByText(/Port must be|Too small/)).toHaveCount(0); + }); + test('disconnects from Garage S3', async ({ page }) => { const credentials = requireGarageCredentials(); diff --git a/e2e/storage/connections-management.spec.ts b/e2e/storage/connections-management.spec.ts index d39d5aa9..4dea636c 100644 --- a/e2e/storage/connections-management.spec.ts +++ b/e2e/storage/connections-management.spec.ts @@ -1,8 +1,15 @@ import { test, expect, type Page } from '../support/fixtures.js'; -import { hasGarageCredentials, requireGarageCredentials } from '../support/garage.js'; +import { + createGarageBucketCredentials, + hasGarageAdmin, + hasGarageCredentials, + requireGarageCredentials +} from '../support/garage.js'; import { connectToStorage, openConnectForm } from './helpers.js'; import { waitForHydration } from '../support/helpers.js'; +const PORT_1_ERROR = 'Could not reach the storage backend — check the host and port.'; + /** Open the Options context menu for the first connection row and click Edit. */ async function openFirstConnectionEditPage(page: Page) { await page.locator('table tbody tr').first().waitFor(); @@ -149,6 +156,22 @@ test.describe('Storage — Connections management', () => { await expect(page.getByText('Access denied — check your credentials.')).toBeVisible(); }); + test('edit page tests the connection when only the endpoint changes', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectToStorage(page, credentials); + await page.goto('/settings/connections'); + await waitForHydration(page); + + await openFirstConnectionEditPage(page); + + // Credentials stay blank (keep the stored ones); nothing listens on port 1. + await page.getByLabel('Port').fill('1'); + await page.getByRole('button', { name: 'Save changes' }).click(); + + await expect(page.getByText(PORT_1_ERROR)).toBeVisible(); + await expect(page).toHaveURL(/\/edit$/); + }); + test('delete button on management page removes the connection after confirmation', async ({ page }) => { @@ -173,6 +196,27 @@ test.describe('Storage — Connections management', () => { await expect(dataRows).toHaveCount(initialItems - 1); }); + test('sidebar on the edit page deletes a connection', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectToStorage(page, credentials); + // Disconnecting shows the saved connections sidebar; navigate from there so it stays filled. + await page.getByRole('button', { name: 'Disconnect' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Disconnect' }).click(); + await expect(page.getByRole('heading', { name: 'Connect to storage' })).toBeVisible(); + const savedList = page.getByRole('list', { name: 'Saved connections' }); + await savedList.getByRole('button', { name: 'More options' }).first().click({ force: true }); + await page.getByRole('menuitem', { name: 'Edit', exact: true }).click(); + await waitForHydration(page); + await expect(page.getByRole('heading', { name: 'Edit connection' })).toBeVisible(); + + await savedList.getByRole('button', { name: 'More options' }).first().click({ force: true }); + await page.getByRole('menuitem', { name: 'Delete', exact: true }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Delete', exact: true }).click(); + + await expect(page).toHaveURL('/settings/connections'); + await expect(page.locator('table tbody tr')).toHaveCount(0); + }); + test('redirects to /settings/connections when editing a non-existent id', async ({ page }) => { await page.goto('/settings/connections/00000000-0000-0000-0000-000000000000/edit'); await waitForHydration(page); @@ -231,6 +275,40 @@ test.describe('Storage — Connections management', () => { await expect(page.getByRole('heading', { name: 'Edit connection' })).toBeVisible(); }); + test('edit page without changes leaves without asking', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectToStorage(page, credentials); + await page.goto('/settings/connections'); + await waitForHydration(page); + + await openFirstConnectionEditPage(page); + await page.getByRole('link', { name: '← Manage connections' }).click(); + + await expect(page).toHaveURL('/settings/connections'); + await expect(page.getByRole('dialog')).toHaveCount(0); + }); + + test('unsaved-changes modal: Escape stays and the guard asks again', async ({ page }) => { + const credentials = requireGarageCredentials(); + await connectToStorage(page, credentials); + await page.goto('/settings/connections'); + await waitForHydration(page); + + await openFirstConnectionEditPage(page); + + await page.getByLabel('Connection name').fill('Changed name'); + const backLink = page.getByRole('link', { name: '← Manage connections' }); + await backLink.click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toBeHidden(); + await expect(page).toHaveURL(/\/edit$/); + + await backLink.click(); + await expect(page.getByRole('heading', { name: 'Unsaved changes' })).toBeVisible(); + }); + test('unsaved-changes modal: Leave navigates away', async ({ page }) => { const credentials = requireGarageCredentials(); await connectToStorage(page, credentials); @@ -248,3 +326,162 @@ test.describe('Storage — Connections management', () => { await expect(page).toHaveURL('/settings/connections'); }); }); + +test.describe('Storage — Connection edit sidebar', () => { + test.use({ locale: 'en-US' }); + + test.beforeEach(() => { + test.skip( + !hasGarageCredentials() || !hasGarageAdmin(), + 'Skipped: requires a running Garage instance with admin access' + ); + }); + + /** + * Saves two connections that differ in their access key, then opens the edit page of the most + * recent one from the saved connections sidebar, which stays filled when navigated from there. + * Returns the access keys in sidebar order. + */ + async function openEditWithTwoConnections(page: Page): Promise<[string, string]> { + const base = requireGarageCredentials(); + const second = await createGarageBucketCredentials(base, { + bucketName: 'e2e-connection-edit-sidebar', + keyName: 'e2e-connection-edit-sidebar', + permissions: { owner: false, read: true, write: false } + }); + await connectToStorage(page, base); + await connectToStorage(page, second, { clearSaved: false }); + + await page.getByRole('button', { name: 'Disconnect' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Disconnect' }).click(); + await expect(page.getByRole('heading', { name: 'Connect to storage' })).toBeVisible(); + const savedList = page.getByRole('list', { name: 'Saved connections' }); + await expect(savedList.getByRole('listitem')).toHaveCount(2); + await savedList.getByRole('button', { name: 'More options' }).first().click({ force: true }); + await page.getByRole('menuitem', { name: 'Edit', exact: true }).click(); + await waitForHydration(page); + await expect(page.getByRole('heading', { name: 'Edit connection' })).toBeVisible(); + + // Most recently saved first. + return [second.accessKeyId, base.accessKeyId]; + } + + function sidebarConnection(page: Page, index: number) { + return page + .getByRole('list', { name: 'Saved connections' }) + .getByRole('listitem') + .nth(index) + .getByRole('button') + .first(); + } + + async function deleteFromSidebar(page: Page, index: number) { + await page + .getByRole('list', { name: 'Saved connections' }) + .getByRole('button', { name: 'More options' }) + .nth(index) + .click({ force: true }); + await page.getByRole('menuitem', { name: 'Delete', exact: true }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Delete', exact: true }).click(); + } + + test('switching connections loads the other connection without asking', async ({ page }) => { + const [firstKey, secondKey] = await openEditWithTwoConnections(page); + await expect(page.getByLabel('Access key')).toHaveValue(firstKey); + const firstUrl = page.url(); + + await sidebarConnection(page, 1).click(); + + await expect(page).not.toHaveURL(firstUrl); + await expect(page.getByLabel('Access key')).toHaveValue(secondKey); + await expect(page.getByRole('dialog')).toHaveCount(0); + + // The switch must not leave the form tainted. + await page.getByRole('link', { name: '← Manage connections' }).click(); + await expect(page).toHaveURL('/settings/connections'); + await expect(page.getByRole('dialog')).toHaveCount(0); + }); + + test('switching connections with unsaved changes asks first', async ({ page }) => { + await openEditWithTwoConnections(page); + const firstUrl = page.url(); + + await page.getByLabel('Connection name').fill('Changed name'); + await sidebarConnection(page, 1).click(); + + await expect(page.getByRole('heading', { name: 'Unsaved changes' })).toBeVisible(); + await page.getByRole('dialog').getByRole('button', { name: 'Stay on page' }).click(); + await expect(page).toHaveURL(firstUrl); + await expect(page.getByLabel('Connection name')).toHaveValue('Changed name'); + }); + + test('sidebar delete with unsaved changes asks in the app before deleting', async ({ page }) => { + await openEditWithTwoConnections(page); + let deleteRequests = 0; + let nativeDialogs = 0; + page.on('request', (request) => { + if (request.url().endsWith('?/deleteConnection')) deleteRequests++; + }); + page.on('dialog', (dialog) => { + nativeDialogs++; + void dialog.dismiss(); + }); + + await page.getByLabel('Connection name').fill('Changed name'); + await deleteFromSidebar(page, 1); + + await expect(page.getByRole('heading', { name: 'Unsaved changes' })).toBeVisible(); + await page.getByRole('button', { name: 'Stay on page' }).click(); + await expect(page.getByRole('heading', { name: 'Unsaved changes' })).toBeHidden(); + await expect(page).toHaveURL(/\/edit$/); + expect(deleteRequests).toBe(0); + + await deleteFromSidebar(page, 1); + await page.getByRole('button', { name: 'Leave' }).click(); + + await expect(page).toHaveURL('/settings/connections'); + await expect(page.locator('table tbody tr')).toHaveCount(1); + expect(deleteRequests).toBe(1); + expect(nativeDialogs).toBe(0); + }); + + test('sidebar delete without changes deletes without asking', async ({ page }) => { + await openEditWithTwoConnections(page); + + await deleteFromSidebar(page, 1); + + await expect(page).toHaveURL('/settings/connections'); + await expect(page.getByRole('heading', { name: 'Unsaved changes' })).toHaveCount(0); + await expect(page.locator('table tbody tr')).toHaveCount(1); + + // The sidebar on the next edit page no longer lists the deleted connection. + await openFirstConnectionEditPage(page); + await expect( + page.getByRole('list', { name: 'Saved connections' }).getByRole('listitem') + ).toHaveCount(1); + }); + + test('a failed sidebar delete shows the error and keeps the unsaved changes', async ({ + page + }) => { + await openEditWithTwoConnections(page); + // An invalid ID makes the action fail. + await page.route(/\?\/deleteConnection$/, (route) => + route.continue({ postData: 'connectionId=not-a-uuid' }) + ); + + await page.getByLabel('Connection name').fill('Changed name'); + await deleteFromSidebar(page, 1); + await page.getByRole('button', { name: 'Leave' }).click(); + + await expect( + page.getByRole('alert').filter({ hasText: 'Connection not found.' }) + ).toBeVisible(); + await expect(page).toHaveURL(/\/edit$/); + await expect(page.getByLabel('Connection name')).toHaveValue('Changed name'); + + // The changes are still guarded. + await page.getByRole('link', { name: '← Manage connections' }).click(); + await expect(page.getByRole('heading', { name: 'Unsaved changes' })).toBeVisible(); + }); +}); diff --git a/e2e/storage/helpers.ts b/e2e/storage/helpers.ts index 0b40a6c6..02a7fe25 100644 --- a/e2e/storage/helpers.ts +++ b/e2e/storage/helpers.ts @@ -53,6 +53,7 @@ async function clearSavedConnections(page: Page) { .catch(() => false) ) break; + const before = await items.count(); // Click the last button in the first list item. On desktop this is the // "more options" button (opacity-0, so use force). On mobile it's the // X delete button which submits the form directly. @@ -65,9 +66,15 @@ async function clearSavedConnections(page: Page) { // Confirm in the modal await page.getByRole('button', { name: 'Delete', exact: true }).click(); } - // The form action POSTs and the server redirects back to /storage; - // wait for the page to re-hydrate before checking the list again. - await waitForHydration(page); + // The delete is sent without a page reload: wait until the list has changed. The last + // delete empties it (desktop shows a placeholder, mobile hides the list). + await expect(async () => { + const changed = + !(await savedList.isVisible()) || + (await savedList.getByText('No saved connections yet').isVisible()) || + (await items.count()) < before; + expect(changed).toBe(true); + }).toPass(); } } @@ -103,14 +110,18 @@ export async function openConnectForm(page: Page, { clearSaved = true } = {}) { } } -export async function connectToStorage(page: Page, credentials: GarageCredentials) { - await openConnectForm(page); +export async function connectToStorage( + page: Page, + credentials: GarageCredentials, + { clearSaved = true } = {} +) { + await openConnectForm(page, { clearSaved }); const url = new URL(credentials.endpoint); const host = url.hostname; const port = url.port; const useTls = url.protocol === 'https:'; - await page.getByLabel('Host').fill(host); + await page.getByLabel('Host', { exact: true }).fill(host); if (port) { await page.getByLabel('Port').fill(port); } diff --git a/messages/de.json b/messages/de.json index a08e2e08..67fcabfe 100644 --- a/messages/de.json +++ b/messages/de.json @@ -166,12 +166,20 @@ "storage_connect_timeout": "Zeitüberschreitung der Verbindung. Das Speicher-Backend hat nicht geantwortet.", "storage_connect_cancel": "Abbrechen", "storage_connect_error": "Verbindung fehlgeschlagen — Endpunkt und Zugangsdaten prüfen.", - "storage_connect_error_unreachable": "Verbindung zum ausgewählten Speicher nicht möglich — der Endpunkt ist möglicherweise nicht erreichbar.", + "storage_connection_switch_failed": "Wechsel zur ausgewählten Verbindung nicht möglich. Bitte erneut versuchen.", "storage_connect_error_access_denied": "Zugriff verweigert — Zugangsdaten prüfen.", "storage_connect_error_not_found": "Speicher-Backend nicht gefunden — Endpunkt prüfen.", "storage_connect_error_server_error": "Das Speicher-Backend hat einen Fehler gemeldet — Endpunkt und Zugangsdaten prüfen.", "storage_connect_error_network": "Speicher-Backend nicht erreichbar — Host und Port prüfen.", "storage_connect_error_hdfs": "HDFS-Verbindungen werden noch nicht unterstützt.", + "storage_connect_save_failed": "Die Verbindung konnte nicht gespeichert werden. Bitte erneut versuchen.", + "storage_connect_activate_failed": "Die Verbindung wurde gespeichert, konnte aber nicht aktiviert werden. Laden Sie die Seite neu und wählen Sie sie in den gespeicherten Verbindungen aus.", + "storage_connect_host_required": "Host ist erforderlich", + "storage_connect_host_invalid": "Host muss ein gültiger Hostname oder eine gültige IP-Adresse sein", + "storage_connect_port_invalid": "Port muss eine ganze Zahl zwischen 1 und 65535 sein", + "storage_connect_region_required": "Region ist erforderlich", + "storage_connect_access_key_required": "Zugriffsschlüssel ist erforderlich, wenn ein geheimer Schlüssel angegeben ist", + "storage_connect_secret_key_required": "Geheimer Schlüssel ist erforderlich, wenn ein Zugriffsschlüssel angegeben ist", "storage_connect_saved": "Gespeicherte Verbindungen", "storage_connect_no_saved": "Noch keine gespeicherten Verbindungen", "storage_connect_forget": "Löschen", @@ -208,6 +216,7 @@ "storage_connections_delete_confirm": "\"{label}\" aus den gespeicherten Verbindungen entfernen? Dies kann nicht rückgängig gemacht werden.", "storage_connections_delete_cancel": "Abbrechen", "storage_connections_delete_confirm_button": "Löschen", + "storage_connections_delete_failed": "Die Verbindung konnte nicht gelöscht werden. Bitte erneut versuchen.", "storage_connection_edit_title": "Verbindung bearbeiten", "storage_connection_edit_name": "Verbindungsname", "storage_connection_edit_name_placeholder": "z. B. Produktions-Garage", diff --git a/messages/en.json b/messages/en.json index 8bd19461..46be28db 100644 --- a/messages/en.json +++ b/messages/en.json @@ -166,12 +166,20 @@ "storage_connect_timeout": "Connection timed out. The storage backend did not respond.", "storage_connect_cancel": "Cancel", "storage_connect_error": "Could not connect — check the endpoint and credentials.", - "storage_connect_error_unreachable": "Could not connect to the selected storage — the endpoint may be unreachable.", + "storage_connection_switch_failed": "Could not switch to the selected connection. Please try again.", "storage_connect_error_access_denied": "Access denied — check your credentials.", "storage_connect_error_not_found": "Could not find the storage backend — check the endpoint.", "storage_connect_error_server_error": "The storage backend returned an error — check the endpoint and credentials.", "storage_connect_error_network": "Could not reach the storage backend — check the host and port.", "storage_connect_error_hdfs": "HDFS connections are not yet supported.", + "storage_connect_save_failed": "Could not save the connection. Please try again.", + "storage_connect_activate_failed": "The connection was saved but could not be activated. Reload the page and select it from the saved connections.", + "storage_connect_host_required": "Host is required", + "storage_connect_host_invalid": "Host must be a valid hostname or IP address", + "storage_connect_port_invalid": "Port must be a whole number between 1 and 65535", + "storage_connect_region_required": "Region is required", + "storage_connect_access_key_required": "Access key is required when a secret key is provided", + "storage_connect_secret_key_required": "Secret key is required when an access key is provided", "storage_connect_saved": "Saved connections", "storage_connect_no_saved": "No saved connections yet", "storage_connect_forget": "Delete", @@ -208,6 +216,7 @@ "storage_connections_delete_confirm": "Remove “{label}” from saved connections? This cannot be undone.", "storage_connections_delete_cancel": "Cancel", "storage_connections_delete_confirm_button": "Delete", + "storage_connections_delete_failed": "Could not delete the connection. Please try again.", "storage_connection_edit_title": "Edit connection", "storage_connection_edit_name": "Connection name", "storage_connection_edit_name_placeholder": "e.g. Production Garage", diff --git a/src/lib/components/storage/landing/StorageConnectForm.svelte b/src/lib/components/storage/landing/StorageConnectForm.svelte index c15ff6b3..1510eda5 100644 --- a/src/lib/components/storage/landing/StorageConnectForm.svelte +++ b/src/lib/components/storage/landing/StorageConnectForm.svelte @@ -27,9 +27,9 @@ { dataType: 'json', validators: zod(StorageConnectionSchema), - onResult: () => { - // Server saved the connection; the redirect to /storage triggers - // the layout load to auto-connect via activeStorageConnectionId. + // Network failures or unexpected server errors: show them in the form instead of the error page. + onError() { + $message = { type: 'error', message: m.storage_connect_save_failed() }; } } ); @@ -57,7 +57,7 @@ {#if connectError} {/if} diff --git a/src/lib/components/storage/landing/StorageConnectForm.svelte.spec.ts b/src/lib/components/storage/landing/StorageConnectForm.svelte.spec.ts index 95909388..4b998b9c 100644 --- a/src/lib/components/storage/landing/StorageConnectForm.svelte.spec.ts +++ b/src/lib/components/storage/landing/StorageConnectForm.svelte.spec.ts @@ -47,7 +47,13 @@ vi.mock('$lib/paraglide/messages.js', () => ({ `Forget connection to ${endpoint}`, storage_connect_forget_confirm: ({ endpoint }: { endpoint: string }) => `Remove ${endpoint} from saved connections?`, - storage_connect_error_unreachable: () => 'Could not connect', + storage_connect_save_failed: () => 'Could not save the connection', + storage_connect_host_required: () => 'Host is required', + storage_connect_host_invalid: () => 'Host is invalid', + storage_connect_port_invalid: () => 'Port is invalid', + storage_connect_region_required: () => 'Region is required', + storage_connect_access_key_required: () => 'Access key is required', + storage_connect_secret_key_required: () => 'Secret key is required', storage_connect_manage: () => 'Manage connections', storage_connect_edit: () => 'Edit', storage_more_options: () => 'More options', @@ -412,4 +418,10 @@ describe('StorageConnectForm', () => { await expect.element(page.getByText('Secret key required')).toBeInTheDocument(); await expect.element(page.getByText('Validation failed')).toBeInTheDocument(); }); + + it('should show the error returned by the use action', async () => { + renderForm({}, [], 'Connection not found.'); + + await expect.element(page.getByRole('alert')).toHaveTextContent('Connection not found.'); + }); }); diff --git a/src/lib/components/storage/sidebar/StorageConnectionSidebar.svelte b/src/lib/components/storage/sidebar/StorageConnectionSidebar.svelte index 0e815be0..92ccb314 100644 --- a/src/lib/components/storage/sidebar/StorageConnectionSidebar.svelte +++ b/src/lib/components/storage/sidebar/StorageConnectionSidebar.svelte @@ -6,7 +6,9 @@ import TooltipTrigger from '$lib/components/TooltipTrigger.svelte'; import { createResizablePanel } from './resizable-panel.svelte.js'; import ResizeHandle from './ResizeHandle.svelte'; + import { enhance } from '$app/forms'; import { resolve } from '$app/paths'; + import type { SubmitFunction } from '@sveltejs/kit'; import * as m from '$lib/paraglide/messages.js'; import type { ConnectionMetadata } from '$lib/storage/connection-types.js'; import { connectionLabel } from '$lib/storage/display-helpers.js'; @@ -25,9 +27,16 @@ * connection edits rather than activating them. */ onselect?: (conn: ConnectionMetadata) => void; + /** + * Called before a delete is sent. Resolving `false` cancels it. Edit pages use this to ask + * about unsaved changes before the connection is deleted, not after. + */ + onbeforedelete?: () => Promise; + /** Called once the server has answered a delete; `deleted` is false when it failed. */ + ondeletesettled?: (connectionId: string, deleted: boolean) => void; } - let { connections, activeId, onselect }: Props = $props(); + let { connections, activeId, onselect, onbeforedelete, ondeletesettled }: Props = $props(); const resize = createResizablePanel({ storageKey: 'storage_connect_sidebar_width', @@ -96,6 +105,22 @@ closeContextMenu(); } + const enhanceDelete: SubmitFunction = async ({ cancel }) => { + if (onbeforedelete && !(await onbeforedelete())) { + cancel(); + cancelDelete(); + return; + } + return async ({ formData, result, update }) => { + cancelDelete(); + ondeletesettled?.( + String(formData.get('connectionId')), + result.type === 'redirect' || result.type === 'success' + ); + await update(); + }; + }; + const deleteConn = $derived.by(() => { if (!menuConn) return null; const ep = menuConn.endpoint ?? ''; @@ -111,7 +136,13 @@ {#if menuConn} -
+