Files
familienarchiv/frontend/src/lib/timeline/eventFormServer.ts
Marcel c13baa4785 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>
2026-06-14 00:28:28 +02:00

144 lines
5.5 KiB
TypeScript

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;
// Whitelist of accepted precision tokens — mirrors the DatePrecision union. Any
// other submitted value falls back to DAY rather than flowing untrusted into the
// request body (the backend enum is the hard gate; this keeps the two symmetric
// with the `type` narrowing below).
const VALID_PRECISIONS: readonly DatePrecision[] = [
'DAY',
'MONTH',
'SEASON',
'YEAR',
'RANGE',
'APPROX',
'UNKNOWN'
];
/**
* Resolves the context-aware post-save / post-delete redirect target. Returns
* the originating person page only when `originPersonIdRaw` is a strict UUID;
* otherwise falls back to the timeline (open-redirect guard).
*/
export function resolveNavTarget(originPersonIdRaw: string): string {
return UUID_RE.test(originPersonIdRaw) ? `/persons/${originPersonIdRaw}` : '/zeitstrahl';
}
export interface ParsedEventForm {
title: string;
type: 'PERSONAL' | 'HISTORICAL';
eventDate: string;
precision: DatePrecision;
eventDateEnd: string | null;
description: string;
personIds: string[];
documentIds: string[];
originPersonId: string;
}
/** Reads the curator event form fields out of submitted FormData. */
export function parseEventForm(formData: FormData): ParsedEventForm {
const rawType = formData.get('type')?.toString();
const type = rawType === 'HISTORICAL' ? 'HISTORICAL' : 'PERSONAL';
const rawPrecision = formData.get('precision')?.toString() as DatePrecision | undefined;
const precision: DatePrecision =
rawPrecision && VALID_PRECISIONS.includes(rawPrecision) ? rawPrecision : 'DAY';
const endRaw = formData.get('eventDateEnd')?.toString().trim() ?? '';
// Off-RANGE submits an empty string → null so a stale end-date never persists.
const eventDateEnd = precision === 'RANGE' && endRaw ? endRaw : null;
return {
title: formData.get('title')?.toString().trim() ?? '',
type,
eventDate: formData.get('eventDate')?.toString().trim() ?? '',
precision,
eventDateEnd,
description: formData.get('description')?.toString().trim() ?? '',
personIds: formData.getAll('personIds').map((v) => v.toString()),
documentIds: formData.getAll('documentIds').map((v) => v.toString()),
originPersonId: formData.get('originPersonId')?.toString() ?? ''
};
}
/**
* 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
): { 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 { 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. */
export function toEventRequest(parsed: ParsedEventForm, version?: number): TimelineEventRequest {
return {
title: parsed.title,
type: parsed.type,
eventDate: parsed.eventDate,
precision: parsed.precision,
eventDateEnd: parsed.eventDateEnd,
...(parsed.description ? { description: parsed.description } : {}),
...(parsed.personIds.length ? { personIds: parsed.personIds } : {}),
...(parsed.documentIds.length ? { documentIds: parsed.documentIds } : {}),
...(version !== undefined ? { version } : {})
} as TimelineEventRequest;
}