Compare commits
3 Commits
feat/issue
...
fe13df574a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe13df574a | ||
|
|
a9080e9dab | ||
|
|
e8a1cc82ff |
@@ -1,3 +0,0 @@
|
|||||||
### Mark all blocks as reviewed
|
|
||||||
PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all
|
|
||||||
Authorization: Basic admin admin123
|
|
||||||
@@ -90,15 +90,6 @@ 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(
|
||||||
|
|||||||
@@ -205,18 +205,6 @@ 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);
|
||||||
|
|||||||
@@ -380,63 +380,4 @@ 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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -506,86 +506,4 @@ 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
202
frontend/e2e/person-typeahead.spec.ts
Normal file
202
frontend/e2e/person-typeahead.spec.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,6 +76,22 @@ 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 = '';
|
||||||
@@ -88,6 +104,7 @@ function handleInput() {
|
|||||||
|
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
onfocused?.();
|
onfocused?.();
|
||||||
|
updateDropdownPosition();
|
||||||
if (restrictToCorrespondentsOf) {
|
if (restrictToCorrespondentsOf) {
|
||||||
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -109,13 +126,47 @@ 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>
|
||||||
|
|
||||||
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
|
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||||
|
|
||||||
|
<div class="relative" use:clickOutside onclickoutside={closeDropdown}>
|
||||||
<label
|
<label
|
||||||
for={name}
|
for="{name}-search"
|
||||||
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'}
|
||||||
@@ -125,13 +176,22 @@ function selectPerson(person: Person) {
|
|||||||
<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'
|
||||||
@@ -140,29 +200,34 @@ function selectPerson(person: Person) {
|
|||||||
: '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 typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
{#if isOpen}
|
||||||
<div
|
<ul
|
||||||
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"
|
id={listboxId}
|
||||||
|
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}
|
||||||
<div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div>
|
<li class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</li>
|
||||||
{:else}
|
{:else}
|
||||||
{#each typeahead.results as person (person.id)}
|
{#each typeahead.results as person, i (person.id)}
|
||||||
<div
|
<li
|
||||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
|
id="{listboxId}-option-{person.id}"
|
||||||
|
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)}
|
||||||
role="button"
|
tabindex="-1"
|
||||||
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>
|
||||||
</div>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 } from 'vitest/browser';
|
import { page, userEvent } 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="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.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('button', { name: 'Max Mustermann' }))
|
.element(page.getByRole('option', { 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="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.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="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.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="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.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="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
expect(onchange).toHaveBeenCalledWith('1');
|
expect(onchange).toHaveBeenCalledWith('1');
|
||||||
onchange.mockClear();
|
onchange.mockClear();
|
||||||
@@ -285,3 +285,194 @@ 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ 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[];
|
||||||
@@ -38,7 +37,6 @@ let {
|
|||||||
onSaveBlock,
|
onSaveBlock,
|
||||||
onDeleteBlock,
|
onDeleteBlock,
|
||||||
onReviewToggle,
|
onReviewToggle,
|
||||||
onMarkAllReviewed,
|
|
||||||
onTriggerOcr,
|
onTriggerOcr,
|
||||||
canWrite = false,
|
canWrite = false,
|
||||||
trainingLabels = [],
|
trainingLabels = [],
|
||||||
@@ -48,14 +46,12 @@ 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(() => {
|
||||||
@@ -64,16 +60,6 @@ $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({
|
||||||
@@ -161,56 +147,9 @@ 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"
|
||||||
|
|||||||
@@ -49,11 +49,6 @@ 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();
|
||||||
@@ -274,61 +269,3 @@ 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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="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"
|
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"
|
||||||
>
|
>
|
||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="senderId"
|
name="senderId"
|
||||||
@@ -73,7 +73,7 @@ let {
|
|||||||
|
|
||||||
<!-- Receiver -->
|
<!-- Receiver -->
|
||||||
<div
|
<div
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<PersonTypeahead
|
<PersonTypeahead
|
||||||
name="receiverId"
|
name="receiverId"
|
||||||
@@ -86,7 +86,7 @@ let {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
|
<div class="grid grid-cols-1 items-end gap-6 md:grid-cols-3">
|
||||||
<!-- Date From -->
|
<!-- Date From -->
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -137,18 +137,6 @@ 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',
|
||||||
@@ -513,7 +501,6 @@ onMount(() => {
|
|||||||
onSaveBlock={saveBlock}
|
onSaveBlock={saveBlock}
|
||||||
onDeleteBlock={deleteBlock}
|
onDeleteBlock={deleteBlock}
|
||||||
onReviewToggle={reviewToggle}
|
onReviewToggle={reviewToggle}
|
||||||
onMarkAllReviewed={markAllReviewed}
|
|
||||||
onTriggerOcr={triggerOcr}
|
onTriggerOcr={triggerOcr}
|
||||||
onToggleTrainingLabel={toggleTrainingLabel}
|
onToggleTrainingLabel={toggleTrainingLabel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user