feat(korrespondenz): address PR #164 review – blockers and suggestions
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m36s
CI / Backend Unit Tests (pull_request) Failing after 2m36s
CI / E2E Tests (pull_request) Failing after 1h49m0s

Blockers (14):
- B1: fix senderName/receiverName to use $derived instead of $state + sync $effect
- B2: migrate all korrespondenz components from messages-extra shim to paraglide m.*
- B3: i18n CorrespondenzEmptyState (heading, subtext, search placeholder)
- B4: add response.ok checks to admin layout server load
- B5: add response.ok checks to korrespondenz page server load
- B6: add page.server.spec.ts with 5 test suites for korrespondenz load function
- B7: add axe-core accessibility checks to all e2e korrespondenz tests
- B8: add Testcontainers JPQL tests for findSinglePersonCorrespondence (DISTINCT + sender)
- B9: hide auth reset-token endpoint from OpenAPI spec; remove from generated api.ts
- B11: replace amber hardcoded hex colors in SinglePersonHintBar with brand tokens
- B12: replace clipboard emoji with Heroicons SVG in SinglePersonHintBar
- B13: create DateInput component (German dd.mm.yyyy); use it in CorrespondenzFilterControls
- B14: add Paraglide compile step to CI workflow before lint/test

Suggestions (11):
- S1: make CorrespondentSuggestionsDropdown a pure display component; lift fetch to PersonBar
- S2: fix leftover messages-extra import in ConversationTimeline; use brand tokens for status dots
- S3: add intent comment to EntityNav openFlyout behavior
- S4: rename canManageGroups → canManagePermissions throughout admin
- S6: remove domFlush helper from DateInput spec; use expect.poll instead
- S7: replace test.skip with throw new Error in bilateral e2e tests
- S8: add inverse aria-disabled test for filter strip
- S9: remove sm:min-h-0 from sort button to preserve 44px touch target
- S10: add title attributes to tablet trigger buttons in EntityNav
- S11: delete messages-extra.ts shim entirely

Also: fix admin pages revealing blank strip at bottom (-mb-6 on admin layout)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-30 19:57:48 +02:00
parent 9d6c7b8605
commit 154f859efc
26 changed files with 459 additions and 184 deletions

View File

@@ -28,6 +28,10 @@ jobs:
run: npm ci
working-directory: frontend
- name: Compile Paraglide i18n
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
working-directory: frontend
- name: Lint
run: npm run lint
working-directory: frontend

View File

