diff --git a/frontend/src/lib/components/document/BulkSelectionBar.svelte b/frontend/src/lib/components/document/BulkSelectionBar.svelte new file mode 100644 index 00000000..5a16897c --- /dev/null +++ b/frontend/src/lib/components/document/BulkSelectionBar.svelte @@ -0,0 +1,46 @@ + + +{#if canWrite && count > 0} +
+ + {m.bulk_edit_n_selected({ count })} + +
+ + +
+
+{/if} diff --git a/frontend/src/lib/components/document/BulkSelectionBar.svelte.spec.ts b/frontend/src/lib/components/document/BulkSelectionBar.svelte.spec.ts new file mode 100644 index 00000000..649220e1 --- /dev/null +++ b/frontend/src/lib/components/document/BulkSelectionBar.svelte.spec.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import { goto } from '$app/navigation'; +import BulkSelectionBar from './BulkSelectionBar.svelte'; +import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte'; + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +afterEach(() => { + cleanup(); + vi.mocked(goto).mockClear(); + bulkSelectionStore.clear(); +}); + +describe('BulkSelectionBar', () => { + it('does not render when canWrite is false', async () => { + bulkSelectionStore.add('a'); + render(BulkSelectionBar, { canWrite: false }); + await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument(); + }); + + it('does not render when selection is empty', async () => { + render(BulkSelectionBar, { canWrite: true }); + await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument(); + }); + + it('renders with the current selection count', async () => { + bulkSelectionStore.add('a'); + bulkSelectionStore.add('b'); + render(BulkSelectionBar, { canWrite: true }); + await expect.element(page.getByTestId('bulk-selection-count')).toHaveTextContent('2'); + }); + + it('clear button empties the store', async () => { + bulkSelectionStore.add('a'); + bulkSelectionStore.add('b'); + render(BulkSelectionBar, { canWrite: true }); + await page.getByTestId('bulk-clear-all').click(); + expect(bulkSelectionStore.size).toBe(0); + }); + + it('Massenbearbeitung navigates to /documents/bulk-edit', async () => { + bulkSelectionStore.add('a'); + render(BulkSelectionBar, { canWrite: true }); + await page.getByTestId('bulk-edit-open').click(); + expect(vi.mocked(goto)).toHaveBeenCalledWith('/documents/bulk-edit'); + }); +}); diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 80159e25..4bc00f64 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -6,6 +6,8 @@ import { SvelteURLSearchParams } from 'svelte/reactivity'; import SearchFilterBar from '../SearchFilterBar.svelte'; import DocumentList from '../DocumentList.svelte'; import Pagination from '$lib/components/Pagination.svelte'; +import BulkSelectionBar from '$lib/components/document/BulkSelectionBar.svelte'; +import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte'; import * as m from '$lib/paraglide/messages.js'; let { data } = $props(); @@ -138,6 +140,45 @@ $effect(() => { } }); +let editingAll = $state(false); + +/** + * Fast path: replace the current selection with every document matching the + * active filter (across all pages) and jump to the bulk-edit screen. The + * /api/documents/ids endpoint is uncapped — chunking happens at PATCH time + * inside the bulk-edit page's save handler. + */ +async function editAllMatching() { + if (editingAll) return; + editingAll = true; + try { + const params = buildSearchParams({ + q: data.q || '', + from: data.from || '', + to: data.to || '', + senderId: data.senderId || '', + receiverId: data.receiverId || '', + tags: data.tags || [], + sort: '', + dir: '', + tagQ: data.tagQ || '', + tagOp: (data.tagOp as 'AND' | 'OR') || 'AND' + }); + params.delete('sort'); + params.delete('dir'); + const res = await fetch(`/api/documents/ids?${params.toString()}`); + if (!res.ok) { + editingAll = false; + return; + } + const ids: string[] = await res.json(); + bulkSelectionStore.setAll(ids); + await goto('/documents/bulk-edit'); + } finally { + editingAll = false; + } +} + // Keep local filter state in sync with server data after navigation completes. // Guard q: skip overwrite while the user is actively typing. $effect(() => { @@ -181,6 +222,20 @@ $effect(() => { onblur={() => (qFocused = false)} /> + {#if data.canWrite && data.totalElements > 0} +
+ +
+ {/if} + { + + diff --git a/frontend/src/routes/enrich/+page.svelte b/frontend/src/routes/enrich/+page.svelte index e6457afd..dce475a8 100644 --- a/frontend/src/routes/enrich/+page.svelte +++ b/frontend/src/routes/enrich/+page.svelte @@ -99,3 +99,5 @@ const canWrite = $derived(data.canWrite); {/if} + +