feat(enrich): field reordering, required-fields progress bar, and no-PDF upload state #261
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Three targeted UX improvements to the Enrich / Edit page that reduce friction during bulk enrichment without restructuring the card layout:
PLACEHOLDERstatus)Design spec:
docs/specs/enrich-edit-unified.html1 — Field Priority Within Cards
WhoWhenSection
Reorder the four
<div>children inside the existinggrid-cols-2container so that required fields occupy row 1 and optional fields occupy row 2.No class changes needed — grid order follows source order.
DescriptionSection
Reorder fields so Title stays first, Tags move up (more useful than archive location during enrichment), and Aufbewahrungsort is pushed below an "Optional" divider.
Add a divider element directly before the
documentLocationfield:Auto-focus first empty required field
In
WhoWhenSection.svelte, applyautofocusto the Date input wheninitialDateIsois empty; otherwise to the SenderPersonTypeahead:2 — Required-Fields Progress Bar
A 3 px-tall strip placed between the enrich top bar and the form scroll area. It tracks how many of the 3 required fields (Title, Date, Sender) are non-empty and fills a progress track accordingly.
Derivation (in
enrich/[id]/+page.svelte)Markup
Place this strip inside the form panel, above
<div class="form-scroll ...">, so it stays fixed while the form scrolls.3 — No-PDF Upload State
When
!doc.filePath(i.e. status isPLACEHOLDER), render an upload zone in the left panel instead of the PDF viewer.States
State A — No file uploaded
State B — File uploading (indeterminate progress)
When
isUploadingis true, replace the zone content with:Add the keyframe to
app.css(orlayout.css):Upload handler
Replace file (edit mode)
In
FileSectionEdit.svelte, move the "Datei ersetzen" trigger out of the form scroll and into the PDF toolbar as a ghost button:Affected Files
src/routes/enrich/[id]/+page.sveltesrc/lib/components/WhoWhenSection.sveltesrc/lib/components/DescriptionSection.sveltesrc/lib/components/FileSectionEdit.sveltesrc/app.css(orlayout.css)@keyframes slidefor upload animationAcceptance Criteria
x / 3) as fields are populatedtransition-all duration-300) as fields are filled/cleareddoc.filePathis null, the left panel shows the dark upload zone (not a blank area)doc.originalFilenameis displayed inside the upload zone🏗️ Markus Keller — Application Architect
Observations
Progress bar state lifting is a hidden prerequisite. The issue proposes
$derived([doc.title || titleValue, dateIso, senderId].filter(Boolean).length)inenrich/[id]/+page.svelte. ButtitleValuelives insideDescriptionSection.svelteas internal state, anddateIsolives insideWhoWhenSection.svelteas internal state. Neither is currently exposed as a bindable prop. This isn't mentioned in the issue — it's a real change that needs to happen before the progress bar can be wired up.The upload zone belongs in its own component. The spec puts drag-and-drop, indeterminate animation, file state, uploading state, and cancel logic all inline in
enrich/[id]/+page.svelte. That page is already 150+ lines. Extract to$lib/components/document/UploadZone.svelte— it's a coherent visual region with its own state machine, which is exactly the splitting criterion."Datei ersetzen" in the PDF toolbar is architecturally awkward.
FileSectionEdit.svelteis route-colocated indocuments/[id]/edit/. Moving the trigger into the PDF toolbar means either (a) couplingDocumentViewer.svelteto a file-replace concern it doesn't own, or (b) passing a Svelte snippet into DocumentViewer from the edit page. Snippet threading is the right call — don't add file-replace awareness to a component used on the enrich, transcription, and detail pages.DocumentVieweralready handles!doc.filePath(lines 77–88 inDocumentViewer.svelte) with a generic icon. The upload zone in the enrich page should bypassDocumentViewerentirely with an outer conditional inenrich/[id]/+page.svelte, rather than adding upload logic inside the viewer. DocumentViewer should stay generic.Recommendations
dateIso = $bindable('')toWhoWhenSectionprops and exposetitleValue(or a derivedcurrentTitle) as a bindable fromDescriptionSectionbefore wiring the progress bar.$lib/components/document/UploadZone.sveltewith props:filename,isUploading,isDragging,onFile,onCancel.enrich/[id]/+page.svelte, wrap the left panel as:{#if !doc.filePath}<UploadZone .../>{:else}<DocumentViewer .../>{/if}.actionSnippetsnippet prop toDocumentViewerso the edit page can inject toolbar actions without DocumentViewer knowing about file replacement.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
The upload API endpoint doesn't exist. The issue's
handleFilecallsfetch('/api/documents/${doc.id}/file', { method: 'POST' }). No such endpoint exists on the backend. The actual upload goes throughPUT /api/documents/{id}as a multipart request (handled by thesaveform action). This call will return 404. Either: (a) add a dedicatedPOST /api/documents/{id}/fileendpoint inDocumentController, or (b) submit the file through the existing SvelteKit form action as a partial form post. Option (a) is simpler for an async upload; option (b) avoids backend changes but triggers a full form save.invalidate('app:document')won't work. The load function in+page.server.tsnever callsdepends('app:document'). Without that, callinginvalidate('app:document')from the client will do nothing — the page data won't refresh after the upload completes, so the PDF viewer will never appear. Adddepends('app:document')to the load function, or useinvalidateAll()as a simpler fallback.titleValueanddateIsoare not bindable.WhoWhenSection.sveltecomputesdateIsoas internal$state.DescriptionSection.sveltederivestitleValueinternally. Neither is in the parent's scope. To compute the progress bar inenrich/[id]/+page.svelte, adddateIso = $bindable('')toWhoWhenSectionand exposetitleValue(or acurrentTitlebindable) fromDescriptionSection.PersonTypeaheadhas noautofocusprop. The autofocus spec (<PersonTypeahead ... autofocus />) can't work —PersonTypeaheaddoesn't accept anautofocusprop (confirmed by reading its props interface). Addautofocus?: boolean = falseand forward it to the underlying<input>element.cancelUploadis referenced but never defined. The upload zone spec showsonclick={cancelUpload}but there's no definition anywhere. Decide: useAbortControllerto cancel the fetch, or just disallow cancel (let upload finish). If usingAbortController, hold the reference as$state<AbortController | null>(null).DescriptionSection field reorder note. Current order is Titel → Aufbewahrungsort → Schlagworte → Inhalt. The reorder moves Aufbewahrungsort to the bottom. The
$stateand$derivedin the component are position-independent, so this is a pure template reorder — no logic changes needed.Recommendations
POST /api/documents/{id}/fileendpoint that accepts a single multipart file, updates the document'sfilePath, and returns the updated document. Annotate with@RequirePermission(Permission.WRITE_ALL).depends('app:document')to the enrich load function.WhoWhenSectionandDescriptionSectionfor the live field values needed by the progress bar.autofocus?: booleanprop toPersonTypeaheadforwarded to the input element.🔐 Nora "NullX" Steiner — Security Engineer
Observations
Client-side
fetchto the backend API bypasses server middleware — acceptable here, but needs auth awareness. ThehandleFilefunction callsfetch('/api/documents/${doc.id}/file', ...)directly from the browser. SvelteKit proxies all/api/*requests to the backend, so the session cookie is sent along — authentication is maintained. No SSRF risk (the URL is hardcoded, not user-controlled). This is a known pattern for async file uploads. Just make sure the new backend endpoint enforces@RequirePermission(Permission.WRITE_ALL).No client-side file type validation before upload starts. The
handleFilefunction passes the file directly toFormDatawithout checkingfile.type. The backend'supdateDocumentpath does validateALLOWED_CONTENT_TYPES(confirmed inDocumentController), so an invalid type will be rejected. But the user gets no feedback until the server responds. Add a client-side type check for fast rejection:No file size limit before upload. A user could drop a 2 GB file and tie up the connection for minutes. The backend presumably has a max file size configured in Spring (default is 1 MB for multipart, likely overridden). Add a client-side check against a known limit (e.g. 50 MB) and show an inline error immediately.
cancelUploadis undefined. If the cancel button appears during upload butcancelUploadis not implemented, clicking it will throw a runtime error. UseAbortControllerand expose acancel()method. Alternatively, omit the cancel button until the function is implemented — a button that throws on click is worse than no button.IDOR risk on the upload endpoint. The upload endpoint must verify that the requesting user has access to the document being uploaded to. If the new
POST /api/documents/{id}/fileendpoint callsdocumentService.updateDocument(id, ...), the existing@RequirePermission(Permission.WRITE_ALL)check covers role but doesn't check document ownership. This is consistent with the rest of the app's access model (role-based, not per-document), so no new risk is introduced — just confirming parity.Recommendations
handleFilebefore starting the upload.cancelUploadviaAbortControlleror remove the button from the spec until it's properly implemented.@RequirePermission(Permission.WRITE_ALL)— same as the existing update endpoint.🧪 Sara Holt — QA Engineer
Observations
Upload failure has no acceptance criterion and no specified UI state. The 13 ACs cover the happy path (upload → progress → PDF viewer) and drag-and-drop styling, but nothing about what happens when the upload fails (network error, server 4xx/5xx, unsupported type). Users need a recoverable error state — the upload zone should return to its idle state with an inline error message. Without this AC, the behavior will be underdefined.
cancelUploadhas no acceptance criterion. AC mentions "Abbrechen" appears during upload but doesn't specify what the UI returns to after cancel, or whether a cancelled upload resets the upload zone cleanly. This is testable behavior that needs specifying.invalidate('app:document')is not currently set up in the load function. The load function doesn't calldepends('app:document'), so the invalidation call won't trigger a data reload. A Vitest test for the load function won't catch this — it'll need an integration or E2E test that: (1) visits an enrich page for a PLACEHOLDER doc, (2) uploads a file, (3) verifies the PDF viewer appears. Without this test, the transition from upload zone to PDF viewer could silently regress.Progress bar requires bindable state not yet exposed by child components. The derivation
[doc.title || titleValue, dateIso, senderId]referencestitleValueanddateIsoas in-scope variables — but they're internal to child components. When these bindable props are added, add a Vitest test for the derivation: mock the three state values and assertrequiredFilledcounts correctly for all 8 combinations (0/1/2/3 filled).Autofocus AC is underspecified for the "all empty" case. The spec says: focus Date when
initialDateIsois empty; otherwise focus Sender. A freshly loaded PLACEHOLDER doc has neither date nor sender — the Date input should receive focus. This is correct per the spec but the AC doesn't state it explicitly. Add: "When both Datum and Absender are empty on load, Datum receives focus."Recommendations
Add these missing ACs:
depends('app:document')wired up)Test strategy:
requiredFilledderivation for all 8 field-filled combinationshandleFilerejects invalid MIME types without uploadingnullfilePath for PLACEHOLDER docs🎨 Leonie Voss — UX Design Lead
Observations
Hardcoded hex
bg-[#4A4846]breaks the token system. The upload zone background is specified asbg-[#4A4846], a raw hex value. The project's dark panel color is already expressed asbg-pdf-bg(used inDocumentViewer.svelte). Usebg-pdf-bg— it matches the PDF viewer panel color, ensures consistency when the panel transitions from upload zone to PDF viewer, and keeps all dark-panel color decisions in one place.Three text sizes in the upload zone are below the 12 px floor. The spec uses
text-[9px]for the "Optional" divider label and the drag hint, andtext-[10px]for the filename and progress labels. Our senior-audience minimum is 12 px. Raise them:text-[9px]) →text-[11px]minimum (usetext-xs= 12px)text-xs(12px)text-xsminimumThe "Abbrechen" cancel link is nearly invisible.
text-[9px] text-white/20is 9 px text at 20% opacity on a dark background — it fails every WCAG contrast level and cannot be reliably tapped. If cancel is included, it must be at leasttext-xs text-white/40 hover:text-white/60, and the touch target must bemin-h-[44px]or wrapped in a<button class="min-h-[44px]">."Datei auswählen" button touch target needs enforcement. The spec's
<label>wrapping the file input haspx-4 py-1.5padding — at 10px font size, this renders roughly 28px tall. WCAG 2.2 SC 2.5.8 requires 24×24 px minimum, but our audience (60+) needs 44×44 px. Addmin-h-[44px] flex items-centerto the label.The drag-highlight token
bg-mint/5is wrong. The spec referencesbg-brand-mint/5in one place andbg-mint/5in another. Onlybg-brand-mint/5is a valid token in this project. Usebg-brand-mint/5consistently.No
aria-liveregion for upload state changes. When the zone switches from idle → uploading → done, screen readers won't announce anything. Add anaria-live="polite"region or update anaria-labelon the zone container to describe the current state. For the uploading state, "Datei wird hochgeladen" should be announced.Progress bar accessibility. The
<div class="h-0.5 flex-1 rounded-full bg-line">bar has no ARIA. Addrole="progressbar" aria-valuenow={requiredFilled} aria-valuemin={0} aria-valuemax={3} aria-label="Pflichtfelder"to the fill container so screen readers announce completion state.Recommendations
bg-[#4A4846]withbg-pdf-bgtext-xs(12px)text-xs text-white/40 hover:text-white/60 min-h-[44px]or remove from spec until properly styledmin-h-[44px] flex items-centerbg-mint/5→bg-brand-mint/5aria-live="polite"region androle="progressbar"on the required-fields bar🛠️ Tobias Wendt — DevOps & Platform Engineer
Observations
No infrastructure impact. This is a pure frontend change — three Svelte components modified, one enrich page extended, one CSS animation added. No new services, no Docker changes, no Flyway migrations, no environment variables. From a platform perspective this is clean.
@keyframes slidegoes inlayout.css, notapp.css. The issue says "Add the keyframe toapp.css(orlayout.css)". Preferlayout.css.app.cssis the Tailwind 4 entry point — it should stay lean (only@import "tailwindcss"and CSS custom properties). Animation keyframes that aren't Tailwind utilities belong inlayout.cssalongside the existing brand token definitions.CI impact is minimal. No new test infrastructure needed. The frontend Vitest suite and
svelte-checktype-check will cover the component changes. If E2E tests exist for the enrich flow, they should be extended to cover the upload zone, but the pipeline itself doesn't change.One thing to verify: the production file size limit. The new upload path calls
PUT /api/documents/{id}with a raw file. Spring Boot's multipart limit is configured inapplication.yaml(typicallyspring.servlet.multipart.max-file-sizeandmax-request-size). Make sure the configured limit is appropriate for family photos and scanned PDFs — 50 MB is a reasonable ceiling for this use case. If it's currently at the Spring default (1 MB), the upload will silently fail for most real documents.🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
Architecture
POST /api/documents/{id}/filebackend endpoint — simpler to call from the browser via rawfetch, returns the updated document, and the frontend can then invalidate to show the PDF viewer. (b) Reuse the existing SvelteKit form action (PUT /api/documents/{id}) by submitting a partialFormData— avoids backend changes but triggers a full form save and a page redirect, breaking the async UX. Option (a) requires writing a new backend endpoint and test; option (b) avoids backend work but conflicts with the "form stays editable during upload" acceptance criterion. (Raised by: Felix)UX / Scope
cancelUploadis undefined, the touch target is inaccessible, and the font is below the 12 px minimum. Including it properly requires:AbortControllerimplementation, accessible sizing (min-h-[44px]), and readable contrast (text-white/40). Deferring keeps this issue focused — the upload zone still works without a cancel button (upload completes normally). Decision: implement cancel properly in this issue, or ship the upload zone without cancel and track cancel as a follow-up? (Raised by: Felix, Nora, Leonie)👨💻 Felix Brandt — Implementation Discussion Summary
Working through the open items from my review comment. All 5 resolved.
Resolved
1. Upload endpoint — new dedicated backend endpoint
Add
POST /api/documents/{id}/filetoDocumentController. Accepts a single@RequestPart MultipartFile, annotated@RequirePermission(Permission.WRITE_ALL), delegates to a newdocumentService.attachFile(id, file)method, returns the updatedDocument. Test coverage:@WebMvcTesthappy path + 403 for missing permission.2. Cancel button — implement now with
AbortControllerHold
let abortController = $state<AbortController | null>(null). On upload start, assign a new controller and passsignaltofetch.cancelUpload()callsabortController?.abort(). CatchAbortErrorand reset to idle. Button styledmin-h-[44px] text-xs text-white/40 hover:text-white/60per Leonie's accessibility requirements.3. Bindable props for progress bar
WhoWhenSection: adddateIso = $bindable('')to props — already$stateinternally, straightforward to exposeDescriptionSection: renametitleOverridetocurrentTitleand expose as a read-only bindable — parent binds but never writes, no$effectneededconst requiredFilled = $derived([currentTitle, dateIso, senderId].filter(Boolean).length)4. Post-upload data refresh — targeted invalidate
Add
depends('app:document')to the enrich load function. Callinvalidate('app:document')after a successful upload. Explicit and self-documenting —invalidateAll()was ruled out as too blunt.5. UploadZone extraction — separate component
Extract to
$lib/components/document/UploadZone.svelte. Purely presentational: all state (abortController,isUploading,isDragging,error) lives inenrich/[id]/+page.svelte. Props:filename,isUploading,isDragging,error,onFile,onCancel. The component owns the markup and animation; the parent owns the state machine.Overall read
The field reordering and progress bar are straightforward once the bindable props are in place. The upload zone has enough moving parts (abort, error state, drag, animation) that extracting it to its own component is the right call before writing a line of it. The new backend endpoint is the only non-frontend work — small but necessary for the async UX to hold together.
✅ Implementation complete — branch
feat/issue-261-enrich-field-reorder-progress-bar-uploadAll 14 planned tasks implemented with TDD (red → green → commit). Summary:
Backend (1 commit)
57ed937— NewPOST /api/documents/{id}/fileendpoint (@RequirePermission(WRITE_ALL)), delegating toDocumentService.attachFile(). 2 new@WebMvcTesttests (200 with WRITE_ALL, 403 without).Frontend (8 commits)
255eeb6—PersonTypeahead: addedautofocus?: booleanprop forwarded to the text<input>.77db791—WhoWhenSection: exposeddateIso = $bindable(''), reordered grid (Datum+Absender in row 1, Empfänger+Ort in row 2), conditionalautofocuson first empty required field.765d282—DescriptionSection: exposedcurrentTitle = $bindable(''), reordered fields (Titel → Schlagworte → Kurzinhalt → [Optional divider] → Aufbewahrungsort).44c7adb—enrich/[id]/+page.server.ts: addeddepends('app:document')so post-uploadinvalidate()triggers a data reload.3893a10—layout.css: added@keyframes slidefor the indeterminate upload animation.14cfe8c— NewUploadZone.sveltecomponent: idle/uploading/error states, drag-and-drop, client-side MIME + 50 MB validation,min-h-[44px]touch targets,aria-live="polite",bg-pdf-bg. 8 Vitest tests.93fc869—countRequiredFilled()utility + 8 Vitest tests covering all field-filled combinations.836d30e—enrich/[id]/+page.sveltewired up: progress bar (role="progressbar"ARIA,requiredFilledderived from boundcurrentTitle/dateIso/senderId), conditional left panel (UploadZone vs DocumentViewer), upload state machine withAbortController+invalidate('app:document'), "Datei ersetzen" ghost toolbar above PDF.Test results
All acceptance criteria satisfied
x / 3labeltransition-all duration-300)doc.filePathis null (dark background usingbg-pdf-bg)doc.originalFilenamedisplayed in upload zoneinvalidate('app:document'))