diff --git a/frontend/.gitignore b/frontend/.gitignore index 3f9bc1b0..5b5d80b6 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -31,3 +31,6 @@ src/lib/paraglide # src/lib/generated/api.ts src/lib/paraglide_bak* /coverage + +# Playwright auth state — regenerated at the start of each CI run via auth.setup.ts +e2e/.auth/ diff --git a/frontend/e2e/dashboard-classic-split.spec.ts b/frontend/e2e/dashboard-classic-split.spec.ts new file mode 100644 index 00000000..203a67a2 --- /dev/null +++ b/frontend/e2e/dashboard-classic-split.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from '@playwright/test'; +import { login } from './helpers/auth'; + +/** + * Classic Split layout — verifies the right column visibility guard. + * + * The right column (DropZone + NeedsMetadata queue) is only rendered when + * `canWrite === true` or there are incomplete docs. A read-only user with a + * complete archive must never see an empty 300px ghost column. + */ + +test.describe('Dashboard Classic Split — write user', () => { + test('right column is visible for admin user', async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('dashboard-right-column')).toBeVisible(); + }); +}); + +test.describe('Dashboard Classic Split — read-only user', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await login(page, 'reader', 'reader123'); + }); + + test('right column is absent for read-only user with no incomplete docs', async ({ page }) => { + await expect(page.getByTestId('dashboard-right-column')).not.toBeVisible(); + }); +}); diff --git a/frontend/e2e/password-reset.spec.ts b/frontend/e2e/password-reset.spec.ts index d2dd5366..fc864820 100644 --- a/frontend/e2e/password-reset.spec.ts +++ b/frontend/e2e/password-reset.spec.ts @@ -80,8 +80,7 @@ test.describe('Password reset', () => { await page.locator('input[name="currentPassword"]').fill(newPassword); await page.locator('input[name="newPassword"]').fill(originalPassword); await page.locator('input[name="confirmPassword"]').fill(originalPassword); - // Profile page has two "Speichern" buttons — the password form is the last one - await page.locator('button[type="submit"]').last().click(); + await page.getByTestId('submit-password').click(); // After changing password, auth_token is stale → redirect to login await expect(page).toHaveURL(/\/login/); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 5017ad9a..3024029b 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -375,6 +375,8 @@ "dashboard_needs_metadata_heading": "Metadaten fehlen", "dashboard_needs_metadata_show_all": "Alle anzeigen", "dashboard_recent_heading": "Zuletzt aktiv", + "dashboard_stats_documents": "Dokumente", + "dashboard_stats_persons": "Personen", "dashboard_resume_label": "Zuletzt geöffnet:", "dashboard_resume_fallback": "Unbekanntes Dokument", "doc_status_placeholder": "Platzhalter", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 6b3e157c..109e7060 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -375,6 +375,8 @@ "dashboard_needs_metadata_heading": "Missing Metadata", "dashboard_needs_metadata_show_all": "Show all", "dashboard_recent_heading": "Recent Activity", + "dashboard_stats_documents": "Documents", + "dashboard_stats_persons": "Persons", "dashboard_resume_label": "Last opened:", "dashboard_resume_fallback": "Unknown document", "doc_status_placeholder": "Placeholder", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 0b7a9e36..c9b445c6 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -375,6 +375,8 @@ "dashboard_needs_metadata_heading": "Metadatos incompletos", "dashboard_needs_metadata_show_all": "Ver todos", "dashboard_recent_heading": "Actividad reciente", + "dashboard_stats_documents": "Documentos", + "dashboard_stats_persons": "Personas", "dashboard_resume_label": "Último abierto:", "dashboard_resume_fallback": "Documento desconocido", "doc_status_placeholder": "Marcador", diff --git a/frontend/src/lib/components/DashboardMentions.svelte b/frontend/src/lib/components/DashboardMentions.svelte deleted file mode 100644 index 176d5fa3..00000000 --- a/frontend/src/lib/components/DashboardMentions.svelte +++ /dev/null @@ -1,57 +0,0 @@ - - -{#if mentions.length > 0} -
-

- {m.dashboard_notifications_heading()} -

