Compare commits

..

2 Commits

Author SHA1 Message Date
Marcel
c641d704a8 merge: resolve conflict with origin/main + fix WCAG AA contrast + add API test
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m27s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 3m3s
CI / Unit & Component Tests (pull_request) Failing after 3m16s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 2m57s
- Merge origin/main (resolved conflict in +page.svelte: use res.ok check from main)
- fix(transcription): bump button text from text-brand-navy/60 (3.83:1) to
  text-brand-navy/80 (6.75:1) to pass WCAG AA 4.5:1 for 12px text
- feat(api-tests): add Transcription.http with PUT /review-all entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:11:58 +02:00
Marcel
69ac183fe8 feat(transcription): add bulk "Alle als fertig markieren" action to transcription panel
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m31s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 3m1s
CI / Unit & Component Tests (pull_request) Failing after 3m13s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 2m57s
Adds a single-transaction backend endpoint PUT /api/documents/{id}/transcription-blocks/review-all
that marks all blocks as reviewed atomically. Emits N individual BLOCK_REVIEWED audit events (one
per previously-unreviewed block). The frontend button is disabled (not hidden) when all blocks are
already reviewed, and shows a spinner during the operation.

Closes #345

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:53:47 +02:00
12 changed files with 328 additions and 484 deletions

View File

@@ -0,0 +1,3 @@
### Mark all blocks as reviewed
PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all
Authorization: Basic admin admin123

View File

@@ -90,6 +90,15 @@ public class TranscriptionBlockController {
return transcriptionService.reviewBlock(documentId, blockId, userId); return transcriptionService.reviewBlock(documentId, blockId, userId);
} }
@PutMapping("/review-all")
@RequirePermission(Permission.WRITE_ALL)
public List<TranscriptionBlock> markAllBlocksReviewed(
@PathVariable UUID documentId,
Authentication authentication) {
UUID userId = requireUserId(authentication);
return transcriptionService.markAllBlocksReviewed(documentId, userId);
}
@GetMapping("/{blockId}/history") @GetMapping("/{blockId}/history")
@RequirePermission(Permission.READ_ALL) @RequirePermission(Permission.READ_ALL)
public List<TranscriptionBlockVersion> getBlockHistory( public List<TranscriptionBlockVersion> getBlockHistory(

View File

@@ -205,6 +205,18 @@ public class TranscriptionService {
return saved; return saved;
} }
@Transactional
public List<TranscriptionBlock> markAllBlocksReviewed(UUID documentId, UUID userId) {
List<TranscriptionBlock> blocks = blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
for (TranscriptionBlock block : blocks) {
if (!block.isReviewed()) {
block.setReviewed(true);
auditService.logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, documentId, null);
}
}
return blockRepository.saveAll(blocks);
}
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) { public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
getBlock(documentId, blockId); getBlock(documentId, blockId);
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId); return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);

View File

@@ -380,4 +380,63 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.reviewed").value(true)); .andExpect(jsonPath("$.reviewed").value(true));
} }
// ─── PUT .../review-all ───────────────────────────────────────────────────
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";
@Test
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void markAllBlocksReviewed_returns200_withAllReviewedBlocks_whenAuthorised() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
TranscriptionBlock b1 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
.text("Block 1").sortOrder(0).reviewed(true).build();
TranscriptionBlock b2 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
.text("Block 2").sortOrder(1).reviewed(true).build();
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
.thenReturn(List.of(b1, b2));
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].reviewed").value(true))
.andExpect(jsonPath("$[1].reviewed").value(true));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void markAllBlocksReviewed_returns200_withEmptyList_whenNoBlocksExist() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
.thenReturn(List.of());
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$").isEmpty());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
when(userService.findByEmail(any())).thenReturn(null);
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isUnauthorized());
}
} }

View File

