fix(timeline): rehydrate event-form pickers after a validation failure

Decision 6 / REQ-010 promised the pickers survive a fail(400) "including
pre-selected persons/documents", but EventForm only seeded them from
event/initialPersons — never from the fail payload — and the payload carried
only bare ids, which can't rebuild a chip (chips need displayName/title). On
the use:enhance path the in-memory selection survived; on a no-JS full reload
the chips were silently dropped.

Now the save action re-fetches the selected persons/documents by id
(lookupSelections, non-ok swallowed like the prefill path) and returns full
chip data; EventForm seeds the pickers from form.persons/documents ahead of
the seeded event. Extract preservedFormFields() to DRY the four fail payloads;
validateEventForm now returns the error pair and the route owns the fail().

Component test pins the rehydration; the server spec now asserts the fail
payload carries labelled chips, not just ids.

Addresses PR #832 review (Developer + Requirements Engineer concern, REQ-010).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-14 00:28:28 +02:00
parent cd5649b96e
commit c13baa4785
6 changed files with 133 additions and 41 deletions

View File

@@ -30,6 +30,10 @@ interface FormResult {
type?: string;
personIds?: string[];
documentIds?: string[];
// Rehydrated chip data (id + label) so the pickers re-render after a fail(400)
// even on a no-JS full reload — bare ids alone can't rebuild a chip (REQ-010).
persons?: PersonOption[];
documents?: DocumentOption[];
}
let {
@@ -46,8 +50,10 @@ let {
form?: FormResult | null;
} = $props();
// Initial-state snapshot from incoming props (event > preserved fail payload).
// The form owns these after mount; re-mount with a different `event` to reset.
// Initial-state snapshot from incoming props, preferring a preserved fail payload
// over the seeded `event`. This component is intentionally single-shot: props are
// snapshotted into $state once, so a parent re-render with a different `event`
// won't update the form — the two dedicated routes always remount, which is fine.
let title = $state(form?.title ?? event?.title ?? '');
let description = $state(form?.description ?? event?.description ?? '');
let type = $state<string>(form?.type ?? event?.type ?? 'PERSONAL');
@@ -55,19 +61,23 @@ let dateIso = $state(event?.eventDate ?? '');
let precision = $state<DatePrecision>((event?.precision as DatePrecision) ?? 'DAY');
let endDateIso = $state(event?.eventDateEnd ?? '');
// On a fail(400) the server returns rehydrated chip data (form.persons/documents)
// so the pickers survive the round-trip — even without JS — ahead of the seeded
// `event` or the prefill initials (REQ-010 / Decision 6).
let selectedPersons = $state<PersonOption[]>(
event?.persons ? event.persons.map(toPersonOption) : initialPersons
form?.persons ?? (event?.persons ? event.persons.map(toPersonOption) : initialPersons)
);
let selectedDocuments = $state<DocumentOption[]>(
event?.documents
? event.documents.map((d) => ({
// Graceful degradation: DocumentRef has no precision fields. formatDocumentOption
// defaults a missing precision to DAY, so the chip shows the full documentDate.
id: d.id,
title: d.title,
documentDate: d.documentDate
}))
: initialDocuments
form?.documents ??
(event?.documents
? event.documents.map((d) => ({
// Graceful degradation: DocumentRef has no precision fields. formatDocumentOption
// defaults a missing precision to DAY, so the chip shows the full documentDate.
id: d.id,
title: d.title,
documentDate: d.documentDate
}))
: initialDocuments)
);
const isEdit = $derived(event !== undefined);

View File

@@ -58,6 +58,19 @@ describe('EventForm — required-field error (REQ-010)', () => {
await page.getByRole('button', { name: 'Speichern' }).click();
await expect.element(page.getByText('Bitte einen Titel eingeben.')).toBeInTheDocument();
});
it('rehydrates the pickers from the fail payload (Decision 6)', async () => {
render(EventForm, {
form: {
titleError: 'Bitte einen Titel eingeben.',
title: '',
persons: [{ id: 'p1', displayName: 'Anna Müller' }],
documents: [{ id: 'd1', title: 'Brief A', documentDate: '1925-04-01' }]
}
});
await expect.element(page.getByText('Anna Müller')).toBeInTheDocument();
await expect.element(page.getByText(/Brief A/)).toBeInTheDocument();
});
});
describe('EventForm — submitting state (named AC)', () => {

View File

@@ -1,9 +1,12 @@
import { fail } from '@sveltejs/kit';
import { m } from '$lib/paraglide/messages.js';
import { createApiClient } from '$lib/shared/api.server';
import type { components } from '$lib/generated/api';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
import type { PersonOption } from '$lib/person/personOption';
import type { DocumentOption } from '$lib/document/documentTypeahead';
type TimelineEventRequest = components['schemas']['TimelineEventRequest'];
type ApiClient = ReturnType<typeof createApiClient>;
// Prevents open redirect: validate before constructing /persons/{id}. See OWASP CWE-601.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -68,24 +71,60 @@ export function parseEventForm(formData: FormData): ParsedEventForm {
}
/**
* Surfaces all failing required-field errors simultaneously (title + date) via a
* fail(400) payload that also preserves every entered value — including the
* picker arrays — so the form re-renders without losing state. Returns null when
* the form is valid.
* Returns both failing required-field errors (title + date) simultaneously, or
* null when the form is valid. The route owns the `fail(400)` so it can enrich
* the payload with the preserved field values and rehydrated picker selections.
*/
export function validateEventForm(parsed: ParsedEventForm) {
export function validateEventForm(
parsed: ParsedEventForm
): { titleError: string; dateError: string } | null {
const titleError = parsed.title.length === 0 ? m.event_editor_title_required() : '';
const dateError = parsed.eventDate.length === 0 ? m.event_editor_date_required() : '';
if (!titleError && !dateError) return null;
return fail(400, {
titleError,
dateError,
return { titleError, dateError };
}
/** The entered field values echoed back in every `fail(...)` so the form re-renders without loss. */
export function preservedFormFields(parsed: ParsedEventForm) {
return {
title: parsed.title,
description: parsed.description,
type: parsed.type,
personIds: parsed.personIds,
documentIds: parsed.documentIds
});
};
}
/**
* Re-fetches the selected persons/documents by id so a `fail(400)` can re-render
* the pickers with full chip labels — the form only resubmits bare ids, which
* cannot rebuild a chip on their own (Decision 6 / REQ-010). Non-ok lookups are
* swallowed: a since-deleted id silently drops from the picker rather than
* leaking existence, mirroring the prefill path in the new-route load.
*/
export async function lookupSelections(
api: ApiClient,
personIds: string[],
documentIds: string[]
): Promise<{ persons: PersonOption[]; documents: DocumentOption[] }> {
const [personResults, documentResults] = await Promise.all([
Promise.all(personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } }))),
Promise.all(
documentIds.map((id) => api.GET('/api/documents/{id}', { params: { path: { id } } }))
)
]);
return {
persons: personResults.filter((r) => r.response.ok && r.data).map((r) => r.data!),
documents: documentResults
.filter((r) => r.response.ok && r.data)
.map((r) => ({
id: r.data!.id,
title: r.data!.title,
documentDate: r.data!.documentDate,
metaDatePrecision: r.data!.metaDatePrecision,
metaDateEnd: r.data!.metaDateEnd
}))
};
}
/** Builds the TimelineEventRequest write body from parsed form fields. */