@@ -10,6 +10,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
/**
@@ -24,6 +26,9 @@ public class AuthE2EController {
private final PasswordResetTokenRepository tokenRepository;
// Hidden from the OpenAPI spec — this endpoint must never appear in the generated api.ts
// even when the e2e profile is active alongside the dev profile during spec generation.
@Operation(hidden = true)
@GetMapping("/reset-token-for-test")
public ResponseEntity<String> getResetTokenForTest(@RequestParam String email) {
return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now())

View File

@@ -15,8 +15,11 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@@ -189,4 +192,65 @@ class DocumentRepositoryTest {
assertThat(result.getTotalElements()).isEqualTo(5);
assertThat(result.getContent()).allMatch(d -> !d.isMetadataComplete());
}
// ─── findSinglePersonCorrespondence — DISTINCT / multi-receiver safety ────
@Test
void findSinglePersonCorrespondence_returnsExactlyOneResult_whenDocumentHasThreeReceiversAndOneMatchesPersonId() {
Person sender = personRepository.save(Person.builder()
.firstName("Hans").lastName("Müller").build());
Person receiver1 = personRepository.save(Person.builder()
.firstName("Anna").lastName("Schmidt").build());
Person receiver2 = personRepository.save(Person.builder()
.firstName("Bertha").lastName("Wagner").build());
Person receiver3 = personRepository.save(Person.builder()
.firstName("Clara").lastName("Koch").build());
// Document addressed to all three receivers
Document doc = documentRepository.save(Document.builder()
.title("Rundschreiben")
.originalFilename("rundschreiben.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.receivers(new HashSet<>(Set.of(receiver1, receiver2, receiver3)))
.documentDate(LocalDate.of(1950, 6, 1))
.build());
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
LocalDate from = LocalDate.of(1900, 1, 1);
LocalDate to = LocalDate.of(2000, 1, 1);
// Query for receiver1 — the DISTINCT must collapse the 3 JOIN rows into 1 result
List<Document> results = documentRepository.findSinglePersonCorrespondence(
receiver1.getId(), from, to, sort);
assertThat(results).hasSize(1);
assertThat(results.get(0).getId()).isEqualTo(doc.getId());
}
@Test
void findSinglePersonCorrespondence_includesDocumentsWherePerson_isSender() {
Person sender = personRepository.save(Person.builder()
.firstName("Hans").lastName("Müller").build());
Person receiver = personRepository.save(Person.builder()
.firstName("Anna").lastName("Schmidt").build());
documentRepository.save(Document.builder()
.title("Brief als Absender")
.originalFilename("brief_absender.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.receivers(new HashSet<>(Set.of(receiver)))
.documentDate(LocalDate.of(1950, 6, 1))
.build());
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
LocalDate from = LocalDate.of(1900, 1, 1);
LocalDate to = LocalDate.of(2000, 1, 1);
List<Document> results = documentRepository.findSinglePersonCorrespondence(
sender.getId(), from, to, sort);
assertThat(results).hasSize(1);
}
}

View File

@@ -1,9 +1,16 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
}
test.describe('Korrespondenz empty state', () => {
test('shows the search heading when no person is selected', async ({ page }) => {
await page.goto('/korrespondenz');
await expect(page.getByText(/Korrespondenz durchsuchen/i)).toBeVisible();
const a11y = await buildAxe(page).analyze();
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
await page.screenshot({ path: 'test-results/e2e/korrespondenz-empty.png' });
});
@@ -37,6 +44,8 @@ test.describe('Korrespondenz single-person mode', () => {
const filterStrip = page.locator('[aria-disabled="false"]').first();
await expect(filterStrip).toBeAttached();
const a11y = await buildAxe(page).analyze();
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
await page.screenshot({ path: 'test-results/e2e/korrespondenz-single-person.png' });
});
@@ -75,10 +84,14 @@ test.describe('Korrespondenz bilateral mode', () => {
// Hint bar should NOT be shown in bilateral mode
await expect(page.getByText(/Alle Briefe von/i)).not.toBeVisible();
const a11y = await buildAxe(page).analyze();
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
await page.screenshot({ path: 'test-results/e2e/korrespondenz-bilateral.png' });
} else {
// No bilateral data available for this person — skip with a note
test.skip(true, `No bilateral correspondent links found for person ${senderId}`);
// E2E seed must include bilateral correspondents — a missing link is a test failure.
throw new Error(
`No bilateral correspondent links found for person ${senderId}. Ensure the E2E seed contains at least one bilateral correspondence pair.`
);
}
});

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { isoToGerman, handleGermanDateInput, germanToIso } from '$lib/utils/date';
import { m } from '$lib/paraglide/messages.js';
interface Props {
value?: string;
errorMessage?: string | null;
name?: string;
id?: string;
placeholder?: string;
class?: string;
onchange?: () => void;
}
let {
value = $bindable(''),
errorMessage = $bindable<string | null>(null),
name,
id,
placeholder,
class: className = '',
onchange
}: Props = $props();
let display = $state(isoToGerman(value ?? ''));
function handleInput(e: Event) {
const result = handleGermanDateInput(e);
display = result.display;
if (result.display === '') {
value = '';
errorMessage = null;
return;
}
if (result.display.length < 10) {
value = '';
errorMessage = m.form_date_error();
return;
}
const iso = germanToIso(result.display);
if (!iso) {
value = '';
errorMessage = m.form_date_error();
return;
}
const [, mo, d] = iso.split('-').map(Number);
if (mo < 1 || mo > 12 || d < 1 || d > 31) {
value = '';
errorMessage = m.form_date_error();
return;
}
value = iso;
errorMessage = null;
onchange?.();
}
</script>
<input
type="text"
inputmode="numeric"
maxlength="10"
id={id}
value={display}
placeholder={placeholder ?? m.form_placeholder_date()}
oninput={handleInput}
class={className}
/>
{#if name}
<input type="hidden" name={name} value={value} />
{/if}

View File

@@ -3,9 +3,6 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import DateInput from './DateInput.svelte';
/** Wait one macrotask so Svelte can flush reactive DOM updates. */
const domFlush = () => new Promise<void>((r) => setTimeout(r, 50));
afterEach(() => cleanup());
// ─── Rendering ────────────────────────────────────────────────────────────────
@@ -197,10 +194,9 @@ describe('DateInput hidden input for form submission', () => {
render(DateInput, { name: 'documentDate', value: '' });
const input = page.getByRole('textbox');
await input.fill('20122024');
await domFlush();
const hidden = document.querySelector<HTMLInputElement>(
'input[type="hidden"][name="documentDate"]'
);
expect(hidden?.value).toBe('2024-12-20');
await expect.poll(() => hidden?.value).toBe('2024-12-20');
});
});

