From ca212e871f1409e94efd8a6fc1d277663e78ef4e Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 20 Mar 2026 16:17:07 +0100 Subject: [PATCH 01/11] feat(conversations): add swap button (#32) Adds a button between the two person typeaheads that swaps sender and receiver, then reloads the conversation view. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + .../src/routes/conversations/+page.svelte | 28 +++++++++++++++++++ 4 files changed, 31 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 1c2d4843..5f0ad724 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -140,6 +140,7 @@ "conv_empty_text": "Die Korrespondenz wird hier angezeigt.", "conv_no_results_heading": "Keine Dokumente gefunden.", "conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.", + "conv_swap_btn": "Personen tauschen", "admin_heading": "Admin Dashboard", "admin_tab_users": "Benutzer", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 1a340e11..556543c2 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -140,6 +140,7 @@ "conv_empty_text": "The correspondence will be shown here.", "conv_no_results_heading": "No documents found.", "conv_no_results_text": "Try adjusting the time period.", + "conv_swap_btn": "Swap persons", "admin_heading": "Admin Dashboard", "admin_tab_users": "Users", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 2ca24a72..a0d36bfc 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -140,6 +140,7 @@ "conv_empty_text": "La correspondencia se mostrará aquí.", "conv_no_results_heading": "No se encontraron documentos.", "conv_no_results_text": "Intente ajustar el período de tiempo.", + "conv_swap_btn": "Intercambiar personas", "admin_heading": "Panel de administración", "admin_tab_users": "Usuarios", diff --git a/frontend/src/routes/conversations/+page.svelte b/frontend/src/routes/conversations/+page.svelte index 854b45c4..9e15379c 100644 --- a/frontend/src/routes/conversations/+page.svelte +++ b/frontend/src/routes/conversations/+page.svelte @@ -37,6 +37,13 @@ function toggleSort() { sortDir = sortDir === 'DESC' ? 'ASC' : 'DESC'; applyFilters(); } + +function swapPersons() { + const tmp = senderId; + senderId = receiverId; + receiverId = tmp; + applyFilters(); +}
@@ -80,6 +87,27 @@ function toggleSort() {
+ {#if senderId && receiverId} +
+ +
+ {/if} +
-- 2.49.1 From 0a1075e03fbc01dfb984fb964ed1a45788cc6953 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 20 Mar 2026 16:18:48 +0100 Subject: [PATCH 02/11] feat(conversations): add summary with document count and year range (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows a summary line above the conversation listing with total document count and the year span, e.g. "4 Dokumente · 1923–1965". Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + .../src/routes/conversations/+page.svelte | 21 +++++++++++++++++++ 4 files changed, 24 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 5f0ad724..3999cf27 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -141,6 +141,7 @@ "conv_no_results_heading": "Keine Dokumente gefunden.", "conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.", "conv_swap_btn": "Personen tauschen", + "conv_summary": "{count} Dokumente · {yearFrom}–{yearTo}", "admin_heading": "Admin Dashboard", "admin_tab_users": "Benutzer", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 556543c2..2aa6bfb2 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -141,6 +141,7 @@ "conv_no_results_heading": "No documents found.", "conv_no_results_text": "Try adjusting the time period.", "conv_swap_btn": "Swap persons", + "conv_summary": "{count} documents · {yearFrom}–{yearTo}", "admin_heading": "Admin Dashboard", "admin_tab_users": "Users", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index a0d36bfc..f60be0d2 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -141,6 +141,7 @@ "conv_no_results_heading": "No se encontraron documentos.", "conv_no_results_text": "Intente ajustar el período de tiempo.", "conv_swap_btn": "Intercambiar personas", + "conv_summary": "{count} documentos · {yearFrom}–{yearTo}", "admin_heading": "Panel de administración", "admin_tab_users": "Usuarios", diff --git a/frontend/src/routes/conversations/+page.svelte b/frontend/src/routes/conversations/+page.svelte index 9e15379c..64086b2e 100644 --- a/frontend/src/routes/conversations/+page.svelte +++ b/frontend/src/routes/conversations/+page.svelte @@ -14,6 +14,14 @@ let fromDate = $state(untrack(() => data.filters.from)); let toDate = $state(untrack(() => data.filters.to)); let sortDir = $state(untrack(() => data.filters.dir)); +const documentYears = $derived( + data.documents + .map((doc) => (doc.documentDate ? new Date(doc.documentDate).getFullYear() : null)) + .filter((y): y is number => y !== null) +); +const yearFrom = $derived(documentYears.length > 0 ? Math.min(...documentYears) : null); +const yearTo = $derived(documentYears.length > 0 ? Math.max(...documentYears) : null); + // Sync with server data after navigation $effect(() => { senderId = data.filters.senderId; @@ -191,6 +199,19 @@ function swapPersons() {

{m.conv_no_results_text()}

{:else} + +
+ {#if yearFrom !== null && yearTo !== null} +

+ {m.conv_summary({ count: data.documents.length, yearFrom, yearTo })} +

+ {:else} +

+ {data.documents.length} +

+ {/if} +
+
-- 2.49.1 From 1ab063486cdc620708ae03dbbe7f3355ddc3c437 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 20 Mar 2026 16:20:36 +0100 Subject: [PATCH 03/11] feat(conversations): add year dividers between documents (#30) Renders a horizontal rule with the year label between consecutive documents that belong to different years. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/conversations/+page.svelte | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/conversations/+page.svelte b/frontend/src/routes/conversations/+page.svelte index 64086b2e..3c893a1b 100644 --- a/frontend/src/routes/conversations/+page.svelte +++ b/frontend/src/routes/conversations/+page.svelte @@ -52,6 +52,17 @@ function swapPersons() { receiverId = tmp; applyFilters(); } + +const enrichedDocuments = $derived( + data.documents.map((doc, i) => { + const year = doc.documentDate ? new Date(doc.documentDate).getFullYear() : null; + const prevYear = + i > 0 && data.documents[i - 1].documentDate + ? new Date(data.documents[i - 1].documentDate!).getFullYear() + : null; + return { doc, year, showYearDivider: year !== null && year !== prevYear }; + }) +);
@@ -221,7 +232,17 @@ function swapPersons() {
- {#each data.documents as doc (doc.id)} + {#each enrichedDocuments as { doc, year, showYearDivider } (doc.id)} + {#if showYearDivider} +
+
+ {year} +
+
+ {/if} {@const isRight = doc.sender?.id === senderId} -- 2.49.1 From 65a8048e2521a7c0760c2485444692109098a95f Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 20 Mar 2026 16:22:25 +0100 Subject: [PATCH 04/11] feat(conversations): add new document link pre-filled with both persons (#33) Adds a link next to the summary that navigates to the new-document form with senderId and receiverId pre-filled from the current conversation. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + frontend/src/routes/conversations/+page.svelte | 13 ++++++++++++- 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 3999cf27..52a3f977 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -142,6 +142,7 @@ "conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.", "conv_swap_btn": "Personen tauschen", "conv_summary": "{count} Dokumente · {yearFrom}–{yearTo}", + "conv_new_doc_link": "Neues Dokument in dieser Korrespondenz", "admin_heading": "Admin Dashboard", "admin_tab_users": "Benutzer", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2aa6bfb2..75fa54f9 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -142,6 +142,7 @@ "conv_no_results_text": "Try adjusting the time period.", "conv_swap_btn": "Swap persons", "conv_summary": "{count} documents · {yearFrom}–{yearTo}", + "conv_new_doc_link": "New document in this correspondence", "admin_heading": "Admin Dashboard", "admin_tab_users": "Users", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index f60be0d2..58be11ed 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -142,6 +142,7 @@ "conv_no_results_text": "Intente ajustar el período de tiempo.", "conv_swap_btn": "Intercambiar personas", "conv_summary": "{count} documentos · {yearFrom}–{yearTo}", + "conv_new_doc_link": "Nuevo documento en esta correspondencia", "admin_heading": "Panel de administración", "admin_tab_users": "Usuarios", diff --git a/frontend/src/routes/conversations/+page.svelte b/frontend/src/routes/conversations/+page.svelte index 3c893a1b..787a44f5 100644 --- a/frontend/src/routes/conversations/+page.svelte +++ b/frontend/src/routes/conversations/+page.svelte @@ -211,7 +211,7 @@ const enrichedDocuments = $derived(
{:else} -
+
{#if yearFrom !== null && yearTo !== null}

{m.conv_summary({ count: data.documents.length, yearFrom, yearTo })} @@ -221,6 +221,17 @@ const enrichedDocuments = $derived( {data.documents.length}

{/if} + + + + + {m.conv_new_doc_link()} +
-- 2.49.1 From aa127de9bd01dc8516359f20da9ce2a3651b0c26 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 20 Mar 2026 16:29:11 +0100 Subject: [PATCH 05/11] refactor(conversations): move swap button between person input fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On desktop the button sits between the two typeaheads as an icon-only button (icon rotated 90° to point left/right) aligned to the input baseline. On mobile it renders full-width with the label text between the stacked fields. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/conversations/+page.svelte | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/frontend/src/routes/conversations/+page.svelte b/frontend/src/routes/conversations/+page.svelte index 787a44f5..436667ec 100644 --- a/frontend/src/routes/conversations/+page.svelte +++ b/frontend/src/routes/conversations/+page.svelte @@ -76,7 +76,7 @@ const enrichedDocuments = $derived(
-
+
+ +
+ +
+
- {#if senderId && receiverId} -
- -
- {/if} -
-- 2.49.1 From e2874528cd02c2b6b8b4dc374af417bb8901bf38 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 20 Mar 2026 16:31:21 +0100 Subject: [PATCH 06/11] fix(conversations): hide new document link for read-only users The link navigates to a page that requires WRITE_ALL. Guard it with data.canWrite (supplied by the layout) so read-only users never see a link that leads to a 403. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/conversations/+page.svelte | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/frontend/src/routes/conversations/+page.svelte b/frontend/src/routes/conversations/+page.svelte index 436667ec..58fd946f 100644 --- a/frontend/src/routes/conversations/+page.svelte +++ b/frontend/src/routes/conversations/+page.svelte @@ -225,17 +225,19 @@ const enrichedDocuments = $derived( {data.documents.length}

{/if} - - - - - {m.conv_new_doc_link()} - + {#if data.canWrite} + + + + + {m.conv_new_doc_link()} + + {/if}
-- 2.49.1 From 76031de8eb40ac40ba3febb578eaa93cfa267bac Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 20 Mar 2026 16:49:17 +0100 Subject: [PATCH 07/11] fix(conversations): restore {#if} guard on swap button The guard was lost when the button was moved into the grid between the two person inputs. Without it the button rendered even when no persons were selected, breaking the UX and the E2E assertion. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/conversations/+page.svelte | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/frontend/src/routes/conversations/+page.svelte b/frontend/src/routes/conversations/+page.svelte index 58fd946f..9cb882f0 100644 --- a/frontend/src/routes/conversations/+page.svelte +++ b/frontend/src/routes/conversations/+page.svelte @@ -92,29 +92,31 @@ const enrichedDocuments = $derived(
-
- -
+ + + + {m.conv_swap_btn()} + +
+ {/if}
Date: Fri, 20 Mar 2026 16:49:52 +0100 Subject: [PATCH 08/11] test(conversations): add component tests for new features Covers: empty state, swap button (visible/hidden, goto called with swapped params), summary content, year dividers, and new document link visibility gated by canWrite. Co-Authored-By: Claude Sonnet 4.6 --- .../routes/conversations/page.svelte.spec.ts | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 frontend/src/routes/conversations/page.svelte.spec.ts diff --git a/frontend/src/routes/conversations/page.svelte.spec.ts b/frontend/src/routes/conversations/page.svelte.spec.ts new file mode 100644 index 00000000..906a1dc4 --- /dev/null +++ b/frontend/src/routes/conversations/page.svelte.spec.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import Page from './+page.svelte'; + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +afterEach(cleanup); + +// ─── Test data ──────────────────────────────────────────────────────────────── + +const baseData = { + canWrite: true, + documents: [], + initialValues: { senderName: '', receiverName: '' }, + filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const } +}; + +const withPersons = { + ...baseData, + filters: { ...baseData.filters, senderId: 'p1', receiverId: 'p2' } +}; + +const makeDoc = (overrides: Record = {}) => ({ + id: 'd1', + title: 'Testbrief', + originalFilename: 'testbrief.pdf', + status: 'UPLOADED' as const, + documentDate: '1923-04-12', + location: 'Berlin', + sender: { id: 'p1', firstName: 'Hans', lastName: 'Müller' }, + receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }], + tags: [], + transcription: null, + filePath: null, + createdAt: '1923-04-12T00:00:00Z', + updatedAt: '1923-04-12T00:00:00Z', + ...overrides +}); + +const withDocs = { + ...withPersons, + documents: [makeDoc()] +}; + +// ─── Empty state ────────────────────────────────────────────────────────────── + +describe('Conversations page – empty state', () => { + it('shows the "select two persons" prompt when no persons are selected', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeInTheDocument(); + }); + + it('does not show the swap button when no persons are selected', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByTestId('conv-swap-btn')).not.toBeInTheDocument(); + }); + + it('does not show the new document link when no persons are selected', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument(); + }); +}); + +// ─── No results ─────────────────────────────────────────────────────────────── + +describe('Conversations page – no results', () => { + it('shows "no documents found" when both persons are selected but there are no documents', async () => { + render(Page, { data: withPersons }); + await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument(); + }); +}); + +// ─── Swap button ────────────────────────────────────────────────────────────── + +describe('Conversations page – swap button', () => { + it('shows the swap button when both persons are selected', async () => { + render(Page, { data: withPersons }); + await expect.element(page.getByTestId('conv-swap-btn')).toBeInTheDocument(); + }); + + it('calls goto with swapped sender and receiver when clicked', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + render(Page, { data: withPersons }); + await page.getByTestId('conv-swap-btn').click(); + expect(goto).toHaveBeenCalledWith(expect.stringContaining('senderId=p2'), expect.anything()); + expect(goto).toHaveBeenCalledWith(expect.stringContaining('receiverId=p1'), expect.anything()); + }); +}); + +// ─── Summary ────────────────────────────────────────────────────────────────── + +describe('Conversations page – summary', () => { + it('shows document count and year range when documents are loaded', async () => { + const data = { + ...withPersons, + documents: [ + makeDoc({ documentDate: '1923-04-12' }), + makeDoc({ id: 'd2', documentDate: '1965-08-03' }) + ] + }; + render(Page, { data }); + const summary = page.getByTestId('conv-summary'); + await expect.element(summary).toHaveTextContent('2'); + await expect.element(summary).toHaveTextContent('1923'); + await expect.element(summary).toHaveTextContent('1965'); + }); +}); + +// ─── Year dividers ──────────────────────────────────────────────────────────── + +describe('Conversations page – year dividers', () => { + it('renders a year divider for the first document', async () => { + render(Page, { data: withDocs }); + await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923'); + }); + + it('renders a divider for each new year in the document list', async () => { + const data = { + ...withPersons, + documents: [ + makeDoc({ documentDate: '1923-04-12' }), + makeDoc({ id: 'd2', documentDate: '1965-08-03' }) + ] + }; + render(Page, { data }); + await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923'); + await expect.element(page.getByTestId('year-divider').nth(1)).toHaveTextContent('1965'); + }); + + it('does not render a second divider for documents from the same year', async () => { + const data = { + ...withPersons, + documents: [ + makeDoc({ documentDate: '1923-04-12' }), + makeDoc({ id: 'd2', documentDate: '1923-09-01' }) + ] + }; + render(Page, { data }); + // Only one divider for 1923; 1965 divider should not appear + await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923'); + await expect.element(page.getByTestId('year-divider').nth(1)).not.toBeInTheDocument(); + }); +}); + +// ─── New document link ──────────────────────────────────────────────────────── + +describe('Conversations page – new document link', () => { + it('shows the link with correct href for a write user', async () => { + render(Page, { data: { ...withDocs, canWrite: true } }); + const link = page.getByTestId('conv-new-doc-link'); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute('href', '/documents/new?senderId=p1&receiverId=p2'); + }); + + it('hides the link for a read-only user', async () => { + render(Page, { data: { ...withDocs, canWrite: false } }); + await expect.element(page.getByTestId('conv-new-doc-link')).not.toBeInTheDocument(); + }); +}); -- 2.49.1 From 4026bb90037268f6ef7c637f817fb62a55c04a7a Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 20 Mar 2026 17:01:24 +0100 Subject: [PATCH 09/11] feat(documents): prefill sender and receiver from URL params on new document page When navigating from the conversations page via the 'New document in this correspondence' link, the senderId and receiverId query params are now read in the server load, resolved to person names, and used to pre-populate the sender typeahead and receiver multi-select on the form. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/documents/new/+page.server.ts | 41 ++++++++++++++++--- .../src/routes/documents/new/+page.svelte | 15 +++++-- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/frontend/src/routes/documents/new/+page.server.ts b/frontend/src/routes/documents/new/+page.server.ts index 4cfe77e7..6ef92f14 100644 --- a/frontend/src/routes/documents/new/+page.server.ts +++ b/frontend/src/routes/documents/new/+page.server.ts @@ -5,10 +5,12 @@ import { parseBackendError, getErrorMessage } from '$lib/errors'; export async function load({ fetch, - locals + locals, + url }: { fetch: typeof globalThis.fetch; locals: App.Locals; + url: URL; }) { const canWrite = locals.user?.groups?.some((g: { permissions: string[] }) => @@ -16,14 +18,41 @@ export async function load({ ) ?? false; if (!canWrite) throw error(403, 'Forbidden'); - const api = createApiClient(fetch); - const personsResult = await api.GET('/api/persons'); + const senderId = url.searchParams.get('senderId') || ''; + const receiverId = url.searchParams.get('receiverId') || ''; - if (!personsResult.response.ok) { - return { persons: [] }; + const api = createApiClient(fetch); + + let initialSenderName = ''; + let initialReceivers: { id: string; firstName: string; lastName: string }[] = []; + + const requests: Promise[] = []; + + if (senderId) { + requests.push( + api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => { + if (data) initialSenderName = `${data.firstName} ${data.lastName}`; + }) + ); } - return { persons: personsResult.data }; + if (receiverId) { + requests.push( + api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => { + if (data) + initialReceivers = [{ id: data.id!, firstName: data.firstName, lastName: data.lastName }]; + }) + ); + } + + const [personsResult] = await Promise.all([api.GET('/api/persons'), ...requests]); + + return { + persons: personsResult.response.ok ? personsResult.data : [], + initialSenderId: senderId, + initialSenderName, + initialReceivers + }; } export const actions = { diff --git a/frontend/src/routes/documents/new/+page.svelte b/frontend/src/routes/documents/new/+page.svelte index e1b1d2d4..3727283b 100644 --- a/frontend/src/routes/documents/new/+page.svelte +++ b/frontend/src/routes/documents/new/+page.svelte @@ -5,11 +5,13 @@ import PersonTypeahead from '$lib/components/PersonTypeahead.svelte'; import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte'; import { m } from '$lib/paraglide/messages.js'; -let { form } = $props(); +let { data, form } = $props(); let tags: string[] = $state([]); -let senderId = $state(''); -let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state([]); +let senderId = $state(data.initialSenderId); +let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state( + data.initialReceivers +); let dateDisplay = $state(''); let dateIso = $state(''); @@ -120,7 +122,12 @@ function handleDateInput(e: Event) {
- +
-- 2.49.1 From 0f8b5828137e2d36e187e332d7f0675986de568c Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 20 Mar 2026 17:23:06 +0100 Subject: [PATCH 10/11] test(documents): add component tests for sender/receiver URL prefill on new document page Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/documents/new/+page.svelte | 5 +- .../routes/documents/new/page.svelte.spec.ts | 71 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 frontend/src/routes/documents/new/page.svelte.spec.ts diff --git a/frontend/src/routes/documents/new/+page.svelte b/frontend/src/routes/documents/new/+page.svelte index 3727283b..f6808de6 100644 --- a/frontend/src/routes/documents/new/+page.svelte +++ b/frontend/src/routes/documents/new/+page.svelte @@ -3,14 +3,15 @@ import { enhance } from '$app/forms'; import TagInput from '$lib/components/TagInput.svelte'; import PersonTypeahead from '$lib/components/PersonTypeahead.svelte'; import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte'; +import { untrack } from 'svelte'; import { m } from '$lib/paraglide/messages.js'; let { data, form } = $props(); let tags: string[] = $state([]); -let senderId = $state(data.initialSenderId); +let senderId = $state(untrack(() => data.initialSenderId)); let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state( - data.initialReceivers + untrack(() => data.initialReceivers) ); let dateDisplay = $state(''); diff --git a/frontend/src/routes/documents/new/page.svelte.spec.ts b/frontend/src/routes/documents/new/page.svelte.spec.ts new file mode 100644 index 00000000..7968fe30 --- /dev/null +++ b/frontend/src/routes/documents/new/page.svelte.spec.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import Page from './+page.svelte'; + +afterEach(cleanup); + +// ─── Test data ──────────────────────────────────────────────────────────────── + +const baseData = { + persons: [], + initialSenderId: '', + initialSenderName: '', + initialReceivers: [] +}; + +// ─── Prefill – sender ───────────────────────────────────────────────────────── + +describe('New document page – sender prefill', () => { + it('shows an empty sender input when no senderId is in the URL', async () => { + render(Page, { data: baseData }); + const input = document.querySelector('#senderId-search'); + expect(input?.value).toBe(''); + }); + + it('shows the sender name in the typeahead input when initialSenderName is set', async () => { + render(Page, { + data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' } + }); + const input = document.querySelector('#senderId-search'); + expect(input?.value).toBe('Hans Müller'); + }); + + it('sets the hidden senderId input to the prefilled ID', async () => { + render(Page, { + data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' } + }); + const hidden = document.querySelector( + 'input[type="hidden"][name="senderId"]' + ); + expect(hidden?.value).toBe('p1'); + }); +}); + +// ─── Prefill – receiver ─────────────────────────────────────────────────────── + +describe('New document page – receiver prefill', () => { + it('shows no receiver chips when initialReceivers is empty', async () => { + render(Page, { data: baseData }); + await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument(); + }); + + it('shows a receiver chip when initialReceivers has a person', async () => { + const data = { + ...baseData, + initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }] + }; + render(Page, { data }); + await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument(); + }); + + it('renders a hidden receiverIds input for the prefilled receiver', async () => { + const data = { + ...baseData, + initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }] + }; + render(Page, { data }); + const hidden = document.querySelector('input[name="receiverIds"]'); + expect(hidden?.value).toBe('p2'); + }); +}); -- 2.49.1 From 513a7290b0f011a77ba200d53d8fd21613887714 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 20 Mar 2026 18:32:37 +0100 Subject: [PATCH 11/11] fix(conversations): keep swap button in DOM to prevent grid column width shift The swap button was conditionally removed from the DOM with {#if}, which caused the receiver input to collapse into the narrow auto column of the grid-cols-[1fr_auto_1fr] layout on desktop when no persons were selected. The button is now always rendered. On desktop it becomes invisible (visibility:hidden) when no persons are selected, preserving the middle column width so both 1fr columns stay equal. On mobile it remains hidden (display:none) via the hidden class so no empty gap appears between the stacked inputs. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/conversations/+page.svelte | 53 ++++++++++--------- .../routes/conversations/page.svelte.spec.ts | 7 +-- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/frontend/src/routes/conversations/+page.svelte b/frontend/src/routes/conversations/+page.svelte index 9cb882f0..8467a3b2 100644 --- a/frontend/src/routes/conversations/+page.svelte +++ b/frontend/src/routes/conversations/+page.svelte @@ -91,32 +91,35 @@ const enrichedDocuments = $derived( />
- - {#if senderId && receiverId} -
- -
- {/if} + + + {m.conv_swap_btn()} + +
{ await expect.element(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeInTheDocument(); }); - it('does not show the swap button when no persons are selected', async () => { + it('hides the swap button when no persons are selected', async () => { render(Page, { data: baseData }); - await expect.element(page.getByTestId('conv-swap-btn')).not.toBeInTheDocument(); + // Button is always in the DOM (holds grid column width on desktop) but made invisible + await expect.element(page.getByTestId('conv-swap-btn')).toHaveClass('invisible'); }); it('does not show the new document link when no persons are selected', async () => { @@ -76,7 +77,7 @@ describe('Conversations page – no results', () => { describe('Conversations page – swap button', () => { it('shows the swap button when both persons are selected', async () => { render(Page, { data: withPersons }); - await expect.element(page.getByTestId('conv-swap-btn')).toBeInTheDocument(); + await expect.element(page.getByTestId('conv-swap-btn')).not.toHaveClass('invisible'); }); it('calls goto with swapped sender and receiver when clicked', async () => { -- 2.49.1