@@ -506,4 +506,86 @@ class TranscriptionServiceTest {
verify(auditService, never()).logAfterCommit(any(), any(), any(), any()); verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
} }
// ─── markAllBlocksReviewed ───────────────────────────────────────────────────
@Test
void markAllBlocksReviewed_setsAllUnreviewedBlocksToReviewed() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock block1 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
TranscriptionBlock block2 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(block1, block2));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
verify(blockRepository).saveAll(List.of(block1, block2));
}
@Test
void markAllBlocksReviewed_isIdempotent_whenAllBlocksAlreadyReviewed() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock block = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(block));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
verify(blockRepository).saveAll(any());
}
@Test
void markAllBlocksReviewed_emitsBlockReviewedAuditEvent_forEachUnreviewedBlock() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock block1 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
TranscriptionBlock block2 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(block1, block2));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
transcriptionService.markAllBlocksReviewed(docId, userId);
verify(auditService, times(2)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
}
@Test
void markAllBlocksReviewed_doesNotEmitAuditEvent_forAlreadyReviewedBlocks() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock alreadyReviewed = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
TranscriptionBlock unreviewed = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(alreadyReviewed, unreviewed));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
transcriptionService.markAllBlocksReviewed(docId, userId);
verify(auditService, times(1)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
}
@Test
void markAllBlocksReviewed_returnsEmptyList_whenNoBlocksExist() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of());
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
assertThat(result).isEmpty();
}
} }

View File

@@ -1,202 +0,0 @@
/**
* E2E regression tests for PersonTypeahead dropdown visibility.
*
* These tests verify that the dropdown list is never clipped by a parent
* container's stacking context — the root cause of issue #343.
*
* The tests run at both desktop (1280×720) and tablet (768×1024) viewports
* as required by the acceptance criteria.
*/
import { test, expect, type Page } from '@playwright/test';
/**
* Find a document edit URL to use as the test page.
* Falls back to /documents/new if no existing document is found.
*/
async function getDocumentEditUrl(page: Page): Promise<string> {
await page.goto('/');
await page.waitForLoadState('networkidle');
const firstDocLink = page.locator('a[href^="/documents/"]').first();
const href = await firstDocLink.getAttribute('href').catch(() => null);
if (href) {
return `${href}/edit`;
}
return '/documents/new';
}
/** Wait for the listbox to become visible after triggering a search. */
async function waitForListbox(page: Page): Promise<void> {
await page.waitForSelector('[role="listbox"]', { state: 'visible', timeout: 2000 });
}
test.describe('PersonTypeahead — dropdown visibility (desktop)', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('sender dropdown items are visible and not clipped in document edit', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
// Find the sender typeahead input (the visible text input, not the hidden one)
const senderInput = page.locator('#senderId-search');
await expect(senderInput).toBeVisible();
// Type to trigger the dropdown
await senderInput.click();
await senderInput.fill('a');
// Wait for the dropdown to appear (handles debounce automatically)
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
const firstOption = dropdown.locator('[role="option"]').first();
await expect(firstOption).toBeVisible();
// Verify the bounding box is within the viewport (not clipped)
const box = await firstOption.boundingBox();
expect(box).not.toBeNull();
expect(box!.y).toBeGreaterThan(0);
expect(box!.y + box!.height).toBeLessThan(720);
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-desktop.png' });
});
test('dropdown is positioned below the input field (not hidden behind parent)', async ({
page
}) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await expect(senderInput).toBeVisible();
const inputBox = await senderInput.boundingBox();
expect(inputBox).not.toBeNull();
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
const dropdownBox = await dropdown.boundingBox();
expect(dropdownBox).not.toBeNull();
// Dropdown must appear below the input, not on top or clipped behind it
expect(dropdownBox!.y).toBeGreaterThanOrEqual(inputBox!.y + inputBox!.height - 5);
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-position.png' });
});
});
test.describe('PersonTypeahead — dropdown visibility (tablet)', () => {
test.use({ viewport: { width: 768, height: 1024 } });
test('sender dropdown items are visible and not clipped on tablet viewport', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await expect(senderInput).toBeVisible();
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
const firstOption = dropdown.locator('[role="option"]').first();
await expect(firstOption).toBeVisible();
const box = await firstOption.boundingBox();
expect(box).not.toBeNull();
expect(box!.y).toBeGreaterThan(0);
expect(box!.y + box!.height).toBeLessThan(1024);
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-tablet.png' });
});
});
test.describe('PersonTypeahead — keyboard navigation', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('ArrowDown moves focus to the first option', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
await senderInput.press('ArrowDown');
// First option should now be the active descendant
const activeDescendant = await senderInput.getAttribute('aria-activedescendant');
expect(activeDescendant).toBeTruthy();
await page.screenshot({ path: 'test-results/e2e/person-typeahead-keyboard-nav.png' });
});
test('Escape key closes the dropdown', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
await senderInput.press('Escape');
await expect(dropdown).not.toBeVisible();
});
test('aria-expanded is true when dropdown is open', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
// Initially closed
const initialExpanded = await senderInput.getAttribute('aria-expanded');
expect(initialExpanded).toBe('false');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const expanded = await senderInput.getAttribute('aria-expanded');
expect(expanded).toBe('true');
});
});
test.describe('PersonTypeahead — click-outside dismiss (fixed position)', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('clicking outside a fixed-position dropdown closes it', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
// Click somewhere else on the page
await page.click('body', { position: { x: 10, y: 10 } });
await expect(dropdown).not.toBeVisible();
});
});

