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 SearchFilterBar from '../SearchFilterBar.svelte';
|
||||||
import DocumentList from '../DocumentList.svelte';
|
import DocumentList from '../DocumentList.svelte';
|
||||||
import Pagination from '$lib/components/Pagination.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';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let { data } = $props();
|
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.
|
// Keep local filter state in sync with server data after navigation completes.
|
||||||
// Guard q: skip overwrite while the user is actively typing.
|
// Guard q: skip overwrite while the user is actively typing.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -181,6 +222,20 @@ $effect(() => {
|
|||||||
onblur={() => (qFocused = false)}
|
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
|
<DocumentList
|
||||||
items={data.items}
|
items={data.items}
|
||||||
total={data.totalElements}
|
total={data.totalElements}
|
||||||
@@ -192,3 +247,5 @@ $effect(() => {
|
|||||||
|
|
||||||
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<BulkSelectionBar canWrite={data.canWrite} />
|
||||||
|
|||||||
@@ -99,3 +99,5 @@ const canWrite = $derived(data.canWrite);
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BulkSelectionBar canWrite={canWrite} />
|
||||||
|
|||||||
Reference in New Issue
Block a user