From 27e3d290e790df617d3b9a4b72bb8ccb6e57cf8d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 25 Apr 2026 15:03:59 +0200 Subject: [PATCH] feat(bulk-edit): add canWrite-gated row checkboxes on /documents and /enrich MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each row in the document search list and the enrichment queue gets a WCAG-compliant (44px touch target) checkbox bound to bulkSelectionStore. Checkbox click does not trigger the row's stretched-link navigation — it sits inside the z-10 content sibling, the link is in the z-0 sibling, so click events do not bubble between them. Refs #225 Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/DocumentRow.svelte | 18 +++++++- .../lib/components/DocumentRow.svelte.spec.ts | 41 +++++++++++++++++++ frontend/src/routes/DocumentList.svelte | 2 +- frontend/src/routes/enrich/+page.server.ts | 2 +- frontend/src/routes/enrich/+page.svelte | 24 +++++++++-- 5 files changed, 81 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/DocumentRow.svelte b/frontend/src/lib/components/DocumentRow.svelte index d4e2bd5c..2b656a65 100644 --- a/frontend/src/lib/components/DocumentRow.svelte +++ b/frontend/src/lib/components/DocumentRow.svelte @@ -4,13 +4,14 @@ import type { components } from '$lib/generated/api'; import { applyOffsets } from '$lib/search'; import { formatDate } from '$lib/utils/date'; import * as m from '$lib/paraglide/messages.js'; +import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte'; import ProgressRing from './ProgressRing.svelte'; import ContributorStack from './ContributorStack.svelte'; import DocumentThumbnail from './DocumentThumbnail.svelte'; type DocumentSearchItem = components['schemas']['DocumentSearchItem']; -let { item }: { item: DocumentSearchItem } = $props(); +let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $props(); const doc = $derived(item.document); const titleText = $derived(doc.title || doc.originalFilename); @@ -55,6 +56,21 @@ function safeTagColor(color: string | null | undefined): string {
+ + {#if canWrite} + + {/if} diff --git a/frontend/src/lib/components/DocumentRow.svelte.spec.ts b/frontend/src/lib/components/DocumentRow.svelte.spec.ts index 274062a6..f7d0b92a 100644 --- a/frontend/src/lib/components/DocumentRow.svelte.spec.ts +++ b/frontend/src/lib/components/DocumentRow.svelte.spec.ts @@ -3,6 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import { goto } from '$app/navigation'; import DocumentRow from './DocumentRow.svelte'; +import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte'; import type { components } from '$lib/generated/api'; vi.mock('$app/navigation', () => ({ goto: vi.fn() })); @@ -10,6 +11,7 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() })); afterEach(() => { cleanup(); vi.mocked(goto).mockClear(); + bulkSelectionStore.clear(); }); type DocumentSearchItem = components['schemas']['DocumentSearchItem']; @@ -265,6 +267,45 @@ describe('DocumentRow – tags', () => { }); }); +// ─── Bulk-selection checkbox ───────────────────────────────────────────────── + +describe('DocumentRow – bulk selection checkbox', () => { + it('does not render the checkbox when canWrite is false', async () => { + render(DocumentRow, { item: makeItem(), canWrite: false }); + await expect.element(page.getByTestId('bulk-select-checkbox')).not.toBeInTheDocument(); + }); + + it('renders the checkbox when canWrite is true', async () => { + render(DocumentRow, { item: makeItem(), canWrite: true }); + await expect.element(page.getByTestId('bulk-select-checkbox')).toBeInTheDocument(); + }); + + it('checkbox aria-label includes the document title', async () => { + const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } }); + render(DocumentRow, { item, canWrite: true }); + await expect + .element(page.getByRole('checkbox', { name: /Brief an Anna/i })) + .toBeInTheDocument(); + }); + + it('toggling the checkbox calls bulkSelectionStore.toggle', async () => { + const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } }); + render(DocumentRow, { item, canWrite: true }); + expect(bulkSelectionStore.has('doc-42')).toBe(false); + + document.querySelector('input[type="checkbox"]')?.click(); + + await expect.poll(() => bulkSelectionStore.has('doc-42')).toBe(true); + }); + + it('checked state mirrors the store', async () => { + bulkSelectionStore.add('doc-99'); + const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } }); + render(DocumentRow, { item, canWrite: true }); + await expect.element(page.getByRole('checkbox')).toBeChecked(); + }); +}); + // ─── ProgressRing & ContributorStack ───────────────────────────────────────── describe('DocumentRow – progress ring and contributors', () => { diff --git a/frontend/src/routes/DocumentList.svelte b/frontend/src/routes/DocumentList.svelte index 74478c66..d982a46a 100644 --- a/frontend/src/routes/DocumentList.svelte +++ b/frontend/src/routes/DocumentList.svelte @@ -119,7 +119,7 @@ function groupByReceiver(docItems: DocumentSearchItem[]) {
    {#each group.items as item (group.label + '-' + item.document.id)} - + {/each}
diff --git a/frontend/src/routes/enrich/+page.server.ts b/frontend/src/routes/enrich/+page.server.ts index 7cb4ea02..65d86b8f 100644 --- a/frontend/src/routes/enrich/+page.server.ts +++ b/frontend/src/routes/enrich/+page.server.ts @@ -19,5 +19,5 @@ export async function load({ const documents = result.response.ok ? (result.data ?? []) : []; - return { documents }; + return { documents, canWrite }; } diff --git a/frontend/src/routes/enrich/+page.svelte b/frontend/src/routes/enrich/+page.svelte index b2130465..e6457afd 100644 --- a/frontend/src/routes/enrich/+page.svelte +++ b/frontend/src/routes/enrich/+page.svelte @@ -1,11 +1,13 @@
@@ -61,8 +63,24 @@ const count = $derived(documents.length);
    {#each documents as doc (doc.id)} -
  • - +
  • + +
    + {#if canWrite} + + {/if}

    {doc.title} @@ -74,7 +92,7 @@ const count = $derived(documents.length); aria-hidden="true" class="ml-4 h-5 w-5 shrink-0 opacity-30 transition-opacity group-hover:opacity-70" /> - +

  • {/each}