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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user