-
- {#each mentions as mention (mention.id)} -
- {#if mention.documentId} - {mention.actorName ?? ''} - - {mention.type === 'MENTION' - ? m.dashboard_notification_mentioned() - : m.dashboard_notification_replied()} - - {:else} - {mention.actorName ?? ''} - {/if} -
- {/each} -
-
- {m.notification_history_view_link()} -
-
-{/if} diff --git a/frontend/src/lib/components/DashboardMentions.svelte.spec.ts b/frontend/src/lib/components/DashboardMentions.svelte.spec.ts deleted file mode 100644 index c0dd67df..00000000 --- a/frontend/src/lib/components/DashboardMentions.svelte.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; - -import DashboardMentions from './DashboardMentions.svelte'; - -afterEach(cleanup); - -type NotificationDTO = { - id: string; - type: 'REPLY' | 'MENTION'; - documentId?: string; - referenceId?: string; - annotationId?: string; - read: boolean; - createdAt: string; - actorName?: string; -}; - -function makeMention(overrides: Partial = {}): NotificationDTO { - return { - id: 'notif-1', - type: 'MENTION', - documentId: 'doc-abc', - referenceId: 'comment-xyz', - read: false, - createdAt: '2026-01-15T10:00:00Z', - actorName: 'Anna Schmidt', - ...overrides - }; -} - -describe('DashboardMentions', () => { - it('renders nothing when mentions list is empty', async () => { - render(DashboardMentions, { mentions: [] }); - const widget = page.getByTestId('dashboard-mentions'); - await expect.element(widget).not.toBeInTheDocument(); - }); - - it('shows a heading when mentions are present', async () => { - render(DashboardMentions, { mentions: [makeMention()] }); - const widget = page.getByTestId('dashboard-mentions'); - await expect.element(widget).toBeInTheDocument(); - }); - - it('builds link with commentId param when no annotationId', async () => { - render(DashboardMentions, { - mentions: [makeMention({ documentId: 'doc-1', referenceId: 'cmt-1' })] - }); - const link = page.getByRole('link'); - await expect.element(link).toHaveAttribute('href', '/documents/doc-1?commentId=cmt-1'); - }); - - it('builds link with commentId and annotationId when annotationId is present', async () => { - render(DashboardMentions, { - mentions: [makeMention({ documentId: 'doc-2', referenceId: 'cmt-2', annotationId: 'ann-9' })] - }); - const link = page.getByRole('link'); - await expect - .element(link) - .toHaveAttribute('href', '/documents/doc-2?commentId=cmt-2&annotationId=ann-9'); - }); - - it('shows actor name in each row', async () => { - render(DashboardMentions, { mentions: [makeMention({ actorName: 'Maria Müller' })] }); - await expect.element(page.getByText('Maria Müller')).toBeInTheDocument(); - }); - - it('shows "replied" label for REPLY type', async () => { - render(DashboardMentions, { mentions: [makeMention({ type: 'REPLY' })] }); - const widget = page.getByTestId('dashboard-mentions'); - await expect.element(widget).toBeInTheDocument(); - const link = page.getByRole('link'); - await expect.element(link).toBeInTheDocument(); - }); - - it('renders a span instead of a link when documentId is absent', async () => { - render(DashboardMentions, { - mentions: [makeMention({ documentId: undefined, actorName: 'Lena Bauer' })] - }); - await expect.element(page.getByText('Lena Bauer')).toBeInTheDocument(); - const links = page.getByRole('link'); - await expect.element(links).not.toBeInTheDocument(); - }); -}); diff --git a/frontend/src/lib/components/DashboardRecentDocuments.svelte b/frontend/src/lib/components/DashboardRecentDocuments.svelte index bea58716..bfe10e0f 100644 --- a/frontend/src/lib/components/DashboardRecentDocuments.svelte +++ b/frontend/src/lib/components/DashboardRecentDocuments.svelte @@ -1,6 +1,7 @@ @@ -94,20 +98,25 @@ $effect(() => { {#if data.isDashboard} - {#if data.canWrite} -
- -
- {/if} + + +
+ {#if showRightColumn} +
+ {#if data.canWrite} + + {/if} + +
+ +
+
+ {/if} -
- - -
-
- +
{:else} diff --git a/frontend/src/routes/admin/groups/layout.svelte.spec.ts b/frontend/src/routes/admin/groups/layout.svelte.spec.ts index 05c48a81..68a35cf5 100644 --- a/frontend/src/routes/admin/groups/layout.svelte.spec.ts +++ b/frontend/src/routes/admin/groups/layout.svelte.spec.ts @@ -109,10 +109,12 @@ describe('GroupsListPanel — collapse toggle', () => { }); it('persists collapse state using the groups-specific localStorage key', async () => { - const setSpy = vi.spyOn(Storage.prototype, 'setItem'); render(GroupsListPanel, { groups }); + const setSpy = vi.spyOn(Storage.prototype, 'setItem'); document.querySelector('[aria-label="Liste einklappen"]')!.click(); - expect(setSpy).toHaveBeenCalledWith('admin_groups_list_collapsed', 'true'); + await vi.waitFor(() => + expect(setSpy).toHaveBeenCalledWith('admin_groups_list_collapsed', 'true') + ); setSpy.mockRestore(); }); }); diff --git a/frontend/src/routes/admin/tags/layout.svelte.spec.ts b/frontend/src/routes/admin/tags/layout.svelte.spec.ts index 8bf7eb61..c3edbbad 100644 --- a/frontend/src/routes/admin/tags/layout.svelte.spec.ts +++ b/frontend/src/routes/admin/tags/layout.svelte.spec.ts @@ -88,10 +88,12 @@ describe('TagsListPanel — collapse toggle', () => { }); it('persists collapse state using the tags-specific localStorage key', async () => { - const setSpy = vi.spyOn(Storage.prototype, 'setItem'); render(TagsListPanel, { tags }); + const setSpy = vi.spyOn(Storage.prototype, 'setItem'); document.querySelector('[aria-label="Liste einklappen"]')!.click(); - expect(setSpy).toHaveBeenCalledWith('admin_tags_list_collapsed', 'true'); + await vi.waitFor(() => + expect(setSpy).toHaveBeenCalledWith('admin_tags_list_collapsed', 'true') + ); setSpy.mockRestore(); }); }); diff --git a/frontend/src/routes/admin/users/layout.svelte.spec.ts b/frontend/src/routes/admin/users/layout.svelte.spec.ts index 865ddbe6..84164697 100644 --- a/frontend/src/routes/admin/users/layout.svelte.spec.ts +++ b/frontend/src/routes/admin/users/layout.svelte.spec.ts @@ -131,10 +131,12 @@ describe('UsersListPanel — collapse toggle', () => { }); it('persists collapse state using the users-specific localStorage key', async () => { - const setSpy = vi.spyOn(Storage.prototype, 'setItem'); render(UsersListPanel, { users }); + const setSpy = vi.spyOn(Storage.prototype, 'setItem'); document.querySelector('[aria-label="Liste einklappen"]')!.click(); - expect(setSpy).toHaveBeenCalledWith('admin_users_list_collapsed', 'true'); + await vi.waitFor(() => + expect(setSpy).toHaveBeenCalledWith('admin_users_list_collapsed', 'true') + ); setSpy.mockRestore(); }); }); diff --git a/frontend/src/routes/conversations/page.svelte.spec.ts b/frontend/src/routes/conversations/page.svelte.spec.ts index 85f18e63..ebc7c4b3 100644 --- a/frontend/src/routes/conversations/page.svelte.spec.ts +++ b/frontend/src/routes/conversations/page.svelte.spec.ts @@ -48,9 +48,9 @@ const withDocs = { // ─── Empty state ────────────────────────────────────────────────────────────── describe('Conversations page – empty state', () => { - it('shows the "select two persons" prompt when no persons are selected', async () => { + it('shows the empty-state heading when no persons are selected', async () => { render(Page, { data: baseData }); - await expect.element(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeInTheDocument(); + await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument(); }); it('hides the swap button when no persons are selected', async () => { diff --git a/frontend/src/routes/login/page.svelte.spec.ts b/frontend/src/routes/login/page.svelte.spec.ts index 02b4d5e0..6cf420a8 100644 --- a/frontend/src/routes/login/page.svelte.spec.ts +++ b/frontend/src/routes/login/page.svelte.spec.ts @@ -10,7 +10,9 @@ afterEach(cleanup); describe('Login page – rendering', () => { it('renders the page title', async () => { render(LoginPage, {}); - await expect.element(page.getByRole('link', { name: 'Familienarchiv' })).toBeInTheDocument(); + await expect + .element(page.getByRole('link', { name: 'Familienarchiv' }).first()) + .toBeInTheDocument(); await page.screenshot({ path: 'test-results/screenshots/login-default.png' }); }); diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index 63d7abd6..24b2e0ad 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -22,14 +22,14 @@ function makeUrl(params: Record = {}) { // ─── dashboard mode (no search filters) ────────────────────────────────────── describe('home page load — dashboard mode', () => { - it('sets isDashboard true and fetches all three widget APIs', async () => { + it('sets isDashboard true and fetches stats, incomplete, and recent APIs', async () => { const mockGet = vi .fn() .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons .mockResolvedValueOnce({ response: { ok: true }, - data: { content: [{ id: 'n1' }] } - }) // notifications + data: { totalDocuments: 42, totalPersons: 7 } + }) // stats .mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1' }] }) // incomplete .mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd2' }] }); // recent vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< @@ -39,17 +39,20 @@ describe('home page load — dashboard mode', () => { const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); expect(result.isDashboard).toBe(true); - expect(result.mentions).toHaveLength(1); + expect(result.stats).toEqual({ totalDocuments: 42, totalPersons: 7 }); expect(result.incompleteDocs).toHaveLength(1); expect(result.recentDocs).toHaveLength(1); expect(result.documents).toEqual([]); }); - it('defaults mentions to [] when notifications API rejects', async () => { + it('returns stats with totalDocuments from /api/stats', async () => { const mockGet = vi .fn() .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons - .mockRejectedValueOnce(new Error('network')) // notifications + .mockResolvedValueOnce({ + response: { ok: true }, + data: { totalDocuments: 248, totalPersons: 34 } + }) // stats .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< @@ -58,7 +61,24 @@ describe('home page load — dashboard mode', () => { const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); - expect(result.mentions).toEqual([]); + expect(result.stats?.totalDocuments).toBe(248); + expect(result.stats?.totalPersons).toBe(34); + }); + + it('returns stats: null when /api/stats rejects', async () => { + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons + .mockRejectedValueOnce(new Error('network')) // stats + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete + .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + + expect(result.stats).toBeNull(); }); it('defaults incompleteDocs to [] when incomplete API rejects', async () => { @@ -81,7 +101,10 @@ describe('home page load — dashboard mode', () => { const mockGet = vi .fn() .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons - .mockResolvedValueOnce({ response: { ok: true }, data: { content: [] } }) // notifications + .mockResolvedValueOnce({ + response: { ok: true }, + data: { totalDocuments: 0, totalPersons: 0 } + }) // stats .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete .mockRejectedValueOnce(new Error('network')); // recent vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< @@ -113,7 +136,7 @@ describe('home page load — search mode', () => { expect(result.isDashboard).toBe(false); expect(result.documents).toHaveLength(1); - expect(result.mentions).toEqual([]); + expect(result.stats).toBeNull(); expect(result.incompleteDocs).toEqual([]); expect(result.recentDocs).toEqual([]); // Only two API calls — no widget calls diff --git a/frontend/src/routes/page.svelte.spec.ts b/frontend/src/routes/page.svelte.spec.ts index 09956fc7..8a280c1a 100644 --- a/frontend/src/routes/page.svelte.spec.ts +++ b/frontend/src/routes/page.svelte.spec.ts @@ -21,8 +21,12 @@ const emptyData = { user: undefined, canWrite: true, canAnnotate: false, + isDashboard: false, filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] }, documents: [], + incompleteDocs: [], + recentDocs: [], + stats: null, incompleteCount: 0, initialValues: { senderName: '', receiverName: '' }, error: null @@ -189,6 +193,42 @@ describe('Home page – search input keystroke preservation', () => { }); }); +// ─── Dashboard mode ─────────────────────────────────────────────────────────── + +describe('Home page – dashboard mode', () => { + const dashboardData = { + ...emptyData, + isDashboard: true, + canWrite: false, + incompleteDocs: [], + recentDocs: [] + }; + + it('hides the right column when canWrite is false and incompleteDocs is empty', async () => { + render(Page, { data: dashboardData }); + const rightCol = page.getByTestId('dashboard-right-column'); + await expect.element(rightCol).not.toBeInTheDocument(); + }); + + it('shows the right column when canWrite is true', async () => { + render(Page, { data: { ...dashboardData, canWrite: true } }); + const rightCol = page.getByTestId('dashboard-right-column'); + await expect.element(rightCol).toBeInTheDocument(); + }); + + it('shows the right column when incompleteDocs is non-empty', async () => { + render(Page, { + data: { + ...dashboardData, + canWrite: false, + incompleteDocs: [{ id: 'd1', title: 'Taufschein' }] + } + }); + const rightCol = page.getByTestId('dashboard-right-column'); + await expect.element(rightCol).toBeInTheDocument(); + }); +}); + // ─── Error state ────────────────────────────────────────────────────────────── describe('Home page – error state', () => { diff --git a/frontend/src/routes/persons/page.svelte.spec.ts b/frontend/src/routes/persons/page.svelte.spec.ts index 9fbb8f7f..113df55d 100644 --- a/frontend/src/routes/persons/page.svelte.spec.ts +++ b/frontend/src/routes/persons/page.svelte.spec.ts @@ -64,7 +64,7 @@ describe('Persons page – rendering', () => { it('shows alias in italic when provided', async () => { render(Page, { data: { ...emptyData, persons: [makePerson({ alias: 'Maxi' })] } }); - await expect.element(page.getByText('"Maxi"')).toBeInTheDocument(); + await expect.element(page.getByText('„Maxi"')).toBeInTheDocument(); }); it('shows life date range when birthYear is provided', async () => { diff --git a/frontend/src/routes/profile/PasswordChangeForm.svelte b/frontend/src/routes/profile/PasswordChangeForm.svelte index 32145da3..6c7e0b42 100644 --- a/frontend/src/routes/profile/PasswordChangeForm.svelte +++ b/frontend/src/routes/profile/PasswordChangeForm.svelte @@ -70,6 +70,7 @@ let {