View File

@@ -724,22 +724,8 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/auth/reset-token-for-test": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getResetTokenForTest"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
// "/api/auth/reset-token-for-test" removed — @Operation(hidden=true) on AuthE2EController.
// Regenerate with `npm run generate:api` after the next backend build to keep in sync.
"/api/admin/import-status": {
parameters: {
query?: never;
@@ -2514,28 +2500,7 @@ export interface operations {
};
};
};
getResetTokenForTest: {
parameters: {
query: {
email: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": string;
};
};
};
};
// getResetTokenForTest removed — @Operation(hidden=true) on AuthE2EController.
importStatus: {
parameters: {
query?: never;

View File

@@ -1,37 +0,0 @@
/**
* Extra message functions for i18n keys added after the last paraglide compile.
*
* TODO: Remove this file once the root-owned paraglide files in src/lib/paraglide/
* are regenerated (run `npm run dev` or the paraglide compile step as the owning user).
* At that point, these functions will be generated into _index.js and the components
* that import from here should switch back to importing from $lib/paraglide/messages.js.
*
* Note: these fall back to German only — locale switching is handled by the generated
* paraglide files, not this shim.
*/
// Svelte auto-escapes interpolated values — do not use {@html} with these strings.
export const conv_hint_single_person = (inputs: { name: string }) =>
`Alle Briefe von ${inputs.name} — wähle einen Korrespondenten oben um einzugrenzen`;
export const conv_hint_single_person_filtered = (inputs: {
name: string;
from: string;
to: string;
sortLabel: string;
}) => `Alle Briefe von ${inputs.name} · ${inputs.from}${inputs.to} · ${inputs.sortLabel}`;
export const conv_strip_period = () => 'Zeitraum';
export const conv_strip_from_placeholder = () => 'Von…';
export const conv_strip_to_placeholder = () => 'Bis…';
export const conv_strip_all_correspondents = () => 'Alle Korrespondenten';
export const conv_strip_sort_newest = () => 'Neueste';
export const conv_strip_sort_oldest = () => 'Älteste';
export const conv_suggestions_heading = () => 'Häufigste Korrespondenten';
export const conv_suggestions_all_label = (inputs: { name: string }) =>
`Alle Korrespondenten von ${inputs.name}`;
export const conv_letters_count = (inputs: { count: number }) => `${inputs.count} Briefe`;
export const conv_empty_search_placeholder = () => 'Person suchen…';
export const conv_empty_recent_label = () => 'Zuletzt geöffnet';
export const conv_no_party = () => '—';

View File

@@ -23,19 +23,35 @@ export async function load({ fetch, locals }) {
if (!hasAnyAdminPerm(user)) throw error(403, getErrorMessage('FORBIDDEN'));
const api = createApiClient(fetch);
// TODO: replace with a dedicated /api/admin/stats endpoint that returns counts only,
// so the System page does not load full entity lists it does not render.
const [usersResult, groupsResult, tagsResult] = await Promise.all([
api.GET('/api/users'),
api.GET('/api/groups'),
api.GET('/api/tags')
]);
if (!usersResult.response.ok) {
const code = (usersResult.error as unknown as { code?: string })?.code;
throw error(usersResult.response.status, getErrorMessage(code));
}
if (!groupsResult.response.ok) {
const code = (groupsResult.error as unknown as { code?: string })?.code;
throw error(groupsResult.response.status, getErrorMessage(code));
}
if (!tagsResult.response.ok) {
const code = (tagsResult.error as unknown as { code?: string })?.code;
throw error(tagsResult.response.status, getErrorMessage(code));
}
return {
userCount: (usersResult.data ?? []).length,
groupCount: (groupsResult.data ?? []).length,
tagCount: (tagsResult.data ?? []).length,
canManageUsers: hasPerm(user, 'ADMIN_USER'),
canManageTags: hasPerm(user, 'ADMIN_TAG'),
canManageGroups: hasPerm(user, 'ADMIN_PERMISSION'),
canManagePermissions: hasPerm(user, 'ADMIN_PERMISSION'),
canRunMaintenance: hasPerm(user, 'ADMIN')
};
}

View File

@@ -12,7 +12,7 @@ let { data, children } = $props();
-mt-6: cancel the global layout's pt-6 on <main>
Height fills from below the global header (64px) to bottom of viewport.
-->
<div class="-mt-6 flex overflow-hidden" style="height: calc(100vh - 65px)">
<div class="-mt-6 -mb-6 flex overflow-hidden" style="height: calc(100vh - 65px)">
<!-- Entity Nav: hidden on mobile, icon strip on tablet, full labels on desktop -->
<div class="hidden md:flex">
<EntityNav
@@ -21,7 +21,7 @@ let { data, children } = $props();
tagCount={data.tagCount}
canManageUsers={data.canManageUsers}
canManageTags={data.canManageTags}
canManageGroups={data.canManageGroups}
canManagePermissions={data.canManagePermissions}
canRunMaintenance={data.canRunMaintenance}
/>
</div>

View File

@@ -36,7 +36,7 @@ onMount(() => {
</a>
{/if}
{#if data.canManageGroups}
{#if data.canManagePermissions}
<a href="/admin/groups" class="flex items-center justify-between px-4 py-4 hover:bg-muted">
<div>
<div class="font-sans text-sm font-bold text-ink">{m.admin_tab_groups()}</div>

View File

@@ -10,7 +10,7 @@ let {
tagCount,
canManageUsers,
canManageTags,
canManageGroups,
canManagePermissions,
canRunMaintenance
}: {
userCount: number;
@@ -18,7 +18,7 @@ let {
tagCount: number;
canManageUsers: boolean;
canManageTags: boolean;
canManageGroups: boolean;
canManagePermissions: boolean;
canRunMaintenance: boolean;
} = $props();
@@ -28,6 +28,9 @@ const isActive = (section: string) => currentPath.startsWith(`/admin/${section}`
let flyoutOpen = $state(false);
let flyoutTriggerElement: HTMLButtonElement | null = null;
// All four section buttons open the same flyout that repeats the full nav.
// This is intentional: on tablet the flyout shows all sections as a wider navigation panel,
// not a context-specific panel for the clicked section.
async function openFlyout(event: MouseEvent) {
flyoutTriggerElement = event.currentTarget as HTMLButtonElement;
flyoutOpen = true;
@@ -71,6 +74,7 @@ function handleKeydown(event: KeyboardEvent) {
data-flyout-trigger
type="button"
aria-label={m.admin_tab_users()}
title={m.admin_tab_users()}
onclick={openFlyout}
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
{isActive('users')
@@ -131,12 +135,13 @@ function handleKeydown(event: KeyboardEvent) {
</a>
{/if}
{#if canManageGroups}
{#if canManagePermissions}
<!-- Tablet trigger button (md only, hidden at lg) -->
<button
data-flyout-trigger
type="button"
aria-label={m.admin_tab_groups()}
title={m.admin_tab_groups()}
onclick={openFlyout}
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
{isActive('groups')
@@ -203,6 +208,7 @@ function handleKeydown(event: KeyboardEvent) {
data-flyout-trigger
type="button"
aria-label={m.admin_tab_tags()}
title={m.admin_tab_tags()}
onclick={openFlyout}
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 transition-colors lg:hidden
{isActive('tags')
@@ -273,6 +279,7 @@ function handleKeydown(event: KeyboardEvent) {
data-flyout-trigger
type="button"
aria-label={m.admin_tab_system()}
title={m.admin_tab_system()}
onclick={openFlyout}
class="flex min-h-[44px] w-full flex-col items-center justify-center gap-0.5 border-t border-l-[3px] border-white/10 py-3 transition-colors lg:hidden
{isActive('system')
@@ -390,7 +397,7 @@ function handleKeydown(event: KeyboardEvent) {
</a>
{/if}
{#if canManageGroups}
{#if canManagePermissions}
<a
href="/admin/groups"
onclick={closeFlyout}

View File

@@ -15,7 +15,7 @@ const props = {
tagCount: 8,
canManageUsers: true,
canManageTags: true,
canManageGroups: true,
canManagePermissions: true,
canRunMaintenance: true
};

View File

@@ -66,7 +66,7 @@ describe('admin layout load — permission check', () => {
expect(result.tagCount).toBe(3);
expect(result.canManageUsers).toBe(true);
expect(result.canManageTags).toBe(true);
expect(result.canManageGroups).toBe(true);
expect(result.canManagePermissions).toBe(true);
expect(result.canRunMaintenance).toBe(true);
});
});

View File

@@ -19,7 +19,7 @@ const fullPerms = {
tagCount: 7,
canManageUsers: true,
canManageTags: true,
canManageGroups: true,
canManagePermissions: true,
canRunMaintenance: true
};

View File

@@ -16,7 +16,7 @@ const fullData = {
tagCount: 7,
canManageUsers: true,
canManageTags: true,
canManageGroups: true,
canManagePermissions: true,
canRunMaintenance: true
};

View File

@@ -1,5 +1,7 @@
import { error } from '@sveltejs/kit';
import type { components } from '$lib/generated/api';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
export async function load({ url, fetch, locals }) {
const senderId = url.searchParams.get('senderId') || '';
@@ -35,14 +37,22 @@ export async function load({ url, fetch, locals }) {
}
}
})
.then(({ data }) => {
documents = data ?? [];
.then((result) => {
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
documents = result.data ?? [];
})
);
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then(({ data }) => {
const p = data as { firstName: string; lastName: string } | undefined;
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then((result) => {
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
const p = result.data as { firstName: string; lastName: string } | undefined;
if (p) senderName = `${p.firstName} ${p.lastName}`;
})
);
@@ -50,8 +60,12 @@ export async function load({ url, fetch, locals }) {
if (receiverId) {
requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then(({ data }) => {
const p = data as { firstName: string; lastName: string } | undefined;
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then((result) => {
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
const p = result.data as { firstName: string; lastName: string } | undefined;
if (p) receiverName = `${p.firstName} ${p.lastName}`;
})
);

View File

@@ -11,25 +11,21 @@ import { m } from '$lib/paraglide/messages.js';
let { data } = $props();
// Filter values are local $state so swapPersons/toggleSort can mutate them before goto.
// They are initialised once from server data and never re-synced — navigation replaces
// the page component, so each load gets a fresh init.
let senderId = $state(untrack(() => data.filters.senderId));
let receiverId = $state(untrack(() => data.filters.receiverId));
let fromDate = $state(untrack(() => data.filters.from));
let toDate = $state(untrack(() => data.filters.to));
let sortDir = $state(untrack(() => data.filters.dir));
// Derived name states — kept as reactive copies so ConversationTimeline always has current names
let senderName = $state(untrack(() => data.initialValues.senderName));
let receiverName = $state(untrack(() => data.initialValues.receiverName));
// Names are pure reads of server data — no local mutation needed.
const senderName = $derived(data.initialValues.senderName);
const receiverName = $derived(data.initialValues.receiverName);
// Sync with server data after navigation; persist recent persons once the name is resolved
// Side-effect only: persist the resolved sender to localStorage once the name is available.
$effect(() => {
senderId = data.filters.senderId;
receiverId = data.filters.receiverId;
fromDate = data.filters.from;
toDate = data.filters.to;
sortDir = data.filters.dir;
senderName = data.initialValues.senderName;
receiverName = data.initialValues.receiverName;
if (data.filters.senderId && data.initialValues.senderName) {
persistRecentPerson(data.filters.senderId, data.initialValues.senderName);
}

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { formatDate } from '$lib/utils/date';
import { m } from '$lib/paraglide/messages.js';
import { conv_no_party } from '$lib/messages-extra';
interface Props {
documents: {
@@ -56,21 +55,21 @@ const shortReceiverName = $derived(receiverName?.split(' ')[0] ?? receiverName ?
function statusDotClass(status: string): string {
const map: Record<string, string> = {
PLACEHOLDER: 'bg-yellow-400',
UPLOADED: 'bg-green-500',
TRANSCRIBED: 'bg-blue-500',
REVIEWED: 'bg-purple-500',
ARCHIVED: 'bg-gray-500'
PLACEHOLDER: 'bg-brand-sand',
UPLOADED: 'bg-brand-mint',
TRANSCRIBED: 'bg-brand-mint',
REVIEWED: 'bg-brand-navy/70',
ARCHIVED: 'bg-brand-navy'
};
return map[status] ?? 'bg-gray-300';
return map[status] ?? 'bg-brand-sand';
}
function otherPartyName(doc: (typeof documents)[number]): string {
if (doc.sender?.id === senderId) {
const r = doc.receivers?.[0];
return r ? `${r.firstName} ${r.lastName}` : conv_no_party();
return r ? `${r.firstName} ${r.lastName}` : m.conv_no_party();
}
return doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : conv_no_party();
return doc.sender ? `${doc.sender.firstName} ${doc.sender.lastName}` : m.conv_no_party();
}
const newDocUrl = $derived(

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { conv_suggestions_heading, conv_suggestions_all_label } from '$lib/messages-extra';
import { m } from '$lib/paraglide/messages.js';
interface Correspondent {
id: string;
@@ -9,29 +8,14 @@ interface Correspondent {
}
interface Props {
senderId: string;
correspondents: Correspondent[];
loading: boolean;
senderName: string;
onselect: (id: string) => void;
onclose: () => void;
}
let { senderId, senderName, onselect, onclose }: Props = $props();
let results = $state<Correspondent[]>([]);
let loading = $state(true);
onMount(() => {
(async () => {
try {
const res = await fetch(`/api/persons/${senderId}/correspondents`);
results = res.ok ? await res.json() : [];
} catch {
results = [];
} finally {
loading = false;
}
})();
});
let { correspondents, loading, senderName, onselect, onclose }: Props = $props();
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
@@ -78,18 +62,18 @@ function getInitials(person: Correspondent): string {
use:clickOutside
role="listbox"
tabindex="-1"
aria-label={conv_suggestions_heading()}
aria-label={m.conv_suggestions_heading()}
class="absolute top-full right-0 left-0 z-30 mt-1 rounded-sm border border-line bg-surface shadow-lg"
onkeydown={(e) => handleKeydown(e, e.currentTarget as HTMLElement)}
>
<!-- Heading -->
<div class="px-3 pt-2 pb-1 text-[10px] font-bold tracking-widest text-ink-3 uppercase">
{conv_suggestions_heading()}
{m.conv_suggestions_heading()}
</div>
<!-- Correspondent rows -->
{#if !loading}
{#each results as person (person.id)}
{#each correspondents as person (person.id)}
<div
role="option"
aria-selected="false"
@@ -123,6 +107,6 @@ function getInitials(person: Correspondent): string {
onclick={() => onselect('')}
onkeydown={(e) => e.key === 'Enter' && onselect('')}
>
{conv_suggestions_all_label({ name: senderName })}
{m.conv_suggestions_all_label({ name: senderName })}
</div>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { conv_empty_search_placeholder, conv_empty_recent_label } from '$lib/messages-extra';
import { m } from '$lib/paraglide/messages.js';
interface RecentPerson {
id: string;
@@ -50,18 +50,18 @@ onMount(() => {
</div>
<!-- Heading -->
<h2 class="font-serif text-xl font-black text-ink">Korrespondenz durchsuchen</h2>
<h2 class="font-serif text-xl font-black text-ink">{m.conv_empty_heading()}</h2>
<!-- Subtext -->
<p class="max-w-sm text-base text-ink-3">
Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent.
{m.conv_empty_text()}
</p>
<!-- Search input placeholder (visual only — clicking focuses Person A typeahead above) -->
<button
type="button"
data-testid="conv-empty-search"
aria-label={conv_empty_search_placeholder()}
aria-label={m.conv_empty_search_placeholder()}
onclick={() => onSelectPerson('')}
class="flex h-10 w-full max-w-sm items-center rounded border border-line bg-muted px-4 text-sm text-ink-3 italic transition-colors hover:border-primary"
>
@@ -81,7 +81,7 @@ onMount(() => {
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
Person suchen…
{m.conv_empty_search_placeholder()}
</button>
<!-- Recent persons — only shown when localStorage has entries -->
@@ -95,7 +95,7 @@ onMount(() => {
<div class="flex w-full max-w-sm flex-col items-center gap-3">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{conv_empty_recent_label()}
{m.conv_empty_recent_label()}
</span>
<div class="flex flex-wrap justify-center gap-2">
{#each recentPersons as person (person.id)}

View File

@@ -1,12 +1,6 @@
<script lang="ts">
import {
conv_strip_period,
conv_strip_from_placeholder,
conv_strip_to_placeholder,
conv_strip_sort_newest,
conv_strip_sort_oldest,
conv_letters_count
} from '$lib/messages-extra';
import { m } from '$lib/paraglide/messages.js';
import DateInput from '$lib/components/DateInput.svelte';
interface Props {
senderId: string;
@@ -40,33 +34,25 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
>
<!-- Period label -->
<span class="hidden text-xs font-bold tracking-wide text-ink-3 uppercase sm:block">
{conv_strip_period()}
{m.conv_strip_period()}
</span>
<!-- From date -->
<input
type="date"
<DateInput
bind:value={fromDate}
onchange={() => onapplyFilters()}
placeholder={conv_strip_from_placeholder()}
aria-label="Von"
class="h-8 min-h-[44px] w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none sm:min-h-0"
class:border-primary={!!fromDate}
class:border-line={!fromDate}
placeholder={m.conv_strip_from_placeholder()}
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none {fromDate ? 'border-primary' : 'border-line'}"
/>
<span class="text-xs text-ink-3"></span>
<!-- To date -->
<input
type="date"
<DateInput
bind:value={toDate}
onchange={() => onapplyFilters()}
placeholder={conv_strip_to_placeholder()}
aria-label="Bis"
class="h-8 min-h-[44px] w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none sm:min-h-0"
class:border-primary={!!toDate}
class:border-line={!toDate}
placeholder={m.conv_strip_to_placeholder()}
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none {toDate ? 'border-primary' : 'border-line'}"
/>
<!-- Document count -->
@@ -76,7 +62,7 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
class:text-primary={hasDateFilter}
class:text-ink-3={!hasDateFilter}
>
{conv_letters_count({ count: documentCount ?? 0 })}
{m.conv_letters_count({ count: documentCount ?? 0 })}
</span>
<!-- Sort button -->
@@ -86,14 +72,14 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
aria-label="Sortierung umkehren"
aria-pressed={sortDir === 'ASC'}
onclick={ontoggleSort}
class="flex h-8 min-h-[44px] items-center gap-1 rounded border px-3 text-xs font-bold sm:min-h-0"
class="flex h-8 min-h-[44px] items-center gap-1 rounded border px-3 text-xs font-bold"
class:border-primary={isActive}
class:text-primary={isActive}
class:border-line={!isActive}
class:text-ink-3={!isActive}
>
{#if sortDir === 'ASC'}
{conv_strip_sort_oldest()}
{m.conv_strip_sort_oldest()}
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
@@ -109,7 +95,7 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
<polyline points="18 15 12 9 6 15" />
</svg>
{:else}
{conv_strip_sort_newest()}
{m.conv_strip_sort_newest()}
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"

View File

@@ -20,12 +20,30 @@ let {
onswapPersons
}: Props = $props();
interface Correspondent {
id: string;
firstName: string;
lastName: string;
}
let swapVisible = $derived(!!(senderId && receiverId));
let showSuggestions = $state(false);
let correspondents = $state<Correspondent[]>([]);
let loadingCorrespondents = $state(false);
function handleCorrespondentFocused() {
if (senderId) showSuggestions = true;
async function handleCorrespondentFocused() {
if (!senderId) return;
showSuggestions = true;
loadingCorrespondents = true;
try {
const res = await fetch(`/api/persons/${senderId}/correspondents`);
correspondents = res.ok ? await res.json() : [];
} catch {
correspondents = [];
} finally {
loadingCorrespondents = false;
}
}
function handleSuggestionSelect(id: string) {
@@ -100,7 +118,8 @@ function handleSuggestionSelect(id: string) {
/>
{#if showSuggestions && senderId && !receiverId}
<CorrespondentSuggestionsDropdown
senderId={senderId}
correspondents={correspondents}
loading={loadingCorrespondents}
senderName=""
onselect={handleSuggestionSelect}
onclose={() => (showSuggestions = false)}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { conv_strip_sort_newest, conv_strip_sort_oldest } from '$lib/messages-extra';
import { m } from '$lib/paraglide/messages.js';
interface Props {
senderName: string;
@@ -11,15 +11,32 @@ interface Props {
let { senderName, fromDate = '', toDate = '', sortDir = 'DESC' }: Props = $props();
let hasDateFilter = $derived(!!(fromDate || toDate));
let sortLabel = $derived(sortDir === 'ASC' ? conv_strip_sort_oldest() : conv_strip_sort_newest());
let sortLabel = $derived(
sortDir === 'ASC' ? m.conv_strip_sort_oldest() : m.conv_strip_sort_newest()
);
let fromYear = $derived(fromDate ? fromDate.substring(0, 4) : '');
let toYear = $derived(toDate ? toDate.substring(0, 4) : '');
</script>
<div
class="flex items-center gap-[5px] border-b border-[#FDBA74] bg-[#FFF7ED] px-[18px] py-[6px] text-xs text-[#92400E]"
class="bg-brand-sand/30 flex items-center gap-[5px] border-b border-brand-mint px-[18px] py-[6px] text-xs text-brand-navy/80"
>
<span class="text-sm" aria-hidden="true">📋</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="shrink-0"
>
<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2" />
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
{#if hasDateFilter}
<strong>{senderName}</strong>

View File

@@ -0,0 +1,146 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { load } from './+page.server';
vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() }));
vi.mock('$lib/errors', () => ({ getErrorMessage: (code: string) => code ?? 'Unknown error' }));
import { createApiClient } from '$lib/api.server';
const writeUser = { groups: [{ permissions: ['WRITE_ALL'] }] };
const readUser = { groups: [{ permissions: ['READ_ALL'] }] };
function makeUrl(params: Record<string, string> = {}): URL {
const url = new URL('http://x/korrespondenz');
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
return url;
}
function mockApi(calls: { ok: boolean; data?: unknown; status?: number }[]) {
const GET = vi.fn();
for (const call of calls) {
GET.mockResolvedValueOnce({
response: { ok: call.ok, status: call.status ?? (call.ok ? 200 : 500) },
data: call.data,
error: call.ok ? undefined : { code: 'INTERNAL_ERROR' }
});
}
vi.mocked(createApiClient).mockReturnValue({ GET } as ReturnType<typeof createApiClient>);
return GET;
}
beforeEach(() => vi.clearAllMocks());
// ─── No senderId ──────────────────────────────────────────────────────────────
describe('korrespondenz load — no senderId', () => {
it('returns empty documents without calling the conversation endpoint', async () => {
const GET = mockApi([]);
const result = await load({
url: makeUrl(),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: readUser }
});
expect(result.documents).toEqual([]);
expect(GET).not.toHaveBeenCalled();
});
});
// ─── With senderId, no receiverId ────────────────────────────────────────────
describe('korrespondenz load — senderId set, no receiverId', () => {
it('calls the conversation endpoint and the sender person endpoint', async () => {
const docs = [{ id: 'd1', title: 'Testbrief' }];
const GET = mockApi([
{ ok: true, data: docs },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
]);
const result = await load({
url: makeUrl({ senderId: 'p1' }),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: readUser }
});
expect(result.documents).toEqual(docs);
expect(result.initialValues.senderName).toBe('Hans Müller');
expect(result.initialValues.receiverName).toBe('');
expect(GET).toHaveBeenCalledTimes(2);
});
});
// ─── With senderId and receiverId ────────────────────────────────────────────
describe('korrespondenz load — senderId and receiverId set', () => {
it('calls conversation, sender person, and receiver person endpoints', async () => {
const GET = mockApi([
{ ok: true, data: [] },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } },
{ ok: true, data: { firstName: 'Anna', lastName: 'Schmidt' } }
]);
const result = await load({
url: makeUrl({ senderId: 'p1', receiverId: 'p2' }),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: readUser }
});
expect(result.initialValues.senderName).toBe('Hans Müller');
expect(result.initialValues.receiverName).toBe('Anna Schmidt');
expect(GET).toHaveBeenCalledTimes(3);
});
});
// ─── canWrite derivation ─────────────────────────────────────────────────────
describe('korrespondenz load — canWrite', () => {
it('derives canWrite true from WRITE_ALL permission', async () => {
mockApi([
{ ok: true, data: [] },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
]);
const result = await load({
url: makeUrl({ senderId: 'p1' }),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: writeUser }
});
expect(result.canWrite).toBe(true);
});
it('derives canWrite false when user lacks WRITE_ALL', async () => {
mockApi([
{ ok: true, data: [] },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
]);
const result = await load({
url: makeUrl({ senderId: 'p1' }),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: readUser }
});
expect(result.canWrite).toBe(false);
});
});
// ─── Backend error propagation ────────────────────────────────────────────────
describe('korrespondenz load — backend error', () => {
it('throws when the conversation endpoint returns non-ok', async () => {
mockApi([
{ ok: false, status: 500 },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
]);
await expect(
load({
url: makeUrl({ senderId: 'p1' }),
fetch: vi.fn() as unknown as typeof fetch,
locals: { user: readUser }
})
).rejects.toMatchObject({ status: 500 });
});
});

View File

@@ -126,6 +126,12 @@ describe('Korrespondenz page filter strip Row 2 disabled state', () => {
const strip = document.querySelector('[aria-disabled="true"]');
expect(strip).not.toBeNull();
});
it('filter controls are not aria-disabled when senderId is set', async () => {
render(Page, { data: withSender });
const strip = document.querySelector('[aria-disabled="false"]');
expect(strip).not.toBeNull();
});
});
// ─── Strip letter count ───────────────────────────────────────────────────────