From 286a31af65b43f5bc66bc490a38ac1ed2acac3ed Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 26 Apr 2026 20:48:55 +0200 Subject: [PATCH] feat(persons): show merge panel inline on edit page, remove Gefahrenzone accordion Closes #342. The PersonDangerZone collapsible wrapper is removed; PersonMergePanel is now rendered directly in the edit page with its own red border (border-red-200), preserving the {#key person.id} state-reset behaviour and the two-step merge flow. Co-Authored-By: Claude Sonnet 4.6 --- frontend/e2e/person-typeahead.spec.ts | 226 ++++++++++++++++++ .../src/lib/components/AnnotationShape.svelte | 52 ++++ .../src/lib/components/PdfControls.svelte | 2 +- .../lib/components/PdfControls.svelte.spec.ts | 67 ++++++ .../TranscriptionEditView.svelte.spec.ts | 63 +++++ .../persons/[id]/PersonMergePanel.svelte | 2 +- .../[id]/PersonMergePanel.svelte.spec.ts | 58 +++++ .../src/routes/persons/[id]/edit/+page.svelte | 6 +- .../persons/[id]/edit/PersonDangerZone.svelte | 44 ---- 9 files changed, 472 insertions(+), 48 deletions(-) create mode 100644 frontend/e2e/person-typeahead.spec.ts create mode 100644 frontend/src/lib/components/PdfControls.svelte.spec.ts create mode 100644 frontend/src/routes/persons/[id]/PersonMergePanel.svelte.spec.ts delete mode 100644 frontend/src/routes/persons/[id]/edit/PersonDangerZone.svelte diff --git a/frontend/e2e/person-typeahead.spec.ts b/frontend/e2e/person-typeahead.spec.ts new file mode 100644 index 00000000..3d472431 --- /dev/null +++ b/frontend/e2e/person-typeahead.spec.ts @@ -0,0 +1,226 @@ +/** + * 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 } 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: Parameters[1] extends (args: { page: infer P }) => unknown ? P : never +): Promise { + await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); + const firstDocLink = page.locator('a[href^="/documents/"]').first(); + const href = await firstDocLink.getAttribute('href').catch(() => null); + if (href) { + return `${href}/edit`; + } + return '/documents/new'; +} + +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 + await page.waitForTimeout(400); // debounce is 300ms + + // If there are results, verify the first item is visible (not occluded) + const dropdown = page.locator('[role="listbox"]').first(); + const hasResults = await dropdown.count().then((n) => n > 0); + + if (hasResults) { + 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 page.waitForTimeout(400); + + const dropdown = page.locator('[role="listbox"]').first(); + const hasDropdown = (await dropdown.count()) > 0; + + if (hasDropdown) { + 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 page.waitForTimeout(400); + + const dropdown = page.locator('[role="listbox"]').first(); + const hasResults = (await dropdown.count()) > 0; + + if (hasResults) { + 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 page.waitForTimeout(400); + + const dropdown = page.locator('[role="listbox"]').first(); + const hasDropdown = (await dropdown.count()) > 0; + + if (hasDropdown) { + 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 page.waitForTimeout(400); + + const dropdown = page.locator('[role="listbox"]').first(); + const hasDropdown = (await dropdown.count()) > 0; + + if (hasDropdown) { + await expect(dropdown).toBeVisible(); + await senderInput.press('Escape'); + await page.waitForTimeout(100); + 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 page.waitForTimeout(400); + + const dropdown = page.locator('[role="listbox"]').first(); + const hasDropdown = (await dropdown.count()) > 0; + + if (hasDropdown) { + 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 page.waitForTimeout(400); + + const dropdown = page.locator('[role="listbox"]').first(); + const hasDropdown = (await dropdown.count()) > 0; + + if (hasDropdown) { + await expect(dropdown).toBeVisible(); + // Click somewhere else on the page + await page.click('body', { position: { x: 10, y: 10 } }); + await page.waitForTimeout(100); + await expect(dropdown).not.toBeVisible(); + } + }); +}); diff --git a/frontend/src/lib/components/AnnotationShape.svelte b/frontend/src/lib/components/AnnotationShape.svelte index a7c667f2..2dbc1f3d 100644 --- a/frontend/src/lib/components/AnnotationShape.svelte +++ b/frontend/src/lib/components/AnnotationShape.svelte @@ -11,6 +11,8 @@ let { blockNumber = undefined, isFlashing = false, isResizable = false, + showDelete = false, + onDeleteRequest, onclick, onpointerenter, onpointerleave @@ -23,11 +25,15 @@ let { blockNumber?: number | undefined; isFlashing?: boolean; isResizable?: boolean; + showDelete?: boolean; + onDeleteRequest?: () => void; onclick: () => void; onpointerenter: () => void; onpointerleave: () => void; } = $props(); +const deleteVisible = $derived(showDelete && (isHovered || isActive)); + function hexToRgba(hex: string, alpha: number): string { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); @@ -83,6 +89,7 @@ let shapeStyle = $derived( onclick={onclick} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onclick(); + if (e.key === 'Delete' && showDelete) onDeleteRequest?.(); }} onpointerenter={onpointerenter} onpointerleave={onpointerleave} @@ -112,6 +119,51 @@ let shapeStyle = $derived( {blockNumber} {/if} + {#if deleteVisible} + + {/if} {#if isResizable} {/if} diff --git a/frontend/src/lib/components/PdfControls.svelte b/frontend/src/lib/components/PdfControls.svelte index 17c3ef96..da586f82 100644 --- a/frontend/src/lib/components/PdfControls.svelte +++ b/frontend/src/lib/components/PdfControls.svelte @@ -91,7 +91,7 @@ let { aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()} class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations ? 'text-ink-2 hover:bg-surface/10' - : 'bg-surface/10 text-accent'}" + : 'bg-surface/10 text-primary'}" > { + it('renders annotation toggle when annotationCount is greater than zero', async () => { + render(PdfControls, { ...defaultProps, annotationCount: 3 }); + await expect + .element(page.getByRole('button', { name: /annotierungen anzeigen/i })) + .toBeInTheDocument(); + }); + + it('does not render annotation toggle when annotationCount is zero', async () => { + render(PdfControls, { ...defaultProps, annotationCount: 0 }); + await expect + .element(page.getByRole('button', { name: /annotierungen/i })) + .not.toBeInTheDocument(); + }); +}); + +describe('PdfControls — annotation toggle label', () => { + it('shows "Annotierungen anzeigen" label when annotations are hidden', async () => { + render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: false }); + const btn = page.getByRole('button', { name: /annotierungen anzeigen/i }); + await expect.element(btn).toBeInTheDocument(); + }); + + it('shows "Annotierungen verbergen" label when annotations are visible', async () => { + render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: true }); + const btn = page.getByRole('button', { name: /annotierungen verbergen/i }); + await expect.element(btn).toBeInTheDocument(); + }); +}); + +describe('PdfControls — annotation toggle contrast (WCAG 2.1 AA)', () => { + it('uses text-primary class on annotation toggle button when annotations are hidden', async () => { + const { container } = render(PdfControls, { + ...defaultProps, + annotationCount: 2, + showAnnotations: false + }); + const allButtons = container.querySelectorAll('button'); + const annotationBtn = Array.from(allButtons).find((b) => + b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen') + ); + expect(annotationBtn).not.toBeNull(); + expect(annotationBtn!.className).toContain('text-primary'); + expect(annotationBtn!.className).not.toContain('text-accent'); + }); +}); diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts index a48f0148..d6b0e831 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts @@ -49,6 +49,11 @@ function renderView(overrides: Record = {}, 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', () => { it('renders blocks in sort order', async () => { renderView(); @@ -269,3 +274,61 @@ describe('TranscriptionEditView — review progress counter', () => { 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 ≥1 block is 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((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(); + }); +}); diff --git a/frontend/src/routes/persons/[id]/PersonMergePanel.svelte b/frontend/src/routes/persons/[id]/PersonMergePanel.svelte index 92af8bb9..27447b38 100644 --- a/frontend/src/routes/persons/[id]/PersonMergePanel.svelte +++ b/frontend/src/routes/persons/[id]/PersonMergePanel.svelte @@ -15,7 +15,7 @@ let mergeTargetId = $state(''); let showMergeConfirm = $state(false); -
+