View File

@@ -76,22 +76,6 @@ const typeahead = createTypeahead<Person>({
debounceMs: 300 debounceMs: 300
}); });
// Fixed-position dropdown state — escapes any CSS stacking context that would clip it.
let inputEl: HTMLInputElement;
let dropdownStyle = $state('');
let activeIndex = $state(-1);
// Stable id linking the input's aria-controls to the listbox element.
const listboxId = `${name}-listbox`;
const isOpen = $derived(typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading));
function updateDropdownPosition() {
if (!inputEl) return;
const rect = inputEl.getBoundingClientRect();
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
}
function handleInput() { function handleInput() {
if (value && searchTerm !== initialName) { if (value && searchTerm !== initialName) {
value = ''; value = '';
@@ -104,7 +88,6 @@ function handleInput() {
function handleFocus() { function handleFocus() {
onfocused?.(); onfocused?.();
updateDropdownPosition();
if (restrictToCorrespondentsOf) { if (restrictToCorrespondentsOf) {
const personId = untrack(() => restrictToCorrespondentsOf)!; const personId = untrack(() => restrictToCorrespondentsOf)!;
(async () => { (async () => {
@@ -126,47 +109,13 @@ function selectPerson(person: Person) {
value = person.id!; value = person.id!;
searchTerm = person.displayName; searchTerm = person.displayName;
typeahead.close(); typeahead.close();
activeIndex = -1;
onchange?.(person.id!); onchange?.(person.id!);
} }
function closeDropdown() {
typeahead.close();
activeIndex = -1;
}
function handleKeydown(e: KeyboardEvent) {
if (!isOpen) return;
const results = typeahead.results;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = (activeIndex + 1) % results.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = (activeIndex - 1 + results.length) % results.length;
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && results[activeIndex]) {
selectPerson(results[activeIndex]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
closeDropdown();
}
}
// Keep dropdown position current when user scrolls or resizes.
// fixed positioning is intentional — it escapes any CSS stacking context (overflow, transform,
// shadow-sm + z-index combinations) that would clip an absolute-positioned dropdown.
</script> </script>
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} /> <div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
<div class="relative" use:clickOutside onclickoutside={closeDropdown}>
<label <label
for="{name}-search" for={name}
class={compact class={compact
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase' ? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
: 'block text-sm font-medium text-ink-2'} : 'block text-sm font-medium text-ink-2'}
@@ -176,22 +125,13 @@ function handleKeydown(e: KeyboardEvent) {
<input type="hidden" name={name} bind:value={value} /> <input type="hidden" name={name} bind:value={value} />
<input <input
bind:this={inputEl}
type="text" type="text"
id="{name}-search" id="{name}-search"
role="combobox"
autocomplete="off" autocomplete="off"
autofocus={autofocus} autofocus={autofocus}
bind:value={searchTerm} bind:value={searchTerm}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-activedescendant={activeIndex >= 0 && typeahead.results[activeIndex]
? `${listboxId}-option-${typeahead.results[activeIndex].id}`
: undefined}
oninput={handleInput} oninput={handleInput}
onfocus={handleFocus} onfocus={handleFocus}
onkeydown={handleKeydown}
placeholder={placeholder ?? m.comp_typeahead_placeholder()} placeholder={placeholder ?? m.comp_typeahead_placeholder()}
class={large class={large
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring' ? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
@@ -200,34 +140,29 @@ function handleKeydown(e: KeyboardEvent) {
: 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'} : 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
/> />
{#if isOpen} {#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
<ul <div
id={listboxId} class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
role="listbox"
style={dropdownStyle}
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
> >
{#if typeahead.loading} {#if typeahead.loading}
<li class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</li> <div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div>
{:else} {:else}
{#each typeahead.results as person, i (person.id)} {#each typeahead.results as person (person.id)}
<li <div
id="{listboxId}-option-{person.id}" class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
role="option"
aria-selected={i === activeIndex}
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg {i === activeIndex ? 'bg-accent-bg' : ''}"
onclick={() => selectPerson(person)} onclick={() => selectPerson(person)}
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)} onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
tabindex="-1" role="button"
tabindex="0"
> >
<div class="flex items-center"> <div class="flex items-center">
<span class="block truncate font-medium"> <span class="block truncate font-medium">
{person.displayName} {person.displayName}
</span> </span>
</div> </div>
</li> </div>
{/each} {/each}
{/if} {/if}
</ul> </div>
{/if} {/if}
</div> </div>

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi, afterEach } from 'vitest'; import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser'; import { page } from 'vitest/browser';
import PersonTypeahead from './PersonTypeahead.svelte'; import PersonTypeahead from './PersonTypeahead.svelte';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350)); const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
@@ -130,11 +130,11 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...'); const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu'); await input.fill('Mu');
await waitForDebounce(); await waitForDebounce();
document.querySelector<HTMLElement>('[role="option"]')!.click(); document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick(); await tick();
await expect.element(input).toHaveValue('Max Mustermann'); await expect.element(input).toHaveValue('Max Mustermann');
await expect await expect
.element(page.getByRole('option', { name: 'Max Mustermann' })) .element(page.getByRole('button', { name: 'Max Mustermann' }))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' }); await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
}); });
@@ -145,7 +145,7 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...'); const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu'); await input.fill('Mu');
await waitForDebounce(); await waitForDebounce();
document.querySelector<HTMLElement>('[role="option"]')!.click(); document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick(); await tick();
await tick(); await tick();
expect(hiddenInput('senderId')?.value).toBe('1'); expect(hiddenInput('senderId')?.value).toBe('1');
@@ -158,7 +158,7 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...'); const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu'); await input.fill('Mu');
await waitForDebounce(); await waitForDebounce();
document.querySelector<HTMLElement>('[role="option"]')!.click(); document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick(); await tick();
expect(onchange).toHaveBeenCalledWith('1'); expect(onchange).toHaveBeenCalledWith('1');
}); });
@@ -177,7 +177,7 @@ describe('PersonTypeahead selection', () => {
const input = page.getByPlaceholder('Namen tippen...'); const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma'); await input.fill('Ma');
await waitForDebounce(); await waitForDebounce();
document.querySelector<HTMLElement>('[role="option"]')!.click(); document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick(); await tick();
await expect.element(input).toHaveValue('Max Mustermann'); await expect.element(input).toHaveValue('Max Mustermann');
}); });
@@ -194,7 +194,7 @@ describe('PersonTypeahead clearing a selection', () => {
await input.fill('Mu'); await input.fill('Mu');
await waitForDebounce(); await waitForDebounce();
document.querySelector<HTMLElement>('[role="option"]')!.click(); document.querySelector<HTMLElement>('[role="button"]')!.click();
await tick(); await tick();
expect(onchange).toHaveBeenCalledWith('1'); expect(onchange).toHaveBeenCalledWith('1');
onchange.mockClear(); onchange.mockClear();
@@ -285,194 +285,3 @@ describe('PersonTypeahead click outside', () => {
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument(); await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
}); });
}); });
// ─── ARIA roles ───────────────────────────────────────────────────────────────
describe('PersonTypeahead ARIA roles', () => {
it('dropdown uses role="listbox" container and role="option" items', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
// Container must be a listbox
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
// Items must be options, not buttons
const options = page.getByRole('option');
await expect.element(options.first()).toBeInTheDocument();
await expect.element(page.getByRole('option', { name: 'Max Mustermann' })).toBeInTheDocument();
await expect.element(page.getByRole('option', { name: 'Anna Musterfrau' })).toBeInTheDocument();
});
it('input has aria-expanded="false" when dropdown is closed', async () => {
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await expect.element(input).toHaveAttribute('aria-expanded', 'false');
});
it('input has aria-expanded="true" when dropdown is open', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await expect.element(input).toHaveAttribute('aria-expanded', 'true');
});
it('input has aria-controls pointing to the listbox id', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
const ariaControls = await input.element().getAttribute('aria-controls');
expect(ariaControls).toBeTruthy();
// The listbox with that id must exist
const listbox = document.getElementById(ariaControls!);
expect(listbox).not.toBeNull();
expect(listbox!.getAttribute('role')).toBe('listbox');
});
it('input has aria-haspopup="listbox"', async () => {
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await expect.element(input).toHaveAttribute('aria-haspopup', 'listbox');
});
});
// ─── Keyboard navigation ──────────────────────────────────────────────────────
describe('PersonTypeahead keyboard navigation', () => {
it('ArrowDown moves highlight to the first option', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await input.click();
await userEvent.keyboard('{ArrowDown}');
await tick();
// First option should be highlighted (aria-selected="true")
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
});
it('ArrowDown then ArrowDown moves highlight to the second option', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await input.click();
await userEvent.keyboard('{ArrowDown}');
await tick();
await userEvent.keyboard('{ArrowDown}');
await tick();
const secondOption = page.getByRole('option', { name: 'Anna Musterfrau' });
await expect.element(secondOption).toHaveAttribute('aria-selected', 'true');
});
it('ArrowUp from first wraps to last option', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await input.click();
await userEvent.keyboard('{ArrowDown}'); // highlight first
await tick();
await userEvent.keyboard('{ArrowUp}'); // wrap to last
await tick();
const lastOption = page.getByRole('option', { name: 'Anna Musterfrau' });
await expect.element(lastOption).toHaveAttribute('aria-selected', 'true');
});
it('ArrowDown from last wraps to first option', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await input.click();
await userEvent.keyboard('{ArrowDown}'); // highlight first (index 0)
await tick();
await userEvent.keyboard('{ArrowDown}'); // highlight second (index 1 = last)
await tick();
await userEvent.keyboard('{ArrowDown}'); // wrap to first (index 0)
await tick();
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
});
it('Enter selects the highlighted option', async () => {
mockFetchWithPersons([
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
}
]);
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
await input.click();
await userEvent.keyboard('{ArrowDown}');
await tick();
await userEvent.keyboard('{Enter}');
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
it('Escape closes the dropdown without selecting', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
await input.click();
await userEvent.keyboard('{Escape}');
await tick();
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
// Value unchanged — nothing was selected
await expect.element(input).toHaveValue('Mu');
});
it('active option id is set as aria-activedescendant on the input', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
// No active option before pressing ArrowDown
const beforeNav = await input.element().getAttribute('aria-activedescendant');
expect(beforeNav).toBeFalsy();
await input.click();
await userEvent.keyboard('{ArrowDown}');
await tick();
const afterNav = await input.element().getAttribute('aria-activedescendant');
expect(afterNav).toBeTruthy();
});
});

