From 3a6a70a1f77372ca49d569d45e25383cb16a2e64 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 24 Apr 2026 17:57:33 +0200 Subject: [PATCH] feat(bulk-upload): add BulkDocumentEditLayout component with save handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State-owner for the bulk upload flow: - N=0: full-panel BulkDropZone - N=1: title + shared metadata (no switcher/scope cards) - N≥2: FileSwitcherStrip + per-file ScopeCard + shared ScopeCard Save handler chunks files at 10/request, POSTs to /api/documents/quick-upload with typed metadata JSON part, tracks progress, redirects to /documents. Co-Authored-By: Claude Sonnet 4.6 --- .../document/BulkDocumentEditLayout.svelte | 190 ++++++++++++++++++ .../BulkDocumentEditLayout.svelte.spec.ts | 99 +++++++++ .../lib/components/document/ScopeCard.svelte | 1 + .../components/document/UploadSaveBar.svelte | 1 + 4 files changed, 291 insertions(+) create mode 100644 frontend/src/lib/components/document/BulkDocumentEditLayout.svelte create mode 100644 frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte new file mode 100644 index 00000000..8a280226 --- /dev/null +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte @@ -0,0 +1,190 @@ + + +{#if files.size === 0} + +
+ +
+{:else} +
+ {#if isMulti} + (activeId = id)} + onRemove={removeFile} + /> + {/if} + +
+ + + + +
+ {#if isMulti} + + + {#if activeFile} + + {/if} + + + + + + + + {:else} + + {#if activeFile} +
+ +
+ {/if} +
+ + +
+ {/if} +
+
+ + { + files.clear(); + activeId = null; + chunkProgress = undefined; + }} + /> +
+{/if} diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts new file mode 100644 index 00000000..e063094d --- /dev/null +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte'; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function makeFile(name: string): File { + return new File(['content'], name, { type: 'application/pdf' }); +} + +async function addFilesViaInput(container: HTMLElement, files: File[]): Promise { + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + if (!input) throw new Error('No file input found — is BulkDropZone visible?'); + await userEvent.upload(input, files); +} + +describe('BulkDocumentEditLayout', () => { + it('N=0: shows BulkDropZone', async () => { + render(BulkDocumentEditLayout, {}); + await expect.element(page.getByTestId('bulk-drop-zone')).toBeInTheDocument(); + }); + + it('N=1: file-switcher-strip and per-file scope card are absent', async () => { + const { container } = render(BulkDocumentEditLayout, {}); + await addFilesViaInput(container, [makeFile('doc.pdf')]); + expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull(); + expect(container.querySelector('[data-variant="per-file"]')).toBeNull(); + }); + + it('N=5: file-switcher-strip and per-file scope card are both present', async () => { + const { container } = render(BulkDocumentEditLayout, {}); + await addFilesViaInput(container, [ + makeFile('a.pdf'), + makeFile('b.pdf'), + makeFile('c.pdf'), + makeFile('d.pdf'), + makeFile('e.pdf') + ]); + expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull(); + expect(container.querySelector('[data-variant="per-file"]')).not.toBeNull(); + }); + + it('removing middle file preserves order of remaining files', async () => { + const { container } = render(BulkDocumentEditLayout, {}); + await addFilesViaInput(container, [ + makeFile('file0.pdf'), + makeFile('file1.pdf'), + makeFile('file2.pdf') + ]); + + // Remove the chip for file1 via its "Entfernen" remove button (second × button) + const removeButtons = container.querySelectorAll( + '[data-testid="file-switcher-strip"] button[aria-label="Entfernen"]' + ); + expect(removeButtons.length).toBe(3); + removeButtons[1].click(); // remove file1 + + // Wait for Svelte to flush the DOM update + await vi.waitFor( + () => { + const chips = container.querySelectorAll( + '[data-testid="file-switcher-strip"] [data-chip-id]' + ); + expect(chips.length).toBe(2); + expect(chips[0].textContent?.trim()).toContain('file0'); + expect(chips[1].textContent?.trim()).toContain('file2'); + }, + { timeout: 1000 } + ); + }); + + it('save calls fetch twice for 12 files (2 chunks of 10)', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ created: [], updated: [], errors: [] }) + }); + vi.stubGlobal('fetch', mockFetch); + + // Also stub goto to prevent navigation errors in test + vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + + const { container } = render(BulkDocumentEditLayout, {}); + const files = Array.from({ length: 12 }, (_, i) => makeFile(`f${i}.pdf`)); + await addFilesViaInput(container, files); + + const saveBtn = container.querySelector( + 'button[data-testid="bulk-save-btn"]' + ) as HTMLButtonElement; + expect(saveBtn).not.toBeNull(); + saveBtn.click(); + + // Wait for async save to complete + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2), { timeout: 3000 }); + }); +}); diff --git a/frontend/src/lib/components/document/ScopeCard.svelte b/frontend/src/lib/components/document/ScopeCard.svelte index d0e7c5c7..ebdbe82a 100644 --- a/frontend/src/lib/components/document/ScopeCard.svelte +++ b/frontend/src/lib/components/document/ScopeCard.svelte @@ -12,6 +12,7 @@ let {