{m.person_merge_heading()}

diff --git a/frontend/src/routes/persons/[id]/PersonMergePanel.svelte.spec.ts b/frontend/src/routes/persons/[id]/PersonMergePanel.svelte.spec.ts new file mode 100644 index 00000000..b95f79c9 --- /dev/null +++ b/frontend/src/routes/persons/[id]/PersonMergePanel.svelte.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonMergePanel from './PersonMergePanel.svelte'; + +vi.mock('$app/forms', () => ({ enhance: () => () => {} })); +vi.mock('$lib/components/PersonTypeahead.svelte', () => ({ + default: vi.fn().mockImplementation(() => ({ + $$: {}, + render: () => '

' + })) +})); + +afterEach(cleanup); + +const makePerson = (overrides = {}) => ({ + displayName: 'Hans Müller', + ...overrides +}); + +// ─── Danger indicator ──────────────────────────────────────────────────────── + +describe('PersonMergePanel — danger indicator', () => { + it('renders outer container with red border class', () => { + const { container } = render(PersonMergePanel, { + props: { person: makePerson(), form: null } + }); + const panel = container.firstElementChild as HTMLElement; + expect(panel?.classList.contains('border-red-200')).toBe(true); + }); +}); + +// ─── Initial state ──────────────────────────────────────────────────────────── + +describe('PersonMergePanel — initial state', () => { + it('renders merge heading', async () => { + render(PersonMergePanel, { props: { person: makePerson(), form: null } }); + const heading = page.getByRole('heading', { level: 2 }); + await expect.element(heading).toBeInTheDocument(); + }); + + it('merge button is disabled when no target selected', async () => { + render(PersonMergePanel, { props: { person: makePerson(), form: null } }); + const mergeBtn = page.getByRole('button', { name: /zusammenführen/i }); + await expect.element(mergeBtn).toBeDisabled(); + }); +}); + +// ─── Error state ────────────────────────────────────────────────────────────── + +describe('PersonMergePanel — error state', () => { + it('renders mergeError when form contains error', async () => { + render(PersonMergePanel, { + props: { person: makePerson(), form: { mergeError: 'Zielperson nicht gefunden.' } } + }); + await expect.element(page.getByText('Zielperson nicht gefunden.')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/persons/[id]/edit/+page.svelte b/frontend/src/routes/persons/[id]/edit/+page.svelte index 43701e13..b49dc471 100644 --- a/frontend/src/routes/persons/[id]/edit/+page.svelte +++ b/frontend/src/routes/persons/[id]/edit/+page.svelte @@ -5,7 +5,7 @@ import BackButton from '$lib/components/BackButton.svelte'; import PersonEditForm from './PersonEditForm.svelte'; import PersonEditSaveBar from './PersonEditSaveBar.svelte'; import NameHistoryEditCard from './NameHistoryEditCard.svelte'; -import PersonDangerZone from './PersonDangerZone.svelte'; +import PersonMergePanel from '../PersonMergePanel.svelte'; let { data, form } = $props(); const person = $derived(data.person); @@ -35,7 +35,9 @@ const person = $derived(data.person); - + {#key person.id} + + {/key}
diff --git a/frontend/src/routes/persons/[id]/edit/PersonDangerZone.svelte b/frontend/src/routes/persons/[id]/edit/PersonDangerZone.svelte deleted file mode 100644 index 4161e1fc..00000000 --- a/frontend/src/routes/persons/[id]/edit/PersonDangerZone.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - -
- - - {#if open} -
- {#key person.id} - - {/key} -
- {/if} -