feat(bulk-edit): add BulkSelectionBar and Alle-X-editieren fast path
- BulkSelectionBar component: sticky bottom bar shown only when canWrite and selection is non-empty. Buttons meet WCAG 44px touch targets and iOS safe-area inset is honoured. - Bar mounted on /documents and /enrich. - Alle X editieren button on /documents replaces the selection with every UUID matching the active filter (via /api/documents/ids) and jumps to /documents/bulk-edit. Refs #225 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
46
frontend/src/lib/components/document/BulkSelectionBar.svelte
Normal file
46
frontend/src/lib/components/document/BulkSelectionBar.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
|
||||
let { canWrite }: { canWrite: boolean } = $props();
|
||||
|
||||
const count = $derived(bulkSelectionStore.size);
|
||||
|
||||
function openBulkEdit() {
|
||||
goto('/documents/bulk-edit');
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
bulkSelectionStore.clear();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if canWrite && count > 0}
|
||||
<div
|
||||
data-testid="bulk-selection-bar"
|
||||
class="fixed right-0 bottom-0 left-0 z-30 flex items-center justify-between border-t border-line bg-surface px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:px-6"
|
||||
>
|
||||
<span class="font-sans text-sm font-medium text-ink" data-testid="bulk-selection-count">
|
||||
{m.bulk_edit_n_selected({ count })}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearAll}
|
||||
class="inline-flex min-h-[44px] items-center px-4 py-2 font-sans text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
data-testid="bulk-clear-all"
|
||||
>
|
||||
{m.bulk_edit_clear_all()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openBulkEdit}
|
||||
class="inline-flex min-h-[44px] items-center bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
|
||||
data-testid="bulk-edit-open"
|
||||
>
|
||||
{m.bulk_edit_button()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
<div class="mb-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onclick={editAllMatching}
|
||||
disabled={editingAll}
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
|
||||
data-testid="bulk-edit-all-x"
|
||||
>
|
||||
{m.bulk_edit_all_x({ count: data.totalElements })}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<DocumentList
|
||||
items={data.items}
|
||||
total={data.totalElements}
|
||||
@@ -192,3 +247,5 @@ $effect(() => {
|
||||
|
||||
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
||||
</main>
|
||||
|
||||
<BulkSelectionBar canWrite={data.canWrite} />
|
||||
|
||||
@@ -99,3 +99,5 @@ const canWrite = $derived(data.canWrite);
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<BulkSelectionBar canWrite={canWrite} />
|
||||
|
||||
Reference in New Issue
Block a user