From fa5dc43864e1cf9701c2bf55570cb45d015187af Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 15:16:06 +0200 Subject: [PATCH] feat(bulk-edit): extend BulkDocumentEditLayout with mode="edit" - New FieldLabelBadge component (additive / replace variants, WCAG AA contrast) - WhoWhenSection: hideDate prop, editMode prop renders badges next to sender and receivers, hides the meta_location field - DescriptionSection: editMode prop renders badges next to tags and archive fields; new bindable archiveBox / archiveFolder inputs only in editMode - PersonTypeahead: optional badge prop forwards to FieldLabelBadge - FileSwitcherStrip FileEntry: file is now optional, documentId added so edit-mode entries reference an existing document by UUID - BulkDocumentEditLayout: mode prop branches drop zone / read-only title / callout / save handler. Edit save chunks 500 IDs per PATCH, stops on chunk failure with retry, marks per-document errors as chips, clears the bulk selection store on full success. Refs #225 Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/PersonTypeahead.svelte | 5 +- .../document/BulkDocumentEditLayout.svelte | 287 +++++++++++++++--- .../BulkDocumentEditLayout.svelte.spec.ts | 187 ++++++++++++ .../document/DescriptionSection.svelte | 92 ++++-- .../document/FieldLabelBadge.svelte | 16 + .../document/FieldLabelBadge.svelte.spec.ts | 30 ++ .../document/FileSwitcherStrip.svelte | 6 +- .../components/document/WhoWhenSection.svelte | 97 +++--- 8 files changed, 610 insertions(+), 110 deletions(-) create mode 100644 frontend/src/lib/components/document/FieldLabelBadge.svelte create mode 100644 frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index 815623da..0ac204ab 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -4,6 +4,7 @@ import type { components } from '$lib/generated/api'; import { m } from '$lib/paraglide/messages.js'; import { clickOutside } from '$lib/actions/clickOutside'; import { createTypeahead } from '$lib/hooks/useTypeahead.svelte'; +import FieldLabelBadge from './document/FieldLabelBadge.svelte'; type Person = components['schemas']['Person']; interface Props { @@ -18,6 +19,7 @@ interface Props { autofocus?: boolean; required?: boolean; restrictToCorrespondentsOf?: string; + badge?: 'additive' | 'replace'; onchange?: (value: string) => void; onfocused?: () => void; } @@ -34,6 +36,7 @@ let { autofocus = false, required = false, restrictToCorrespondentsOf, + badge, onchange, onfocused }: Props = $props(); @@ -116,7 +119,7 @@ function selectPerson(person: Person) { class={compact ? 'block text-xs font-bold tracking-wide text-ink-3 uppercase' : 'block text-sm font-medium text-ink-2'} - >{label}{#if required}*{/if}{label}{#if required}*{/if}{#if badge}{/if} diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte index 6ce93a07..d6f75d22 100644 --- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte @@ -5,6 +5,7 @@ import { onDestroy, untrack } from 'svelte'; import { m } from '$lib/paraglide/messages.js'; import { getConfirmService } from '$lib/services/confirm.svelte.js'; import type { ConfirmService } from '$lib/services/confirm.svelte.js'; +import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte'; import BulkDropZone from './BulkDropZone.svelte'; import FileSwitcherStrip from './FileSwitcherStrip.svelte'; import type { FileEntry } from './FileSwitcherStrip.svelte'; @@ -19,6 +20,12 @@ import type { components } from '$lib/generated/api'; type Person = components['schemas']['Person']; +export type BulkEditEntry = { + documentId: string; + title: string; + pdfUrl: string; +}; + // Optional — not available in unit tests that don't provide CONFIRM_KEY context. let _confirmService: ConfirmService | null; try { @@ -28,13 +35,17 @@ try { } let { + mode = 'upload', initialSenderId = '', initialSenderName = '', - initialReceivers = [] + initialReceivers = [], + initialEditEntries = [] }: { + mode?: 'upload' | 'edit'; initialSenderId?: string; initialSenderName?: string; initialReceivers?: Person[]; + initialEditEntries?: BulkEditEntry[]; } = $props(); // --- File state --- @@ -42,12 +53,35 @@ let files = new SvelteMap(); let activeId = $state(null); let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined); let saving = $state(false); +// Partial-failure surface: when set, the last save aborted at chunk N of M. +let partialSaved = $state<{ done: number; total: number } | null>(null); // --- Shared metadata --- let senderId = $state(untrack(() => initialSenderId)); let selectedReceivers = $state(untrack(() => initialReceivers)); let dateIso = $state(''); let tags = $state([]); +// Bulk-edit only — replace-on-non-blank semantics. +let documentLocation = $state(''); +let archiveBox = $state(''); +let archiveFolder = $state(''); + +// Hydrate edit-mode entries on mount. The IDs in bulkSelectionStore drive the +// fetch upstream in the route — by the time this layout mounts, the metadata +// has already been resolved into `initialEditEntries`. +if (mode === 'edit') { + for (const entry of untrack(() => initialEditEntries)) { + const id = entry.documentId; // reuse documentId as the local FileEntry key + files.set(id, { + id, + documentId: entry.documentId, + title: entry.title, + status: 'idle', + previewUrl: entry.pdfUrl + }); + if (!activeId) activeId = id; + } +} // --- Derived --- const isMulti = $derived(files.size >= 2); @@ -105,10 +139,8 @@ onDestroy(() => { } }); -// --- Save --- -async function save() { - if (saving) return; - saving = true; +// --- Save (upload mode) --- +async function saveUpload() { const entries = Array.from(files.values()); // 10 files per request keeps multipart bodies well under typical reverse-proxy limits (e.g. nginx default 1 MB client_max_body_size per PDF). const chunkSize = 10; @@ -122,7 +154,7 @@ async function save() { for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; const formData = new FormData(); - chunk.forEach((entry) => formData.append('files', entry.file)); + chunk.forEach((entry) => entry.file && formData.append('files', entry.file)); const metadata = { titles: chunk.map((e) => e.title), senderId: senderId || null, @@ -143,8 +175,8 @@ async function save() { if (!res.ok || errorFilenames.size > 0) { hadErrors = true; for (const entry of chunk) { - // When backend names specific files, mark only those; otherwise mark all. - const isError = errorFilenames.size > 0 ? errorFilenames.has(entry.file.name) : true; + const filename = entry.file?.name; + const isError = errorFilenames.size > 0 && filename ? errorFilenames.has(filename) : true; if (isError) { const e = files.get(entry.id); if (e) files.set(entry.id, { ...e, status: 'error' }); @@ -160,9 +192,97 @@ async function save() { } chunkProgress = { done: i + 1, total: chunks.length }; } - saving = false; if (!hadErrors) goto('/documents'); } + +// --- Save (edit mode) --- +async function saveBulkEdit() { + const entries = Array.from(files.values()); + const ids = entries.map((e) => e.documentId).filter((x): x is string => !!x); + + // PATCH cap matches backend: 500 IDs per request. Sequential, stop on chunk + // failure so the user sees a deterministic "X of N saved" outcome. + const chunkSize = 500; + const chunks: string[][] = []; + for (let i = 0; i < ids.length; i += chunkSize) { + chunks.push(ids.slice(i, i + chunkSize)); + } + chunkProgress = { done: 0, total: chunks.length }; + partialSaved = null; + + const dto = { + tagNames: tags.map((t) => t.name), + senderId: senderId || null, + receiverIds: selectedReceivers.map((r) => r.id), + documentLocation: documentLocation || null, + archiveBox: archiveBox || null, + archiveFolder: archiveFolder || null + }; + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + try { + const res = await fetch('/api/documents/bulk', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...dto, documentIds: chunk }) + }); + if (!res.ok) { + // Network/server failure: the chunk did not apply. Mark its entries + // as errored, surface partial-save state, and stop. + for (const id of chunk) { + const e = files.get(id); + if (e) files.set(id, { ...e, status: 'error' }); + } + partialSaved = { done: i, total: chunks.length }; + return; + } + const body = (await res.json().catch(() => null)) as { + updated: number; + errors: { id: string; message: string }[]; + } | null; + if (body && body.errors && body.errors.length > 0) { + for (const err of body.errors) { + const e = files.get(err.id); + if (e) files.set(err.id, { ...e, status: 'error' }); + } + } + } catch { + for (const id of chunk) { + const e = files.get(id); + if (e) files.set(id, { ...e, status: 'error' }); + } + partialSaved = { done: i, total: chunks.length }; + return; + } + chunkProgress = { done: i + 1, total: chunks.length }; + } + + const stillErrored = Array.from(files.values()).some((e) => e.status === 'error'); + if (!stillErrored) { + bulkSelectionStore.clear(); + goto('/documents'); + } +} + +async function save() { + if (saving) return; + saving = true; + try { + if (mode === 'edit') { + await saveBulkEdit(); + } else { + await saveUpload(); + } + } finally { + saving = false; + } +} + +async function retrySave() { + partialSaved = null; + await save(); +}
@@ -213,11 +333,11 @@ async function save() {
- {#if files.size === 0} - + {#if mode === 'upload' && files.size === 0} + - {:else} - + {:else if files.size > 0} +
{#if activeFile} @@ -243,22 +363,44 @@ async function save() { class:opacity-60={files.size === 0} class:pointer-events-none={files.size === 0} > + {#if mode === 'edit'} + +
+ {m.bulk_edit_hint()} +
+ {/if} + {#if isMulti} {#if activeFile} - + {#if mode === 'edit'} +
+ + {m.form_label_title()} + +

{activeFile.title}

+
+ {:else} + + {/if} {/if}
@@ -268,33 +410,51 @@ async function save() { bind:selectedReceivers={selectedReceivers} bind:dateIso={dateIso} initialSenderName={initialSenderName} + hideDate={mode === 'edit'} + editMode={mode === 'edit'} + /> + - {:else}
- + {#if mode === 'edit' && activeFile} +
+ + {m.form_label_title()} + +

{activeFile.title}

+
+ {:else} + + {/if}
- + + {/if} + + {#if partialSaved} + {/if}
diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts index 1b24627d..1f96216f 100644 --- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts @@ -312,3 +312,190 @@ describe('BulkDocumentEditLayout', () => { ); }); }); + +// ─── mode="edit" ───────────────────────────────────────────────────────────── + +describe('BulkDocumentEditLayout — mode="edit"', () => { + const editEntry = (i: number) => ({ + documentId: `doc-${i}`, + title: `Brief ${i}`, + pdfUrl: `/api/documents/doc-${i}/file` + }); + + it('does not render the BulkDropZone in edit mode', async () => { + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1)] + }); + expect(container.querySelector('[data-testid="bulk-drop-zone"]')).toBeNull(); + }); + + it('renders the onboarding callout with role=note in edit mode', async () => { + render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1)] + }); + const callout = page.getByTestId('bulk-edit-callout'); + await expect.element(callout).toBeInTheDocument(); + await expect.element(callout).toHaveAttribute('role', 'note'); + }); + + it('renders read-only title display (no input) in edit mode', async () => { + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1)] + }); + expect(container.querySelector('[data-testid="readonly-title"]')).not.toBeNull(); + // Per-file ScopeCard absent at N=1 — title rendered in the single card + const titleInput = container.querySelector('input[type="text"][value="Brief 1"]'); + expect(titleInput).toBeNull(); + }); + + it('hides the date field via WhoWhenSection hideDate prop', async () => { + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1)] + }); + expect(container.querySelector('[data-testid="who-when-date"]')).toBeNull(); + }); + + it('shows additive badge next to tags label', async () => { + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1)] + }); + expect(container.querySelector('[data-testid="field-label-badge-additive"]')).not.toBeNull(); + }); + + it('shows replace badges next to sender and archive fields', async () => { + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1)] + }); + const replaceBadges = container.querySelectorAll('[data-testid="field-label-badge-replace"]'); + // sender + documentLocation + archiveBox + archiveFolder = 4 + expect(replaceBadges.length).toBeGreaterThanOrEqual(4); + }); + + it('shows the archiveBox and archiveFolder bulk-only inputs', async () => { + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1)] + }); + expect(container.querySelector('[data-testid="description-archive-box"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull(); + }); + + it('save calls PATCH /api/documents/bulk in edit mode', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ updated: 2, errors: [] }) + }); + vi.stubGlobal('fetch', mockFetch); + + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1), editEntry(2)] + }); + + const saveBtn = container.querySelector( + 'button[data-testid="bulk-save-btn"]' + ) as HTMLButtonElement; + expect(saveBtn).not.toBeNull(); + saveBtn.click(); + + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 }); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/documents/bulk'); + expect(init.method).toBe('PATCH'); + const body = JSON.parse(init.body); + expect(body.documentIds).toEqual(['doc-1', 'doc-2']); + }); + + it('chunks IDs into 500-sized PATCH requests', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ updated: 500, errors: [] }) + }); + vi.stubGlobal('fetch', mockFetch); + + const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i)); + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: entries + }); + + const saveBtn = container.querySelector( + 'button[data-testid="bulk-save-btn"]' + ) as HTMLButtonElement; + saveBtn.click(); + + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(3), { timeout: 5000 }); + expect(JSON.parse(mockFetch.mock.calls[0][1].body).documentIds.length).toBe(500); + expect(JSON.parse(mockFetch.mock.calls[1][1].body).documentIds.length).toBe(500); + expect(JSON.parse(mockFetch.mock.calls[2][1].body).documentIds.length).toBe(100); + }); + + it('stops on chunk failure and shows the partial-failure alert with retry', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ updated: 500, errors: [] }) }) + .mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'INTERNAL_ERROR' }) }); + vi.stubGlobal('fetch', mockFetch); + + const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i)); + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: entries + }); + + const saveBtn = container.querySelector( + 'button[data-testid="bulk-save-btn"]' + ) as HTMLButtonElement; + saveBtn.click(); + + await vi.waitFor( + () => { + const alert = container.querySelector('[data-testid="bulk-edit-partial-failure"]'); + expect(alert).not.toBeNull(); + }, + { timeout: 5000 } + ); + // Should have called twice — chunks 0 and 1 — but not the third. + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(vi.mocked(goto)).not.toHaveBeenCalled(); + }); + + it('marks per-document error chips when service returns errors[]', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + updated: 1, + errors: [{ id: 'doc-2', message: 'Sender not found' }] + }) + }) + ); + + const { container } = render(BulkDocumentEditLayout, { + mode: 'edit', + initialEditEntries: [editEntry(1), editEntry(2)] + }); + + const saveBtn = container.querySelector( + 'button[data-testid="bulk-save-btn"]' + ) as HTMLButtonElement; + saveBtn.click(); + + await vi.waitFor( + () => { + const errorChip = container.querySelector( + '[data-testid="file-switcher-strip"] [data-chip-id="doc-2"][data-status="error"]' + ); + expect(errorChip).not.toBeNull(); + }, + { timeout: 3000 } + ); + }); +}); diff --git a/frontend/src/lib/components/document/DescriptionSection.svelte b/frontend/src/lib/components/document/DescriptionSection.svelte index 27e7442e..dcde5a7a 100644 --- a/frontend/src/lib/components/document/DescriptionSection.svelte +++ b/frontend/src/lib/components/document/DescriptionSection.svelte @@ -1,31 +1,45 @@
@@ -67,40 +81,78 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
-

{m.form_label_tags()}

+

+ {m.form_label_tags()} + {#if editMode}{/if} +

t.name).join(',')} />
- -
- - -
+ {#if !editMode} + +
+ + +
+ {/if} -
+
+ >{m.form_label_archive_location()} + {#if editMode}{/if} +

{m.form_helper_archive_location()}

+ + {#if editMode} + +
+ + +
+ + +
+ + +
+ {/if}
diff --git a/frontend/src/lib/components/document/FieldLabelBadge.svelte b/frontend/src/lib/components/document/FieldLabelBadge.svelte new file mode 100644 index 00000000..ac59e552 --- /dev/null +++ b/frontend/src/lib/components/document/FieldLabelBadge.svelte @@ -0,0 +1,16 @@ + + + + {text} + diff --git a/frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts b/frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts new file mode 100644 index 00000000..9895e0c0 --- /dev/null +++ b/frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import FieldLabelBadge from './FieldLabelBadge.svelte'; + +afterEach(() => cleanup()); + +describe('FieldLabelBadge', () => { + it('renders the additive variant text', async () => { + render(FieldLabelBadge, { variant: 'additive' }); + await expect.element(page.getByTestId('field-label-badge-additive')).toBeInTheDocument(); + await expect + .element(page.getByTestId('field-label-badge-additive')) + .toHaveTextContent('+ wird hinzugefügt'); + }); + + it('renders the replace variant text', async () => { + render(FieldLabelBadge, { variant: 'replace' }); + await expect + .element(page.getByTestId('field-label-badge-replace')) + .toHaveTextContent('wird ersetzt'); + }); + + it('uses text-gray-600 for WCAG-AA contrast on muted backgrounds', async () => { + render(FieldLabelBadge, { variant: 'replace' }); + await expect + .element(page.getByTestId('field-label-badge-replace')) + .toHaveClass(/text-gray-600/); + }); +}); diff --git a/frontend/src/lib/components/document/FileSwitcherStrip.svelte b/frontend/src/lib/components/document/FileSwitcherStrip.svelte index 8816d1a1..2ee272da 100644 --- a/frontend/src/lib/components/document/FileSwitcherStrip.svelte +++ b/frontend/src/lib/components/document/FileSwitcherStrip.svelte @@ -4,7 +4,11 @@ import { m } from '$lib/paraglide/messages.js'; export interface FileEntry { id: string; - file: File; + /** Present in upload mode only. Edit mode entries reference an existing + * document by `documentId` and have no local file blob. */ + file?: File; + /** Present in edit mode only — the server-side document UUID being edited. */ + documentId?: string; title: string; status: 'idle' | 'error'; previewUrl: string; diff --git a/frontend/src/lib/components/document/WhoWhenSection.svelte b/frontend/src/lib/components/document/WhoWhenSection.svelte index 679ecc84..6bfb2bb7 100644 --- a/frontend/src/lib/components/document/WhoWhenSection.svelte +++ b/frontend/src/lib/components/document/WhoWhenSection.svelte @@ -2,6 +2,7 @@ import { untrack } from 'svelte'; import PersonTypeahead from '$lib/components/PersonTypeahead.svelte'; import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte'; +import FieldLabelBadge from './FieldLabelBadge.svelte'; import { isoToGerman, handleGermanDateInput } from '$lib/utils/date'; import { m } from '$lib/paraglide/messages.js'; import type { components } from '$lib/generated/api'; @@ -16,7 +17,9 @@ let { initialLocation = '', initialSenderName = '', suggestedDateIso = '', - suggestedSenderName = '' + suggestedSenderName = '', + hideDate = false, + editMode = false }: { senderId?: string; selectedReceivers?: Person[]; @@ -26,6 +29,8 @@ let { initialSenderName?: string; suggestedDateIso?: string; suggestedSenderName?: string; + hideDate?: boolean; + editMode?: boolean; } = $props(); let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso))); @@ -56,60 +61,72 @@ $effect(() => {
- -
- - - - {#if dateInvalid} -

{m.form_date_error()}

- {/if} -
+ {#if !hideDate} + +
+ + + + {#if dateInvalid} +

{m.form_date_error()}

+ {/if} +
+ {/if} - +
-

{m.form_label_receivers()}

+

+ {m.form_label_receivers()} + {#if editMode}{/if} +

- -
- - -
+ {#if !editMode} + +
+ + +
+ {/if}