feat(persons): show merge panel inline on edit page, remove Gefahrenzone accordion
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m27s
CI / OCR Service Tests (push) Successful in 47s
CI / Backend Unit Tests (push) Failing after 3m11s
CI / Unit & Component Tests (pull_request) Failing after 3m19s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 3m1s
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m27s
CI / OCR Service Tests (push) Successful in 47s
CI / Backend Unit Tests (push) Failing after 3m11s
CI / Unit & Component Tests (pull_request) Failing after 3m19s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 3m1s
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 <noreply@anthropic.com>
This commit is contained in:
226
frontend/e2e/person-typeahead.spec.ts
Normal file
226
frontend/e2e/person-typeahead.spec.ts
Normal file
@@ -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<typeof test>[1] extends (args: { page: infer P }) => unknown ? P : never
|
||||
): Promise<string> {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
</div>
|
||||
{/if}
|
||||
{#if deleteVisible}
|
||||
<button
|
||||
data-testid="annotation-delete-{annotation.id}"
|
||||
type="button"
|
||||
aria-label="Löschen"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteRequest?.();
|
||||
}}
|
||||
style="
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--color-error, #e53e3e);
|
||||
color: var(--color-error, #e53e3e);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
|
||||
z-index: 10;
|
||||
"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if isResizable}
|
||||
<AnnotationEditOverlay annotation={annotation} />
|
||||
{/if}
|
||||
|
||||
@@ -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'}"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
|
||||
67
frontend/src/lib/components/PdfControls.svelte.spec.ts
Normal file
67
frontend/src/lib/components/PdfControls.svelte.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { vi, describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import PdfControls from './PdfControls.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const defaultProps = {
|
||||
currentPage: 1,
|
||||
totalPages: 3,
|
||||
isLoaded: true,
|
||||
showAnnotations: false,
|
||||
annotationCount: 0,
|
||||
onPrev: vi.fn(),
|
||||
onNext: vi.fn(),
|
||||
onZoomIn: vi.fn(),
|
||||
onZoomOut: vi.fn(),
|
||||
onToggleAnnotations: vi.fn()
|
||||
};
|
||||
|
||||
describe('PdfControls — annotation toggle visibility', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
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<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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ let mergeTargetId = $state('');
|
||||
let showMergeConfirm = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="mb-10 overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||
<div class="mb-10 overflow-hidden rounded-sm border border-red-200 bg-surface shadow-sm">
|
||||
<div class="p-6 md:p-8">
|
||||
<h2 class="mb-1 font-serif text-lg text-ink">{m.person_merge_heading()}</h2>
|
||||
<p class="mb-5 font-sans text-sm text-ink-2">
|
||||
|
||||
@@ -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: () => '<div></div>'
|
||||
}))
|
||||
}));
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
<NameHistoryEditCard aliases={data.aliases} canWrite={true} aliasError={form?.aliasError} />
|
||||
|
||||
<PersonDangerZone person={person} form={form} />
|
||||
{#key person.id}
|
||||
<PersonMergePanel person={person} form={form} />
|
||||
{/key}
|
||||
|
||||
<PersonEditSaveBar discardHref="/persons/{person.id}" formId="person-edit-form" />
|
||||
</div>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import PersonMergePanel from '../PersonMergePanel.svelte';
|
||||
|
||||
let {
|
||||
person,
|
||||
form
|
||||
}: {
|
||||
person: { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
form?: { mergeError?: string } | null;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="mt-8 overflow-hidden rounded-sm border border-red-200 bg-surface shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (open = !open)}
|
||||
class="flex w-full items-center justify-between px-6 py-4 text-left"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span class="text-sm font-bold tracking-widest text-red-600 uppercase">
|
||||
{m.person_danger_zone_heading()}
|
||||
</span>
|
||||
<svg
|
||||
class="h-4 w-4 text-red-400 transition-transform {open ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="border-t border-red-100 px-6 py-4">
|
||||
{#key person.id}
|
||||
<PersonMergePanel person={person} form={form} />
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user