feat(#68): lead new document form with file upload, all metadata optional
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m17s
CI / Backend Unit Tests (push) Failing after 9h3m48s
CI / E2E Tests (push) Failing after 28m15s

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-26 22:52:12 +01:00
parent d6f4ea05d9
commit c5e28ac18e
7 changed files with 203 additions and 67 deletions

View File

@@ -77,12 +77,49 @@ test.describe('Document detail', () => {
}); });
test.describe('New document', () => { 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.goto('/documents/new');
await page.waitForSelector('[data-hydrated]');
await expect(page.getByRole('heading', { name: /Neues Dokument/i })).toBeVisible(); 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' }); 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', () => { test.describe('Document creation', () => {
@@ -97,6 +134,21 @@ test.describe('Document creation', () => {
await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-create.png' }); 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', () => { test.describe('Document editing', () => {

View File

@@ -39,7 +39,7 @@
"form_placeholder_location": "z.B. Berlin, Wien…", "form_placeholder_location": "z.B. Berlin, Wien…",
"form_label_sender": "Absender", "form_label_sender": "Absender",
"form_label_receivers": "Empfänger", "form_label_receivers": "Empfänger",
"form_label_title": "Titel *", "form_label_title": "Titel",
"form_label_tags": "Schlagworte", "form_label_tags": "Schlagworte",
"form_label_content": "Inhalt", "form_label_content": "Inhalt",
"form_placeholder_content": "Kurze Beschreibung des Inhalts…", "form_placeholder_content": "Kurze Beschreibung des Inhalts…",
@@ -75,6 +75,7 @@
"doc_file_replace_label": "Neue Datei hochladen", "doc_file_replace_label": "Neue Datei hochladen",
"doc_file_replace_note": "(ersetzt die aktuelle Datei)", "doc_file_replace_note": "(ersetzt die aktuelle Datei)",
"doc_current_file_label": "Aktuelle Datei:", "doc_current_file_label": "Aktuelle Datei:",
"doc_more_details": "Weitere Details",
"doc_new_heading": "Neues Dokument", "doc_new_heading": "Neues Dokument",
"doc_edit_heading": "Bearbeiten", "doc_edit_heading": "Bearbeiten",
"doc_section_details": "Details", "doc_section_details": "Details",

View File

@@ -39,7 +39,7 @@
"form_placeholder_location": "e.g. Berlin, Vienna…", "form_placeholder_location": "e.g. Berlin, Vienna…",
"form_label_sender": "Sender", "form_label_sender": "Sender",
"form_label_receivers": "Recipients", "form_label_receivers": "Recipients",
"form_label_title": "Title *", "form_label_title": "Title",
"form_label_tags": "Tags", "form_label_tags": "Tags",
"form_label_content": "Content", "form_label_content": "Content",
"form_placeholder_content": "Brief description of the content…", "form_placeholder_content": "Brief description of the content…",
@@ -75,6 +75,7 @@
"doc_file_replace_label": "Upload new file", "doc_file_replace_label": "Upload new file",
"doc_file_replace_note": "(replaces the current file)", "doc_file_replace_note": "(replaces the current file)",
"doc_current_file_label": "Current file:", "doc_current_file_label": "Current file:",
"doc_more_details": "More details",
"doc_new_heading": "New document", "doc_new_heading": "New document",
"doc_edit_heading": "Edit", "doc_edit_heading": "Edit",
"doc_section_details": "Details", "doc_section_details": "Details",

View File

@@ -39,7 +39,7 @@
"form_placeholder_location": "p.ej. Berlín, Viena…", "form_placeholder_location": "p.ej. Berlín, Viena…",
"form_label_sender": "Remitente", "form_label_sender": "Remitente",
"form_label_receivers": "Destinatarios", "form_label_receivers": "Destinatarios",
"form_label_title": "Título *", "form_label_title": "Título",
"form_label_tags": "Etiquetas", "form_label_tags": "Etiquetas",
"form_label_content": "Contenido", "form_label_content": "Contenido",
"form_placeholder_content": "Breve descripción del contenido…", "form_placeholder_content": "Breve descripción del contenido…",
@@ -75,6 +75,7 @@
"doc_file_replace_label": "Subir nuevo archivo", "doc_file_replace_label": "Subir nuevo archivo",
"doc_file_replace_note": "(reemplaza el archivo actual)", "doc_file_replace_note": "(reemplaza el archivo actual)",
"doc_current_file_label": "Archivo actual:", "doc_current_file_label": "Archivo actual:",
"doc_more_details": "Más detalles",
"doc_new_heading": "Nuevo documento", "doc_new_heading": "Nuevo documento",
"doc_edit_heading": "Editar", "doc_edit_heading": "Editar",
"doc_section_details": "Detalles", "doc_section_details": "Detalles",

View File

@@ -9,7 +9,8 @@ let {
initialDocumentLocation = '', initialDocumentLocation = '',
initialSummary = '', initialSummary = '',
titleRequired = false, titleRequired = false,
suggestedTitle = '' suggestedTitle = '',
hideTitle = false
}: { }: {
tags?: string[]; tags?: string[];
initialTitle?: string; initialTitle?: string;
@@ -17,17 +18,12 @@ let {
initialSummary?: string; initialSummary?: string;
titleRequired?: boolean; titleRequired?: boolean;
suggestedTitle?: string; suggestedTitle?: string;
hideTitle?: boolean;
} = $props(); } = $props();
let titleValue = $state(untrack(() => initialTitle));
let titleDirty = $state(false); let titleDirty = $state(false);
let titleOverride = $state(untrack(() => initialTitle));
$effect(() => { let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOverride);
const suggested = suggestedTitle;
if (suggested && !untrack(() => titleDirty)) {
titleValue = suggested;
}
});
</script> </script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm"> <div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
@@ -36,25 +32,27 @@ $effect(() => {
</h2> </h2>
<div class="space-y-5"> <div class="space-y-5">
<!-- Titel --> {#if !hideTitle}
<div> <!-- Titel -->
<label for="title" class="mb-1 block text-sm font-medium text-ink-2" <div>
>{m.form_label_title()}{#if titleRequired} <label for="title" class="mb-1 block text-sm font-medium text-ink-2"
*{/if}</label >{m.form_label_title()}{#if titleRequired}
> *{/if}</label
<input >
id="title" <input
type="text" id="title"
name="title" type="text"
value={titleValue} name="title"
oninput={(e) => { value={titleValue}
titleValue = (e.target as HTMLInputElement).value; oninput={(e) => {
titleDirty = true; 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" required={titleRequired}
/> class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
</div> />
</div>
{/if}
<!-- Aufbewahrungsort --> <!-- Aufbewahrungsort -->
<div> <div>

View File

@@ -17,6 +17,30 @@ let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $
); );
let parsedSuggestion = $state<FilenameParseResult>({}); let parsedSuggestion = $state<FilenameParseResult>({});
// 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;
}
});
</script> </script>
<div class="mx-auto max-w-4xl px-4 py-8"> <div class="mx-auto max-w-4xl px-4 py-8">
@@ -49,21 +73,51 @@ let parsedSuggestion = $state<FilenameParseResult>({});
{/if} {/if}
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20"> <form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
<WhoWhenSection <!-- File upload — prominent, at the top -->
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
initialSenderName={data.initialSenderName}
suggestedDateIso={parsedSuggestion.dateIso ?? ''}
suggestedSenderName={parsedSuggestion.personName ?? ''}
/>
<DescriptionSection
bind:tags={tags}
titleRequired={true}
suggestedTitle={parsedSuggestion.suggestedTitle ?? ''}
/>
<TranscriptionSection />
<FileSectionNew onfileParsed={(r) => (parsedSuggestion = r)} /> <FileSectionNew onfileParsed={(r) => (parsedSuggestion = r)} />
<!-- Standalone title card -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<label for="new-title" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_title()}</label
>
<input
id="new-title"
type="text"
name="title"
value={titleValue}
oninput={(e) => {
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…"
/>
</div>
<!-- Collapsible further details -->
<details
bind:open={detailsOpen}
class="group rounded-sm border border-line bg-surface shadow-sm"
>
<summary class="cursor-pointer list-none px-6 py-4">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.doc_more_details()}</span
>
</summary>
<div class="space-y-6 px-0 pb-6">
<WhoWhenSection
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
initialSenderName={data.initialSenderName}
suggestedDateIso={parsedSuggestion.dateIso ?? ''}
suggestedSenderName={parsedSuggestion.personName ?? ''}
/>
<DescriptionSection bind:tags={tags} hideTitle={true} />
<TranscriptionSection />
</div>
</details>
<!-- Sticky Save Bar --> <!-- Sticky Save Bar -->
<div <div
class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]" class="sticky bottom-0 z-10 -mx-4 flex items-center justify-between border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)]"

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js'; 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 { let {
onfileParsed onfileParsed
@@ -10,29 +10,58 @@ let {
function handleFileChange(e: Event) { function handleFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]; 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);
} }
</script> </script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm"> <div class="rounded-sm border border-line bg-surface shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase"> <div class="border-b border-line px-6 py-4">
{m.doc_section_file()} <h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
</h2> {m.doc_section_file()}
</h2>
</div>
<label for="file-upload" class="mb-1 block text-sm font-medium text-ink-2"> <label
{m.doc_file_upload_label()} for="file-upload"
<span class="font-normal text-ink-3">({m.doc_file_upload_note()})</span> class="flex cursor-pointer flex-col items-center gap-3 px-6 py-10 transition-colors hover:bg-muted/40"
>
<svg
class="h-10 w-10 text-ink-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
<span class="text-sm font-medium text-ink-2">
{m.doc_file_upload_label()}
</span>
<span class="text-xs text-ink-3">{m.doc_file_upload_note()}</span>
</label> </label>
<input <div class="px-6 pb-6">
id="file-upload" <input
type="file" id="file-upload"
name="file" type="file"
onchange={handleFileChange} name="file"
class="block w-full cursor-pointer text-sm onchange={handleFileChange}
text-ink-2 file:mr-4 file:rounded class="block w-full cursor-pointer rounded border border-dashed border-line p-2 text-sm
file:border-0 file:bg-muted text-ink-2 file:mr-4 file:rounded
file:px-4 file:py-2 file:border-0 file:bg-muted
file:text-sm file:font-semibold file:px-4 file:py-2
file:text-ink hover:file:bg-muted" file:text-sm file:font-semibold
/> file:text-ink hover:file:bg-muted"
/>
</div>
</div> </div>