feat(#68): lead new document form with file upload, all metadata optional
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:
@@ -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', () => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)]"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user