From 4bbe4a27d1a613e74606cb664205cfae07fbd3d2 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 21 Mar 2026 13:16:29 +0100 Subject: [PATCH] feat(storage): add drag & drop file moving and Playwright E2E tests Drag & Drop: - FileCard: draggable with type/id data transfer - FolderCard: draggable + drop target with visual feedback (dashed green border) - FileGrid: onMoveToFolder callback for drag-to-folder operations - filesStore: moveFile() and moveFolder() methods via API - Wired up in /files and /files/[folderId] pages with toast notifications E2E Tests (Playwright): - playwright.config.ts with multi-browser support - auth.spec.ts: login page rendering, invalid credentials, redirect - files.spec.ts: file list UI, view toggle, new folder modal, empty state - navigation.spec.ts: nav items, routing, page headings - search.spec.ts: search input, button state, initial state Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/storage/apps/web/e2e/auth.spec.ts | 59 +++++++++ apps/storage/apps/web/e2e/files.spec.ts | 119 ++++++++++++++++++ apps/storage/apps/web/e2e/navigation.spec.ts | 98 +++++++++++++++ apps/storage/apps/web/e2e/search.spec.ts | 79 ++++++++++++ apps/storage/apps/web/package.json | 5 +- apps/storage/apps/web/playwright.config.ts | 39 ++++++ .../src/lib/components/files/FileCard.svelte | 12 +- .../src/lib/components/files/FileGrid.svelte | 13 +- .../lib/components/files/FolderCard.svelte | 54 +++++++- .../apps/web/src/lib/stores/files.svelte.ts | 16 +++ .../apps/web/src/routes/files/+page.svelte | 23 ++++ .../src/routes/files/[folderId]/+page.svelte | 23 ++++ 12 files changed, 534 insertions(+), 6 deletions(-) create mode 100644 apps/storage/apps/web/e2e/auth.spec.ts create mode 100644 apps/storage/apps/web/e2e/files.spec.ts create mode 100644 apps/storage/apps/web/e2e/navigation.spec.ts create mode 100644 apps/storage/apps/web/e2e/search.spec.ts create mode 100644 apps/storage/apps/web/playwright.config.ts diff --git a/apps/storage/apps/web/e2e/auth.spec.ts b/apps/storage/apps/web/e2e/auth.spec.ts new file mode 100644 index 000000000..1e05344af --- /dev/null +++ b/apps/storage/apps/web/e2e/auth.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from '@playwright/test'; + +// Auth tests run WITHOUT storageState (unauthenticated) + +// Helper: wait for the app to finish loading (skeleton disappears) +async function waitForAppReady(page: import('@playwright/test').Page) { + // The root layout shows a loading spinner until auth initializes + // Wait for it to disappear and the actual page content to render + await page.waitForFunction( + () => { + // Check if loading skeleton is gone + const skeleton = document.querySelector('.app-loading-skeleton, [data-skeleton]'); + return !skeleton || skeleton.children.length === 0; + }, + { timeout: 30000 } + ); + // Give Svelte time to render + await page.waitForTimeout(500); +} + +test.describe('Authentication', () => { + test('login page renders with email and password fields', async ({ page }) => { + await page.goto('/login'); + await waitForAppReady(page); + + // LoginPage uses id="email" and id="password" (from shared-auth-ui) + const emailInput = page.locator('input[type="email"], input[name="email"], #email'); + const passwordInput = page.locator('input[type="password"], input[name="password"], #password'); + + await expect(emailInput.first()).toBeVisible({ timeout: 10000 }); + await expect(passwordInput.first()).toBeVisible({ timeout: 5000 }); + await expect(page.locator('button[type="submit"]')).toBeVisible(); + }); + + test('invalid credentials show error message', async ({ page }) => { + await page.goto('/login'); + await waitForAppReady(page); + + const emailInput = page.locator('input[type="email"], input[name="email"], #email').first(); + const passwordInput = page + .locator('input[type="password"], input[name="password"], #password') + .first(); + + await emailInput.fill('nonexistent@test.local'); + await passwordInput.fill('WrongPassword123!'); + await page.locator('button[type="submit"]').click(); + + // Error alert should appear + const errorAlert = page.locator('#form-error, [role="alert"]'); + await expect(errorAlert.first()).toBeVisible({ timeout: 10000 }); + }); + + test('unauthenticated access to /files redirects to /login', async ({ page }) => { + await page.goto('/files'); + + // The app layout's onMount redirects unauthenticated users to /login + await page.waitForURL(/\/login/, { timeout: 30000 }); + }); +}); diff --git a/apps/storage/apps/web/e2e/files.spec.ts b/apps/storage/apps/web/e2e/files.spec.ts new file mode 100644 index 000000000..9e4355b01 --- /dev/null +++ b/apps/storage/apps/web/e2e/files.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from '@playwright/test'; + +// Helper: wait for the app to finish loading +async function waitForAppReady(page: import('@playwright/test').Page) { + await page.waitForFunction( + () => { + const skeleton = document.querySelector('.app-loading-skeleton, [data-skeleton]'); + return !skeleton || skeleton.children.length === 0; + }, + { timeout: 30000 } + ); + await page.waitForTimeout(500); +} + +test.describe('File Management', () => { + test('root page (/) redirects to /files', async ({ page }) => { + await page.goto('/'); + + // The root +page.svelte does goto('/files') on mount + await page.waitForURL(/\/files/, { timeout: 15000 }); + }); + + test('files page shows "Meine Dateien" heading', async ({ page }) => { + await page.goto('/files'); + await waitForAppReady(page); + + const heading = page.locator('h1', { hasText: 'Meine Dateien' }); + await expect(heading).toBeVisible({ timeout: 10000 }); + }); + + test('files page has view toggle buttons (grid/list)', async ({ page }) => { + await page.goto('/files'); + await waitForAppReady(page); + + const gridButton = page.locator('button[aria-label="Rasteransicht"]'); + const listButton = page.locator('button[aria-label="Listenansicht"]'); + + await expect(gridButton).toBeVisible({ timeout: 10000 }); + await expect(listButton).toBeVisible(); + }); + + test('files page has "Hochladen" and "Neuer Ordner" buttons', async ({ page }) => { + await page.goto('/files'); + await waitForAppReady(page); + + const uploadButton = page.locator('button', { hasText: 'Hochladen' }); + const newFolderButton = page.locator('button', { hasText: 'Neuer Ordner' }); + + await expect(uploadButton).toBeVisible({ timeout: 10000 }); + await expect(newFolderButton).toBeVisible(); + }); + + test('clicking "Neuer Ordner" opens modal with name input', async ({ page }) => { + await page.goto('/files'); + await waitForAppReady(page); + + // Click the "Neuer Ordner" button + const newFolderButton = page.locator('button', { hasText: 'Neuer Ordner' }).first(); + await newFolderButton.click(); + + // Modal should appear with role="dialog" + const modal = page.locator('[role="dialog"]'); + await expect(modal).toBeVisible({ timeout: 5000 }); + + // Modal should have the title "Neuer Ordner" + const modalTitle = page.locator('#modal-title'); + await expect(modalTitle).toHaveText('Neuer Ordner'); + + // Modal should have a folder name input + const nameInput = page.locator('#folder-name'); + await expect(nameInput).toBeVisible(); + await expect(nameInput).toHaveAttribute('type', 'text'); + + // Modal should have a submit button + const createButton = page.locator('[role="dialog"] button[type="submit"]'); + await expect(createButton).toBeVisible(); + await expect(createButton).toHaveText('Erstellen'); + }); + + test('empty state shows upload prompt', async ({ page }) => { + await page.goto('/files'); + await waitForAppReady(page); + + // When no files exist, the empty state should show + // It may show loading first, then empty state or file list + // We check for either empty state or file content + const emptyState = page.locator('.empty-state'); + const fileContent = page.locator('.files-page'); + + await expect(fileContent).toBeVisible({ timeout: 10000 }); + + // If empty state is visible, verify its content + if (await emptyState.isVisible()) { + const emptyHeading = page.locator('.empty-state h2', { hasText: 'Noch keine Dateien' }); + await expect(emptyHeading).toBeVisible(); + + const emptyText = page.locator('.empty-state p', { + hasText: 'Lade deine ersten Dateien hoch', + }); + await expect(emptyText).toBeVisible(); + } + }); + + test('view toggle switches between grid and list view', async ({ page }) => { + await page.goto('/files'); + await waitForAppReady(page); + + const gridButton = page.locator('button[aria-label="Rasteransicht"]'); + const listButton = page.locator('button[aria-label="Listenansicht"]'); + + // Click list view button + await listButton.click(); + await expect(listButton).toHaveClass(/active/); + + // Click grid view button + await gridButton.click(); + await expect(gridButton).toHaveClass(/active/); + }); +}); diff --git a/apps/storage/apps/web/e2e/navigation.spec.ts b/apps/storage/apps/web/e2e/navigation.spec.ts new file mode 100644 index 000000000..316a8ee50 --- /dev/null +++ b/apps/storage/apps/web/e2e/navigation.spec.ts @@ -0,0 +1,98 @@ +import { test, expect } from '@playwright/test'; + +// Helper: wait for the app to finish loading +async function waitForAppReady(page: import('@playwright/test').Page) { + await page.waitForFunction( + () => { + const skeleton = document.querySelector('.app-loading-skeleton, [data-skeleton]'); + return !skeleton || skeleton.children.length === 0; + }, + { timeout: 30000 } + ); + await page.waitForTimeout(500); +} + +test.describe('Navigation', () => { + test('all nav items exist: Dateien, Geteilt, Favoriten, Papierkorb, Suche', async ({ page }) => { + await page.goto('/files'); + await waitForAppReady(page); + + // The PillNavigation renders navigation links + const navItems = ['Dateien', 'Geteilt', 'Favoriten', 'Papierkorb', 'Suche']; + + for (const label of navItems) { + const navLink = page.locator(`a, button`, { hasText: label }).first(); + await expect(navLink).toBeVisible({ timeout: 10000 }); + } + }); + + test('clicking navigation items changes URL', async ({ page }) => { + await page.goto('/files'); + await waitForAppReady(page); + + // Navigate to Favoriten + const favoritesLink = page.locator('a[href="/favorites"]').first(); + await favoritesLink.click(); + await expect(page).toHaveURL(/\/favorites/); + + // Navigate to Papierkorb + const trashLink = page.locator('a[href="/trash"]').first(); + await trashLink.click(); + await expect(page).toHaveURL(/\/trash/); + + // Navigate to Suche + const searchLink = page.locator('a[href="/search"]').first(); + await searchLink.click(); + await expect(page).toHaveURL(/\/search/); + + // Navigate back to Dateien + const filesLink = page.locator('a[href="/files"]').first(); + await filesLink.click(); + await expect(page).toHaveURL(/\/files/); + }); + + test('search page has search input', async ({ page }) => { + await page.goto('/search'); + await waitForAppReady(page); + + const searchInput = page.locator('input[type="search"]'); + await expect(searchInput).toBeVisible({ timeout: 10000 }); + await expect(searchInput).toHaveAttribute('placeholder', 'Dateien und Ordner durchsuchen...'); + }); + + test('trash page shows "Papierkorb" heading', async ({ page }) => { + await page.goto('/trash'); + await waitForAppReady(page); + + const heading = page.locator('h1', { hasText: 'Papierkorb' }); + await expect(heading).toBeVisible({ timeout: 10000 }); + }); + + test('settings page loads', async ({ page }) => { + await page.goto('/settings'); + await waitForAppReady(page); + + // The settings page has a title "Einstellungen" + const heading = page.getByText('Einstellungen', { exact: false }).first(); + await expect(heading).toBeVisible({ timeout: 10000 }); + }); + + test('offline page renders correctly', async ({ page }) => { + await page.goto('/offline'); + + // The offline page shows either "Du bist offline" or "Verbindung wiederhergestellt" + // Since we are connected during tests, it may redirect + // But we can check the page title + await expect(page).toHaveTitle(/Offline - Storage/); + + // The page should contain the offline heading or redirect message + const offlineHeading = page.locator('h1'); + await expect(offlineHeading).toBeVisible({ timeout: 10000 }); + + // Should show one of the expected texts + const headingText = await offlineHeading.textContent(); + expect( + headingText === 'Du bist offline' || headingText === 'Verbindung wiederhergestellt!' + ).toBeTruthy(); + }); +}); diff --git a/apps/storage/apps/web/e2e/search.spec.ts b/apps/storage/apps/web/e2e/search.spec.ts new file mode 100644 index 000000000..03af770e2 --- /dev/null +++ b/apps/storage/apps/web/e2e/search.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test'; + +// Helper: wait for the app to finish loading +async function waitForAppReady(page: import('@playwright/test').Page) { + await page.waitForFunction( + () => { + const skeleton = document.querySelector('.app-loading-skeleton, [data-skeleton]'); + return !skeleton || skeleton.children.length === 0; + }, + { timeout: 30000 } + ); + await page.waitForTimeout(500); +} + +test.describe('Search', () => { + test('search page renders with input field', async ({ page }) => { + await page.goto('/search'); + await waitForAppReady(page); + + // Page heading + const heading = page.locator('h1', { hasText: 'Suche' }); + await expect(heading).toBeVisible({ timeout: 10000 }); + + // Search input field + const searchInput = page.locator('input[type="search"]'); + await expect(searchInput).toBeVisible(); + }); + + test('search input has correct type="search"', async ({ page }) => { + await page.goto('/search'); + await waitForAppReady(page); + + const searchInput = page.locator('input[type="search"]'); + await expect(searchInput).toBeVisible({ timeout: 10000 }); + await expect(searchInput).toHaveAttribute('type', 'search'); + await expect(searchInput).toHaveAttribute('aria-label', 'Dateien und Ordner durchsuchen'); + }); + + test('empty search shows initial state message', async ({ page }) => { + await page.goto('/search'); + await waitForAppReady(page); + + // When no search has been performed, the initial state should show + const initialMessage = page.locator('.empty-state h2', { + hasText: 'Dateien durchsuchen', + }); + await expect(initialMessage).toBeVisible({ timeout: 10000 }); + + const hintText = page.locator('.empty-state p', { + hasText: 'Gib einen Suchbegriff ein', + }); + await expect(hintText).toBeVisible(); + }); + + test('search button exists and is disabled when input is empty', async ({ page }) => { + await page.goto('/search'); + await waitForAppReady(page); + + // The search bar has a submit button + const searchButton = page.locator('.search-bar button', { hasText: 'Suchen' }); + await expect(searchButton).toBeVisible({ timeout: 10000 }); + + // Button should be disabled when input is empty + await expect(searchButton).toBeDisabled(); + + // Type something into the search input + const searchInput = page.locator('input[type="search"]'); + await searchInput.fill('test query'); + + // Button should now be enabled + await expect(searchButton).toBeEnabled(); + + // Clear the input + await searchInput.fill(''); + + // Button should be disabled again + await expect(searchButton).toBeDisabled(); + }); +}); diff --git a/apps/storage/apps/web/package.json b/apps/storage/apps/web/package.json index bbe3cf5a6..31afb18aa 100644 --- a/apps/storage/apps/web/package.json +++ b/apps/storage/apps/web/package.json @@ -11,7 +11,9 @@ "lint": "eslint .", "format": "prettier --write .", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "devDependencies": { "@testing-library/svelte": "^5.2.0", @@ -36,6 +38,7 @@ }, "dependencies": { "@manacore/shared-api-client": "workspace:*", + "@manacore/shared-app-onboarding": "workspace:*", "@manacore/shared-auth": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", diff --git a/apps/storage/apps/web/playwright.config.ts b/apps/storage/apps/web/playwright.config.ts new file mode 100644 index 000000000..fbce03f7b --- /dev/null +++ b/apps/storage/apps/web/playwright.config.ts @@ -0,0 +1,39 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['html']], + + use: { + baseURL: 'http://localhost:5185', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + actionTimeout: 10000, + navigationTimeout: 30000, + }, + + timeout: 60000, + expect: { timeout: 5000 }, + + projects: process.env.CI + ? [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + ] + : [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + + webServer: { + command: 'pnpm run build && pnpm run preview --port 5185', + port: 5185, + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, + + outputDir: 'test-results/', +}); diff --git a/apps/storage/apps/web/src/lib/components/files/FileCard.svelte b/apps/storage/apps/web/src/lib/components/files/FileCard.svelte index 6ef0f7809..a1359cfc4 100644 --- a/apps/storage/apps/web/src/lib/components/files/FileCard.svelte +++ b/apps/storage/apps/web/src/lib/components/files/FileCard.svelte @@ -51,7 +51,17 @@ const Icon = getFileIcon(file.mimeType); -
+
{ + e.dataTransfer?.setData('application/json', JSON.stringify({ type: 'file', id: file.id })); + e.dataTransfer!.effectAllowed = 'move'; + }} +>
{#if file.isFavorite} diff --git a/apps/storage/apps/web/src/lib/components/files/FileGrid.svelte b/apps/storage/apps/web/src/lib/components/files/FileGrid.svelte index eee5028fd..3b484ee69 100644 --- a/apps/storage/apps/web/src/lib/components/files/FileGrid.svelte +++ b/apps/storage/apps/web/src/lib/components/files/FileGrid.svelte @@ -10,10 +10,18 @@ onFolderClick?: (folder: StorageFolder) => void; onFileAction?: (action: string, file: StorageFile) => void; onFolderAction?: (action: string, folder: StorageFolder) => void; + onMoveToFolder?: (itemType: 'file' | 'folder', itemId: string, targetFolderId: string) => void; } - let { files, folders, onFileClick, onFolderClick, onFileAction, onFolderAction }: Props = - $props(); + let { + files, + folders, + onFileClick, + onFolderClick, + onFileAction, + onFolderAction, + onMoveToFolder, + }: Props = $props();
@@ -22,6 +30,7 @@ {folder} onClick={() => onFolderClick?.(folder)} onAction={(action) => onFolderAction?.(action, folder)} + onDrop={(data) => onMoveToFolder?.(data.type, data.id, folder.id)} /> {/each} {#each files as file (file.id)} diff --git a/apps/storage/apps/web/src/lib/components/files/FolderCard.svelte b/apps/storage/apps/web/src/lib/components/files/FolderCard.svelte index caf9d7d1f..0989e2221 100644 --- a/apps/storage/apps/web/src/lib/components/files/FolderCard.svelte +++ b/apps/storage/apps/web/src/lib/components/files/FolderCard.svelte @@ -6,11 +6,40 @@ folder: StorageFolder; onClick?: () => void; onAction?: (action: string) => void; + onDrop?: (data: { type: 'file' | 'folder'; id: string }) => void; } - let { folder, onClick, onAction }: Props = $props(); + let { folder, onClick, onAction, onDrop }: Props = $props(); let showMenu = $state(false); + let isDragOver = $state(false); + + function handleDragOver(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + isDragOver = true; + } + + function handleDragLeave(e: DragEvent) { + e.preventDefault(); + isDragOver = false; + } + + function handleDrop(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + isDragOver = false; + const raw = e.dataTransfer?.getData('application/json'); + if (raw) { + try { + const data = JSON.parse(raw); + if (data.id !== folder.id) { + onDrop?.(data); + } + } catch {} + } + } function handleMenuClick(e: MouseEvent) { e.stopPropagation(); @@ -37,7 +66,21 @@ let folderColor = $derived(folder.color ? colorMap[folder.color] || folder.color : undefined); -
+
{ + e.dataTransfer?.setData('application/json', JSON.stringify({ type: 'folder', id: folder.id })); + e.dataTransfer!.effectAllowed = 'move'; + }} + ondragover={handleDragOver} + ondragleave={handleDragLeave} + ondrop={handleDrop} +>
{#if folder.isFavorite} @@ -94,6 +137,13 @@ box-shadow: var(--shadow-md); } + .folder-card.drag-over { + border-color: rgb(var(--color-success)); + border-style: dashed; + background: rgba(var(--color-success), 0.05); + box-shadow: var(--shadow-lg); + } + .folder-icon { position: relative; color: rgb(var(--color-primary)); diff --git a/apps/storage/apps/web/src/lib/stores/files.svelte.ts b/apps/storage/apps/web/src/lib/stores/files.svelte.ts index 43ddf40b3..903bd9361 100644 --- a/apps/storage/apps/web/src/lib/stores/files.svelte.ts +++ b/apps/storage/apps/web/src/lib/stores/files.svelte.ts @@ -155,6 +155,22 @@ export const filesStore = { return result; }, + async moveFile(id: string, targetFolderId: string) { + const result = await filesApi.move(id, targetFolderId); + if (!result.error) { + files = files.filter((f) => f.id !== id); + } + return result; + }, + + async moveFolder(id: string, targetFolderId: string) { + const result = await foldersApi.move(id, targetFolderId); + if (!result.error) { + folders = folders.filter((f) => f.id !== id); + } + return result; + }, + async downloadFile(id: string, filename: string) { const blob = await filesApi.download(id); if (blob) { diff --git a/apps/storage/apps/web/src/routes/files/+page.svelte b/apps/storage/apps/web/src/routes/files/+page.svelte index 66f4af780..d35d77eca 100644 --- a/apps/storage/apps/web/src/routes/files/+page.svelte +++ b/apps/storage/apps/web/src/routes/files/+page.svelte @@ -151,6 +151,28 @@ } } + async function handleMoveToFolder( + itemType: 'file' | 'folder', + itemId: string, + targetFolderId: string + ) { + if (itemType === 'file') { + const result = await filesStore.moveFile(itemId, targetFolderId); + if (result?.error) { + toastStore.error(result.error); + } else { + toastStore.success('Datei verschoben'); + } + } else { + const result = await filesStore.moveFolder(itemId, targetFolderId); + if (result?.error) { + toastStore.error(result.error); + } else { + toastStore.success('Ordner verschoben'); + } + } + } + function handleBreadcrumbNavigate(id: string | null) { if (id) { goto(`/files/${id}`); @@ -245,6 +267,7 @@ onFolderClick={handleFolderClick} onFileAction={handleFileAction} onFolderAction={handleFolderAction} + onMoveToFolder={handleMoveToFolder} /> {:else} {:else}