View File

@@ -19,6 +19,7 @@ type Props = {
onSaveBlock: (blockId: string, text: string) => Promise<void>; onSaveBlock: (blockId: string, text: string) => Promise<void>;
onDeleteBlock: (blockId: string) => Promise<void>; onDeleteBlock: (blockId: string) => Promise<void>;
onReviewToggle: (blockId: string) => Promise<void>; onReviewToggle: (blockId: string) => Promise<void>;
onMarkAllReviewed?: () => Promise<void>;
onTriggerOcr?: (scriptType: string, useExistingAnnotations: boolean) => void; onTriggerOcr?: (scriptType: string, useExistingAnnotations: boolean) => void;
canWrite?: boolean; canWrite?: boolean;
trainingLabels?: string[]; trainingLabels?: string[];
@@ -37,6 +38,7 @@ let {
onSaveBlock, onSaveBlock,
onDeleteBlock, onDeleteBlock,
onReviewToggle, onReviewToggle,
onMarkAllReviewed,
onTriggerOcr, onTriggerOcr,
canWrite = false, canWrite = false,
trainingLabels = [], trainingLabels = [],
@@ -46,12 +48,14 @@ let {
let activeBlockId: string | null = $state(null); let activeBlockId: string | null = $state(null);
let localLabels: string[] = $derived.by(() => [...trainingLabels]); let localLabels: string[] = $derived.by(() => [...trainingLabels]);
let listEl: HTMLElement | null = $state(null); let listEl: HTMLElement | null = $state(null);
let markingAllReviewed = $state(false);
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder)); const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
const hasBlocks = $derived(blocks.length > 0); const hasBlocks = $derived(blocks.length > 0);
const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length); const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
const totalCount = $derived(blocks.length); const totalCount = $derived(blocks.length);
const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0); const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
const allReviewed = $derived(totalCount > 0 && reviewedCount === totalCount);
// Sync: when an annotation is clicked on the PDF, activate the corresponding block // Sync: when an annotation is clicked on the PDF, activate the corresponding block
$effect(() => { $effect(() => {
@@ -60,6 +64,16 @@ $effect(() => {
if (block) activeBlockId = block.id; if (block) activeBlockId = block.id;
}); });
async function handleMarkAllReviewed() {
if (!onMarkAllReviewed) return;
markingAllReviewed = true;
try {
await onMarkAllReviewed();
} finally {
markingAllReviewed = false;
}
}
const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId }); const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
const dragDrop = createBlockDragDrop({ const dragDrop = createBlockDragDrop({
@@ -147,9 +161,56 @@ async function handleLabelToggle(label: string) {
{#if hasBlocks} {#if hasBlocks}
<!-- Sticky review progress header --> <!-- Sticky review progress header -->
<div class="sticky top-0 z-10 border-b border-line bg-surface px-4 pt-3 pb-2"> <div class="sticky top-0 z-10 border-b border-line bg-surface px-4 pt-3 pb-2">
<div class="flex items-center justify-between">
<p class="font-sans text-xs text-ink-2"> <p class="font-sans text-xs text-ink-2">
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft <span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
</p> </p>
{#if onMarkAllReviewed}
<button
onclick={handleMarkAllReviewed}
disabled={allReviewed || markingAllReviewed}
title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined}
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
>
{#if markingAllReviewed}
<svg
class="h-3.5 w-3.5 animate-spin"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
{:else}
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{/if}
Alle als fertig markieren
</button>
{/if}
</div>
<div class="bg-brand-sand mt-1.5 h-0.5 w-full overflow-hidden rounded-full"> <div class="bg-brand-sand mt-1.5 h-0.5 w-full overflow-hidden rounded-full">
<div <div
class="h-full rounded-full bg-brand-mint transition-all duration-300" class="h-full rounded-full bg-brand-mint transition-all duration-300"

View File

@@ -49,6 +49,11 @@ function renderView(overrides: Record<string, unknown> = {}, service = createCon
}; };
} }
const unreviewedBlock1 = { ...block1, reviewed: false };
const unreviewedBlock2 = { ...block2, reviewed: false };
const reviewedBlock1 = { ...block1, reviewed: true };
const reviewedBlock2 = { ...block2, reviewed: true };
describe('TranscriptionEditView — rendering', () => { describe('TranscriptionEditView — rendering', () => {
it('renders blocks in sort order', async () => { it('renders blocks in sort order', async () => {
renderView(); renderView();
@@ -269,3 +274,61 @@ describe('TranscriptionEditView — review progress counter', () => {
await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument(); await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument();
}); });
}); });
// ─── Bulk mark all as reviewed ────────────────────────────────────────────────
describe('TranscriptionEditView — mark all reviewed', () => {
it('shows "Alle als fertig markieren" button when onMarkAllReviewed is provided and blocks are unreviewed', async () => {
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeInTheDocument();
});
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.not.toBeInTheDocument();
});
it('disables button when all blocks are already reviewed', async () => {
renderView({
blocks: [reviewedBlock1, reviewedBlock2],
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeDisabled();
});
it('calls onMarkAllReviewed exactly once when button is clicked', async () => {
const onMarkAllReviewed = vi.fn().mockResolvedValue(undefined);
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed
});
await page.getByRole('button', { name: /Alle als fertig markieren/ }).click();
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
});
it('disables button while operation is in-flight', async () => {
let resolveMarkAll!: () => void;
const onMarkAllReviewed = vi
.fn()
.mockReturnValue(new Promise<void>((r) => (resolveMarkAll = r)));
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed
});
const btn = page.getByRole('button', { name: /Alle als fertig markieren/ });
await btn.click();
await expect.element(btn).toBeDisabled();
resolveMarkAll();
});
});

View File

@@ -31,7 +31,7 @@ let {
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6"> <div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
<!-- Sender --> <!-- Sender -->
<div <div
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase" class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
> >
<PersonTypeahead <PersonTypeahead
name="senderId" name="senderId"
@@ -73,7 +73,7 @@ let {
<!-- Receiver --> <!-- Receiver -->
<div <div
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase" class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
> >
<PersonTypeahead <PersonTypeahead
name="receiverId" name="receiverId"
@@ -86,7 +86,7 @@ let {
</div> </div>
</div> </div>
<div class="grid grid-cols-1 items-end gap-6 md:grid-cols-3"> <div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
<!-- Date From --> <!-- Date From -->
<div> <div>
<label <label

View File

@@ -137,6 +137,18 @@ async function reviewToggle(blockId: string) {
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b)); transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
} }
async function markAllReviewed() {
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/review-all`, {
method: 'PUT'
});
if (!res.ok) return;
const updated = await res.json();
for (const b of updated) {
const existing = transcriptionBlocks.find((x) => x.id === b.id);
if (existing) existing.reviewed = b.reviewed;
}
}
async function toggleTrainingLabel(label: string, enrolled: boolean) { async function toggleTrainingLabel(label: string, enrolled: boolean) {
const res = await fetch(`/api/documents/${doc.id}/training-labels`, { const res = await fetch(`/api/documents/${doc.id}/training-labels`, {
method: 'PATCH', method: 'PATCH',
@@ -501,6 +513,7 @@ onMount(() => {
onSaveBlock={saveBlock} onSaveBlock={saveBlock}
onDeleteBlock={deleteBlock} onDeleteBlock={deleteBlock}
onReviewToggle={reviewToggle} onReviewToggle={reviewToggle}
onMarkAllReviewed={markAllReviewed}
onTriggerOcr={triggerOcr} onTriggerOcr={triggerOcr}
onToggleTrainingLabel={toggleTrainingLabel} onToggleTrainingLabel={toggleTrainingLabel}
/> />