Compare commits
7 Commits
9aed929b67
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50621f9a15 | ||
|
|
1fca1f80a2 | ||
|
|
46dae8a826 | ||
|
|
e5fe2fc5c6 | ||
|
|
0ab85d888b | ||
|
|
48c82aa07b | ||
|
|
1299f191e2 |
@@ -134,6 +134,7 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
public void validateBatch(int fileCount, DocumentBatchMetadataDTO metadata) {
|
||||
// 50-file hard cap keeps FormData requests at a manageable size and protects against runaway bulk uploads.
|
||||
if (fileCount > 50) {
|
||||
throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request");
|
||||
}
|
||||
|
||||
@@ -858,6 +858,7 @@
|
||||
"bulk_save_cta_one": "Speichern →",
|
||||
"bulk_save_cta": "{count} speichern →",
|
||||
"bulk_discard_all": "Alle verwerfen",
|
||||
"bulk_discard_confirm": "Alle Dateien und eingegebenen Daten verwerfen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"bulk_add_more": "Weitere hinzufügen",
|
||||
"bulk_scope_per_file_label": "Nur diese Datei",
|
||||
"bulk_scope_shared_label": "Gilt für alle {count}",
|
||||
|
||||
@@ -858,6 +858,7 @@
|
||||
"bulk_save_cta_one": "Save →",
|
||||
"bulk_save_cta": "Save {count} →",
|
||||
"bulk_discard_all": "Discard all",
|
||||
"bulk_discard_confirm": "Discard all files and entered data? This action cannot be undone.",
|
||||
"bulk_add_more": "Add more",
|
||||
"bulk_scope_per_file_label": "This file only",
|
||||
"bulk_scope_shared_label": "Applies to all {count}",
|
||||
|
||||
@@ -858,6 +858,7 @@
|
||||
"bulk_save_cta_one": "Guardar →",
|
||||
"bulk_save_cta": "Guardar {count} →",
|
||||
"bulk_discard_all": "Descartar todo",
|
||||
"bulk_discard_confirm": "¿Descartar todos los archivos y datos introducidos? Esta acción no se puede deshacer.",
|
||||
"bulk_add_more": "Añadir más",
|
||||
"bulk_scope_per_file_label": "Solo este archivo",
|
||||
"bulk_scope_shared_label": "Para todos los {count}",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { SvelteMap } from 'svelte/reactivity';
|
||||
import { goto } from '$app/navigation';
|
||||
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 BulkDropZone from './BulkDropZone.svelte';
|
||||
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
||||
import type { FileEntry } from './FileSwitcherStrip.svelte';
|
||||
@@ -17,6 +19,14 @@ import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
// Optional — not available in unit tests that don't provide CONFIRM_KEY context.
|
||||
let _confirmService: ConfirmService | null;
|
||||
try {
|
||||
_confirmService = getConfirmService();
|
||||
} catch {
|
||||
_confirmService = null;
|
||||
}
|
||||
|
||||
let {
|
||||
initialSenderId = '',
|
||||
initialSenderName = '',
|
||||
@@ -31,6 +41,7 @@ let {
|
||||
let files = new SvelteMap<string, FileEntry>();
|
||||
let activeId = $state<string | null>(null);
|
||||
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
|
||||
let saving = $state(false);
|
||||
|
||||
// --- Shared metadata ---
|
||||
let senderId = $state(untrack(() => initialSenderId));
|
||||
@@ -76,6 +87,18 @@ function discardAll() {
|
||||
chunkProgress = undefined;
|
||||
}
|
||||
|
||||
async function handleDiscard() {
|
||||
if (_confirmService) {
|
||||
const ok = await _confirmService.confirm({
|
||||
title: m.bulk_discard_all(),
|
||||
body: m.bulk_discard_confirm(),
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
discardAll();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
for (const entry of files.values()) {
|
||||
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||
@@ -84,7 +107,10 @@ onDestroy(() => {
|
||||
|
||||
// --- Save ---
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
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;
|
||||
const chunks: FileEntry[][] = [];
|
||||
for (let i = 0; i < entries.length; i += chunkSize) {
|
||||
@@ -108,22 +134,33 @@ async function save() {
|
||||
// Raw fetch is intentional: SvelteKit form actions can't stream chunked
|
||||
// FormData with per-chunk progress. Session cookie is sent automatically
|
||||
// by the browser for same-origin requests.
|
||||
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
|
||||
if (!res.ok) {
|
||||
hadErrors = true;
|
||||
try {
|
||||
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
|
||||
const body = await res.json().catch(() => ({ errors: [] }));
|
||||
const errorFilenames = new Set<string>(
|
||||
(body.errors ?? []).map((err: { filename: string }) => err.filename)
|
||||
);
|
||||
for (const entry of chunk) {
|
||||
if (errorFilenames.has(entry.file.name)) {
|
||||
const e = files.get(entry.id);
|
||||
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||
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;
|
||||
if (isError) {
|
||||
const e = files.get(entry.id);
|
||||
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
hadErrors = true;
|
||||
for (const entry of chunk) {
|
||||
const e = files.get(entry.id);
|
||||
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||
}
|
||||
}
|
||||
chunkProgress = { done: i + 1, total: chunks.length };
|
||||
}
|
||||
saving = false;
|
||||
if (!hadErrors) goto('/documents');
|
||||
}
|
||||
</script>
|
||||
@@ -163,7 +200,7 @@ async function save() {
|
||||
<button
|
||||
type="button"
|
||||
data-testid="discard-all-btn"
|
||||
onclick={discardAll}
|
||||
onclick={handleDiscard}
|
||||
class="text-xs font-medium text-red-600/70 hover:text-red-700"
|
||||
>
|
||||
{m.bulk_discard_all()}
|
||||
@@ -275,7 +312,8 @@ async function save() {
|
||||
fileCount={files.size}
|
||||
chunkProgress={chunkProgress}
|
||||
onSave={save}
|
||||
onDiscard={discardAll}
|
||||
onDiscard={handleDiscard}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { goto } from '$app/navigation';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
|
||||
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
@@ -206,6 +207,112 @@ describe('BulkDocumentEditLayout', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('save() marks only the failed file when server returns HTTP 200 with a partial errors array', async () => {
|
||||
// Backend can return 200 OK while reporting individual file failures
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
created: [{ id: '1' }],
|
||||
updated: [],
|
||||
errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }]
|
||||
})
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||
expect(errorChips.length).toBe(1);
|
||||
expect(errorChips[0].textContent).toContain('b');
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
// Navigation should be suppressed because hadErrors is true
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save() marks all chunk files as errored when fetch throws a network error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')));
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||
expect(errorChips.length).toBe(2);
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save() does not call fetch a second time when already saving', async () => {
|
||||
let resolveFirst: (() => void) | undefined;
|
||||
const mockFetch = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFirst = () =>
|
||||
resolve({
|
||||
ok: true,
|
||||
json: async () => ({ created: [], updated: [], errors: [] })
|
||||
} as Response);
|
||||
})
|
||||
);
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click(); // first click — fetch is in-flight
|
||||
saveBtn.click(); // second click — should be a no-op
|
||||
|
||||
resolveFirst?.();
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('discard-all does not clear files when the user cancels the confirm dialog', async () => {
|
||||
const service = createConfirmService();
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
context: new Map([[CONFIRM_KEY, service]])
|
||||
});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||
|
||||
const discardBtn = container.querySelector(
|
||||
'button[data-testid="discard-all-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
discardBtn.click();
|
||||
|
||||
// The confirm dialog should open (service.options not null)
|
||||
await vi.waitFor(() => expect(service.options).not.toBeNull(), { timeout: 1000 });
|
||||
|
||||
// Cancel — files should remain
|
||||
service.settle(false);
|
||||
await vi.waitFor(
|
||||
() => expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull(),
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('discard-all resets to N=0 state and shows drop zone', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
export interface FileEntry {
|
||||
@@ -33,6 +34,15 @@ function scrollNext() {
|
||||
trackEl?.scrollBy({ left: 120, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
async function handleRemove(entry: FileEntry, index: number) {
|
||||
const targetId = index > 0 ? files[index - 1].id : (files[index + 1]?.id ?? null);
|
||||
onRemove(entry.id);
|
||||
if (targetId) {
|
||||
await tick();
|
||||
(listEl?.querySelector<HTMLElement>(`[data-chip-id="${targetId}"]`) ?? null)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!listEl) return;
|
||||
const node = listEl;
|
||||
@@ -73,19 +83,27 @@ $effect(() => {
|
||||
>‹</button
|
||||
>
|
||||
|
||||
<div bind:this={trackEl} class="flex flex-1 gap-1 overflow-x-auto" style="scrollbar-width:none">
|
||||
<ul bind:this={listEl} role="list" class="flex flex-row gap-1 py-1">
|
||||
{#each files as entry, i (entry.id)}
|
||||
<li role="listitem" class="inline-flex shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
aria-current={entry.id === activeId ? 'true' : undefined}
|
||||
data-status={entry.status}
|
||||
data-chip-id={entry.id}
|
||||
onclick={() => onSelect(entry.id)}
|
||||
class={[
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-[2px] px-1.5 py-0.5 text-[11px] font-bold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent',
|
||||
<!-- Gradient fade overlays signal hidden overflow to pointer-only users -->
|
||||
<div class="relative flex flex-1 overflow-hidden">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r from-pdf-ctrl to-transparent"
|
||||
></div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l from-pdf-ctrl to-transparent"
|
||||
></div>
|
||||
<div bind:this={trackEl} class="flex flex-1 gap-1 overflow-x-auto" style="scrollbar-width:none">
|
||||
<ul bind:this={listEl} role="list" class="flex flex-row gap-1 py-1">
|
||||
{#each files as entry, i (entry.id)}
|
||||
<li role="listitem" class="inline-flex shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
aria-current={entry.id === activeId ? 'true' : undefined}
|
||||
data-status={entry.status}
|
||||
data-chip-id={entry.id}
|
||||
onclick={() => onSelect(entry.id)}
|
||||
class={[
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-[2px] px-1.5 py-0.5 text-xs font-bold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent',
|
||||
entry.id === activeId
|
||||
? 'bg-accent text-primary'
|
||||
: 'bg-black/[0.06] text-ink-2 hover:bg-black/10',
|
||||
@@ -93,32 +111,33 @@ $effect(() => {
|
||||
? '!border !border-dashed !border-red-400 !bg-red-50/80 !text-red-700'
|
||||
: ''
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
class={[
|
||||
'rounded-[2px] px-0.5 text-[9px] font-extrabold opacity-85',
|
||||
>
|
||||
<span
|
||||
class={[
|
||||
'rounded-[2px] px-0.5 text-[11px] font-extrabold opacity-85',
|
||||
entry.id === activeId ? 'bg-black/20' : 'bg-black/10'
|
||||
].join(' ')}
|
||||
>{i + 1}</span
|
||||
>{i + 1}</span
|
||||
>
|
||||
<span class="max-w-[8rem] truncate" title={entry.title}>{entry.title}</span>
|
||||
{#if entry.status === 'error'}
|
||||
<span class="sr-only">{m.bulk_file_error_chip_label()}</span>
|
||||
<span aria-hidden="true" class="ml-0.5 font-extrabold text-red-600">!</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.bulk_remove_file()}
|
||||
data-remove-id={entry.id}
|
||||
onclick={() => handleRemove(entry, i)}
|
||||
class="ml-0.5 flex h-[44px] w-[44px] items-center justify-center text-base text-ink-3 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<span class="max-w-[8rem] truncate" title={entry.title}>{entry.title}</span>
|
||||
{#if entry.status === 'error'}
|
||||
<span class="sr-only">{m.bulk_file_error_chip_label()}</span>
|
||||
<span aria-hidden="true" class="ml-0.5 font-extrabold text-red-600">!</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.bulk_remove_file()}
|
||||
data-remove-id={entry.id}
|
||||
onclick={() => onRemove(entry.id)}
|
||||
class="ml-0.5 flex h-[44px] w-[44px] items-center justify-center text-base text-ink-3 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -99,6 +99,32 @@ describe('FileSwitcherStrip', () => {
|
||||
expect(srOnly).not.toBeNull();
|
||||
});
|
||||
|
||||
it('focus moves to the previous chip after the middle chip is removed', async () => {
|
||||
const files = makeFiles(3); // id-0, id-1, id-2
|
||||
const onRemove = vi.fn();
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: files[1].id,
|
||||
onSelect: vi.fn(),
|
||||
onRemove
|
||||
});
|
||||
|
||||
const removeBtn = container.querySelector('[data-remove-id="id-1"]') as HTMLButtonElement;
|
||||
expect(removeBtn).not.toBeNull();
|
||||
removeBtn.click();
|
||||
expect(onRemove).toHaveBeenCalledWith('id-1');
|
||||
|
||||
// After removal, focus should be on the chip for id-0 (the previous chip)
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const prevChip = container.querySelector('[data-chip-id="id-0"]') as HTMLElement | null;
|
||||
expect(prevChip).not.toBeNull();
|
||||
expect(document.activeElement).toBe(prevChip);
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('ArrowRight moves focus to next chip without leaving strip', async () => {
|
||||
const files = makeFiles(3);
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
|
||||
@@ -5,12 +5,14 @@ let {
|
||||
fileCount,
|
||||
chunkProgress,
|
||||
onSave,
|
||||
onDiscard
|
||||
onDiscard,
|
||||
disabled = false
|
||||
}: {
|
||||
fileCount: number;
|
||||
chunkProgress?: { done: number; total: number };
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
onDiscard: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
@@ -37,7 +39,7 @@ let {
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bulk-save-btn"
|
||||
disabled={fileCount === 0}
|
||||
disabled={fileCount === 0 || disabled}
|
||||
onclick={onSave}
|
||||
class="min-h-[44px] rounded-sm bg-primary px-6 text-sm font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user