From 065dd8fabdbd2942dcacc2570d4d645ba301b8ae Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 22:32:58 +0100 Subject: [PATCH 1/4] fix(e2e): fix two flaky annotation tests Test 6 (delete annotation): the mouse-draw test can create multiple annotations in CI. Changed the assertion to `countBefore - 1` instead of a hard-coded 0, so the test is resilient to any pre-existing count. Test 7 (hash versioning): `[data-testid^="annotation-"]` matched both real annotation elements AND `annotation-outdated-notice` (which also starts with "annotation-"), inflating the count to 2 instead of 0. Added `:not([data-testid="annotation-outdated-notice"])` to exclude the notice from the count assertion. Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/documents.spec.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/e2e/documents.spec.ts b/frontend/e2e/documents.spec.ts index 8ddac2b1..20c9a976 100644 --- a/frontend/e2e/documents.spec.ts +++ b/frontend/e2e/documents.spec.ts @@ -327,10 +327,12 @@ test.describe('PDF annotations — admin', () => { await page.waitForSelector('[data-hydrated]'); await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 }); - // Ensure annotation is visible before enabling annotate mode + // Ensure at least one annotation is visible before enabling annotate mode await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({ timeout: 8000 }); + // Record count now — the draw test may have created more than one annotation + const countBefore = await page.locator('[data-testid^="annotation-"]').count(); // Enable annotate mode to show delete buttons await page.getByRole('button', { name: /^annotieren$/i }).click(); @@ -339,7 +341,7 @@ test.describe('PDF annotations — admin', () => { await expect(deleteBtn).toBeVisible({ timeout: 8000 }); await deleteBtn.click(); - await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, { + await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(countBefore - 1, { timeout: 8000 }); @@ -407,7 +409,10 @@ test.describe('PDF annotations — file hash versioning', () => { await page.waitForSelector('[data-hydrated]'); await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 }); - await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, { timeout: 8000 }); + // Use :not() to exclude the outdated-notice element whose testid also starts with "annotation-" + await expect( + page.locator('[data-testid^="annotation-"]:not([data-testid="annotation-outdated-notice"])') + ).toHaveCount(0, { timeout: 8000 }); await expect(page.locator('[data-testid="annotation-outdated-notice"]')).toBeVisible({ timeout: 5000 }); -- 2.49.1 From d6f4ea05d97dca4d8edf1ba2cee7c8e544a2b4f7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 22:51:24 +0100 Subject: [PATCH 2/4] feat(#68): fall back to filename as title when createDocument gets no title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a document is created without an explicit title (null or blank), the service now derives the title from the uploaded filename using the same titleFromFilename() logic already used by storeDocument — stripping the extension for plain names and formatting structured names as "Firstname Lastname (DD.MM.YYYY)". Co-Authored-By: Claude Sonnet 4.6 --- .../service/DocumentService.java | 6 +- .../service/DocumentServiceTest.java | 56 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index bb379194..4b332b51 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -108,9 +108,13 @@ public class DocumentService { || (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()); } + String titleToUse = (dto.getTitle() != null && !dto.getTitle().isBlank()) + ? dto.getTitle() + : titleFromFilename(filename); + Document doc = Document.builder() .originalFilename(filename) - .title(dto.getTitle()) + .title(titleToUse) .documentDate(dto.getDocumentDate()) .location(dto.getLocation()) .documentLocation(dto.getDocumentLocation()) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index c4a1c6d2..c02fb358 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -467,6 +467,62 @@ class DocumentServiceTest { assertThat(captor.getValue().getSender()).isNull(); } + // ─── createDocument title fallback ──────────────────────────────────────── + + @Test + void createDocument_usesTitleFromFilename_whenDtoTitleIsNull() throws Exception { + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + // dto.title is null + MockMultipartFile file = new MockMultipartFile("file", "Brief_1965.pdf", "application/pdf", new byte[]{1}); + Document saved = Document.builder().id(UUID.randomUUID()).title("Brief_1965") + .originalFilename("Brief_1965.pdf").status(DocumentStatus.PLACEHOLDER).build(); + when(documentRepository.save(any())).thenReturn(saved); + when(documentRepository.findById(any())).thenReturn(Optional.of(saved)); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + documentService.createDocument(dto, file); + + verify(documentRepository, atLeastOnce()).save(captor.capture()); + assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Brief_1965"); + } + + @Test + void createDocument_usesTitleFromFilename_whenDtoTitleIsBlank() throws Exception { + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setTitle(" "); + MockMultipartFile file = new MockMultipartFile("file", "Rechnung_1980.pdf", "application/pdf", new byte[]{1}); + Document saved = Document.builder().id(UUID.randomUUID()).title("Rechnung_1980") + .originalFilename("Rechnung_1980.pdf").status(DocumentStatus.PLACEHOLDER).build(); + when(documentRepository.save(any())).thenReturn(saved); + when(documentRepository.findById(any())).thenReturn(Optional.of(saved)); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + documentService.createDocument(dto, file); + + verify(documentRepository, atLeastOnce()).save(captor.capture()); + assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Rechnung_1980"); + } + + @Test + void createDocument_keepsDtoTitle_whenProvided() throws Exception { + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setTitle("Mein Titel"); + MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1}); + Document saved = Document.builder().id(UUID.randomUUID()).title("Mein Titel") + .originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build(); + when(documentRepository.save(any())).thenReturn(saved); + when(documentRepository.findById(any())).thenReturn(Optional.of(saved)); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + documentService.createDocument(dto, file); + + verify(documentRepository, atLeastOnce()).save(captor.capture()); + assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Mein Titel"); + } + // ─── createDocument metadataComplete ───────────────────────────────────── @Test -- 2.49.1 From c5e28ac18e69372838d09f762e49a77d86a4e8fc Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 22:52:12 +0100 Subject: [PATCH 3/4] feat(#68): lead new document form with file upload, all metadata optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the "New Document" page so users can save quickly: - FileSectionNew becomes the first element, redesigned as a prominent upload zone with an icon and large click target - Title field is rendered standalone below the upload zone; it auto-populates from the filename (via parseFilename + stripExtension fallback) unless the user has already typed something - All remaining metadata (who/when, description, transcription) moves into a collapsible "Weitere Details" section that auto-expands when URL prefill data or a form error is present, or when filename parsing detects a date/person - title is no longer required — the form can be saved with only a file - DescriptionSection gains a `hideTitle` prop for use in this layout - `form_label_title` translation key no longer carries a hardcoded `*`; the asterisk is rendered by the template only when `titleRequired` is set (currently only the edit form) - E2E tests added for all three scenarios from the issue Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/documents.spec.ts | 56 ++++++++++++- frontend/messages/de.json | 3 +- frontend/messages/en.json | 3 +- frontend/messages/es.json | 3 +- .../document/DescriptionSection.svelte | 54 ++++++------- .../src/routes/documents/new/+page.svelte | 80 ++++++++++++++++--- .../documents/new/FileSectionNew.svelte | 71 +++++++++++----- 7 files changed, 203 insertions(+), 67 deletions(-) diff --git a/frontend/e2e/documents.spec.ts b/frontend/e2e/documents.spec.ts index 20c9a976..3f122c29 100644 --- a/frontend/e2e/documents.spec.ts +++ b/frontend/e2e/documents.spec.ts @@ -77,12 +77,49 @@ test.describe('Document detail', () => { }); test.describe('New document', () => { - test('renders the upload form', async ({ page }) => { + test('renders the upload form with file input first', async ({ page }) => { await page.goto('/documents/new'); + await page.waitForSelector('[data-hydrated]'); await expect(page.getByRole('heading', { name: /Neues Dokument/i })).toBeVisible(); - await expect(page.getByLabel('Titel')).toBeVisible(); + // File input comes before the title field in DOM order + const fileInput = page.locator('input[type="file"]'); + const titleInput = page.getByLabel('Titel'); + await expect(fileInput).toBeVisible(); + await expect(titleInput).toBeVisible(); + const fileBox = await fileInput.boundingBox(); + const titleBox = await titleInput.boundingBox(); + expect(fileBox!.y).toBeLessThan(titleBox!.y); await page.screenshot({ path: 'test-results/e2e/document-new.png' }); }); + + test('title field is pre-filled from filename when a file is selected', async ({ page }) => { + await page.goto('/documents/new'); + await page.waitForSelector('[data-hydrated]'); + const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles({ + name: 'Brief_1965.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + }); + await expect(page.getByLabel('Titel')).toHaveValue('Brief_1965'); + await page.screenshot({ path: 'test-results/e2e/document-new-filename-prefill.png' }); + }); + + test('typed title is not overwritten when a file is selected', async ({ page }) => { + await page.goto('/documents/new'); + await page.waitForSelector('[data-hydrated]'); + await page.getByLabel('Titel').fill('Weihnachtsbrief 1965'); + const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles({ + name: 'Brief_1965.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + }); + await expect(page.getByLabel('Titel')).toHaveValue('Weihnachtsbrief 1965'); + await page.screenshot({ path: 'test-results/e2e/document-new-title-not-overwritten.png' }); + }); }); test.describe('Document creation', () => { @@ -97,6 +134,21 @@ test.describe('Document creation', () => { await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/document-create.png' }); }); + + test('user saves a document with only a file — title comes from filename', async ({ page }) => { + await page.goto('/documents/new'); + await page.waitForSelector('[data-hydrated]'); + const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + await page.locator('input[type="file"]').setInputFiles({ + name: 'Brief_1965.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + }); + await page.getByRole('button', { name: 'Speichern', exact: true }).click(); + await expect(page).toHaveURL(/\/documents\/[^/]+$/); + await expect(page.getByRole('heading', { name: 'Brief_1965' })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/document-create-file-only.png' }); + }); }); test.describe('Document editing', () => { diff --git a/frontend/messages/de.json b/frontend/messages/de.json index e9d4d967..16718738 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -39,7 +39,7 @@ "form_placeholder_location": "z.B. Berlin, Wien…", "form_label_sender": "Absender", "form_label_receivers": "Empfänger", - "form_label_title": "Titel *", + "form_label_title": "Titel", "form_label_tags": "Schlagworte", "form_label_content": "Inhalt", "form_placeholder_content": "Kurze Beschreibung des Inhalts…", @@ -75,6 +75,7 @@ "doc_file_replace_label": "Neue Datei hochladen", "doc_file_replace_note": "(ersetzt die aktuelle Datei)", "doc_current_file_label": "Aktuelle Datei:", + "doc_more_details": "Weitere Details", "doc_new_heading": "Neues Dokument", "doc_edit_heading": "Bearbeiten", "doc_section_details": "Details", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 61e34dc9..462dea3b 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -39,7 +39,7 @@ "form_placeholder_location": "e.g. Berlin, Vienna…", "form_label_sender": "Sender", "form_label_receivers": "Recipients", - "form_label_title": "Title *", + "form_label_title": "Title", "form_label_tags": "Tags", "form_label_content": "Content", "form_placeholder_content": "Brief description of the content…", @@ -75,6 +75,7 @@ "doc_file_replace_label": "Upload new file", "doc_file_replace_note": "(replaces the current file)", "doc_current_file_label": "Current file:", + "doc_more_details": "More details", "doc_new_heading": "New document", "doc_edit_heading": "Edit", "doc_section_details": "Details", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 51d7ebe8..0ae0b91a 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -39,7 +39,7 @@ "form_placeholder_location": "p.ej. Berlín, Viena…", "form_label_sender": "Remitente", "form_label_receivers": "Destinatarios", - "form_label_title": "Título *", + "form_label_title": "Título", "form_label_tags": "Etiquetas", "form_label_content": "Contenido", "form_placeholder_content": "Breve descripción del contenido…", @@ -75,6 +75,7 @@ "doc_file_replace_label": "Subir nuevo archivo", "doc_file_replace_note": "(reemplaza el archivo actual)", "doc_current_file_label": "Archivo actual:", + "doc_more_details": "Más detalles", "doc_new_heading": "Nuevo documento", "doc_edit_heading": "Editar", "doc_section_details": "Detalles", diff --git a/frontend/src/lib/components/document/DescriptionSection.svelte b/frontend/src/lib/components/document/DescriptionSection.svelte index 6d2c13c4..b83ac296 100644 --- a/frontend/src/lib/components/document/DescriptionSection.svelte +++ b/frontend/src/lib/components/document/DescriptionSection.svelte @@ -9,7 +9,8 @@ let { initialDocumentLocation = '', initialSummary = '', titleRequired = false, - suggestedTitle = '' + suggestedTitle = '', + hideTitle = false }: { tags?: string[]; initialTitle?: string; @@ -17,17 +18,12 @@ let { initialSummary?: string; titleRequired?: boolean; suggestedTitle?: string; + hideTitle?: boolean; } = $props(); -let titleValue = $state(untrack(() => initialTitle)); let titleDirty = $state(false); - -$effect(() => { - const suggested = suggestedTitle; - if (suggested && !untrack(() => titleDirty)) { - titleValue = suggested; - } -}); +let titleOverride = $state(untrack(() => initialTitle)); +let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOverride);
@@ -36,25 +32,27 @@ $effect(() => {
- -
- - { - titleValue = (e.target as HTMLInputElement).value; - titleDirty = true; - }} - required={titleRequired} - class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink" - /> -
+ {#if !hideTitle} + +
+ + { + titleOverride = (e.target as HTMLInputElement).value; + titleDirty = true; + }} + required={titleRequired} + class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink" + /> +
+ {/if}
diff --git a/frontend/src/routes/documents/new/+page.svelte b/frontend/src/routes/documents/new/+page.svelte index ac2d8a6f..5e84accb 100644 --- a/frontend/src/routes/documents/new/+page.svelte +++ b/frontend/src/routes/documents/new/+page.svelte @@ -17,6 +17,30 @@ let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $ ); let parsedSuggestion = $state({}); + +// Title is derived from the filename suggestion unless the user has typed something +let titleDirty = $state(false); +let titleOverride = $state(''); +let titleValue = $derived( + titleDirty ? titleOverride : (parsedSuggestion.suggestedTitle ?? titleOverride) +); + +// Details panel: starts open when prefill data is present or a form error occurred. +// Auto-opens when filename parsing finds a date/sender, but never force-closes — user +// can always collapse the section manually. +let detailsOpen = $state( + !!( + untrack(() => data.initialSenderId) || + untrack(() => data.initialReceivers).length > 0 || + untrack(() => form)?.error + ) +); + +$effect(() => { + if (parsedSuggestion.dateIso || senderId || selectedReceivers.length > 0) { + detailsOpen = true; + } +});
@@ -49,21 +73,51 @@ let parsedSuggestion = $state({}); {/if}
- - - + (parsedSuggestion = r)} /> + +
+ + { + titleOverride = (e.target as HTMLInputElement).value; + titleDirty = true; + }} + class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink" + placeholder="Titel eingeben…" + /> +
+ + +
+ + {m.doc_more_details()} + +
+ + + +
+
+
import { m } from '$lib/paraglide/messages.js'; -import { parseFilename, type FilenameParseResult } from '$lib/utils/filename'; +import { parseFilename, stripExtension, type FilenameParseResult } from '$lib/utils/filename'; let { onfileParsed @@ -10,29 +10,58 @@ let { function handleFileChange(e: Event) { const file = (e.target as HTMLInputElement).files?.[0]; - if (file) onfileParsed?.(parseFilename(file.name)); + if (!file) return; + const parsed = parseFilename(file.name); + const result: FilenameParseResult = { + ...parsed, + suggestedTitle: parsed.suggestedTitle ?? stripExtension(file.name) + }; + onfileParsed?.(result); } -
-

- {m.doc_section_file()} -

+
+
+

+ {m.doc_section_file()} +

+
-
-- 2.49.1 From a7eaa40852a3c40e22b1bc959e85838353708a38 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 27 Mar 2026 07:04:54 +0100 Subject: [PATCH 4/4] fix(#68): hide native file input, show selected filename in upload zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native browser file input showed an untranslatable "Browse…" button and "No file selected" text. The input is now sr-only; the large upload zone label acts as the sole click target. When a file is selected its name replaces the prompt text inside the zone. Co-Authored-By: Claude Sonnet 4.6 --- .../documents/new/FileSectionNew.svelte | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/frontend/src/routes/documents/new/FileSectionNew.svelte b/frontend/src/routes/documents/new/FileSectionNew.svelte index 9ab27047..e22d2e0d 100644 --- a/frontend/src/routes/documents/new/FileSectionNew.svelte +++ b/frontend/src/routes/documents/new/FileSectionNew.svelte @@ -8,9 +8,12 @@ let { onfileParsed?: (result: FilenameParseResult) => void; } = $props(); +let selectedFilename = $state(null); + function handleFileChange(e: Event) { const file = (e.target as HTMLInputElement).files?.[0]; if (!file) return; + selectedFilename = file.name; const parsed = parseFilename(file.name); const result: FilenameParseResult = { ...parsed, @@ -45,23 +48,12 @@ function handleFileChange(e: Event) { d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /> - - {m.doc_file_upload_label()} - - {m.doc_file_upload_note()} + {#if selectedFilename} + {selectedFilename} + {:else} + {m.doc_file_upload_label()} + {m.doc_file_upload_note()} + {/if} -
- -
+
-- 2.49.1