Compare commits
3 Commits
c78a1d69dc
...
6b10daeeac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b10daeeac | ||
|
|
74b473e3d7 | ||
|
|
f1b3e8c2d8 |
@@ -811,7 +811,6 @@
|
||||
"pagination_next": "Weiter",
|
||||
"pagination_page_of": "Seite {page} von {total}",
|
||||
"pagination_nav_label": "Seitennavigation",
|
||||
<<<<<<< HEAD
|
||||
|
||||
"common_opens_new_tab": "(öffnet in neuem Tab)",
|
||||
|
||||
|
||||
@@ -811,7 +811,6 @@
|
||||
"pagination_next": "Next",
|
||||
"pagination_page_of": "Page {page} of {total}",
|
||||
"pagination_nav_label": "Pagination",
|
||||
<<<<<<< HEAD
|
||||
|
||||
"common_opens_new_tab": "(opens in new tab)",
|
||||
|
||||
|
||||
@@ -811,7 +811,6 @@
|
||||
"pagination_next": "Siguiente",
|
||||
"pagination_page_of": "Página {page} de {total}",
|
||||
"pagination_nav_label": "Paginación",
|
||||
<<<<<<< HEAD
|
||||
|
||||
"common_opens_new_tab": "(abre en pestaña nueva)",
|
||||
|
||||
|
||||
@@ -105,14 +105,21 @@ async function save() {
|
||||
tagNames: tags.map((t) => t.name)
|
||||
};
|
||||
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
|
||||
// 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;
|
||||
const body = await res.json().catch(() => ({ errors: [] }));
|
||||
const errorCount = (body.errors ?? []).length;
|
||||
for (let j = 0; j < errorCount && j < chunk.length; j++) {
|
||||
const e = files.get(chunk[j].id);
|
||||
if (e) files.set(chunk[j].id, { ...e, status: 'error' });
|
||||
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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
chunkProgress = { done: i + 1, total: chunks.length };
|
||||
|
||||
@@ -159,6 +159,34 @@ describe('BulkDocumentEditLayout', () => {
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save marks only the file whose filename matches the backend error, not adjacent files', async () => {
|
||||
// backend returns error keyed to b.pdf — only b.pdf chip should get data-status="error"
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ 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 }
|
||||
);
|
||||
});
|
||||
|
||||
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')]);
|
||||
|
||||
@@ -13,6 +13,7 @@ let isDragging = $state(false);
|
||||
<div
|
||||
role="region"
|
||||
aria-label={m.bulk_drop_zone_label()}
|
||||
aria-describedby="bulk-drop-desc"
|
||||
data-testid="bulk-drop-zone"
|
||||
class="flex flex-1 flex-col items-center justify-center p-6"
|
||||
ondragover={(e) => {
|
||||
@@ -54,7 +55,7 @@ let isDragging = $state(false);
|
||||
<p class="font-serif text-base font-bold text-ink">{m.bulk_drop_hint()}</p>
|
||||
|
||||
<!-- Sub description -->
|
||||
<p class="text-sm leading-relaxed text-ink-2">{m.bulk_drop_desc()}</p>
|
||||
<p id="bulk-drop-desc" class="text-sm leading-relaxed text-ink-2">{m.bulk_drop_desc()}</p>
|
||||
|
||||
<!-- CTA button -->
|
||||
<label
|
||||
|
||||
@@ -102,6 +102,9 @@ $effect(() => {
|
||||
>{i + 1}</span
|
||||
>
|
||||
<span class="max-w-[8rem] truncate" title={entry.title}>{entry.title}</span>
|
||||
{#if entry.status === 'error'}
|
||||
<span aria-hidden="true" class="ml-0.5 font-extrabold text-red-600">!</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -19,6 +19,9 @@ let {
|
||||
<progress
|
||||
value={chunkProgress.done}
|
||||
max={chunkProgress.total}
|
||||
aria-valuenow={chunkProgress.done}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={chunkProgress.total}
|
||||
aria-label={m.bulk_upload_progress({ done: chunkProgress.done, total: chunkProgress.total })}
|
||||
class="[&::-webkit-progress-bar]:bg-brand-sand mb-3 h-1 w-full rounded-full [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-accent"
|
||||
></progress>
|
||||
|
||||
Reference in New Issue
Block a user