fix(timeline): validate RANGE end-date client-side with a field-level error
A RANGE event with a blank end date passed validateEventForm and reached the backend, which 400s with a generic INVALID_DATE_RANGE mapped to "end must not be before start" — wrong for a missing end date, and shown only as a top-of-form alert. Validate it before the API call and surface a dedicated event_editor_end_date_required message on the end-date field via a new DatePrecisionField endDateError prop (defaults '', so the document form is unchanged). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1056,6 +1056,7 @@
|
|||||||
"event_editor_title_placeholder": "Titel des Ereignisses",
|
"event_editor_title_placeholder": "Titel des Ereignisses",
|
||||||
"event_editor_title_required": "Bitte einen Titel eingeben.",
|
"event_editor_title_required": "Bitte einen Titel eingeben.",
|
||||||
"event_editor_date_required": "Bitte ein Datum eingeben.",
|
"event_editor_date_required": "Bitte ein Datum eingeben.",
|
||||||
|
"event_editor_end_date_required": "Bitte ein Enddatum eingeben.",
|
||||||
"event_editor_type_label": "Typ",
|
"event_editor_type_label": "Typ",
|
||||||
"event_editor_persons_label": "Personen",
|
"event_editor_persons_label": "Personen",
|
||||||
"event_editor_documents_label": "Briefe",
|
"event_editor_documents_label": "Briefe",
|
||||||
|
|||||||
@@ -1056,6 +1056,7 @@
|
|||||||
"event_editor_title_placeholder": "Event title",
|
"event_editor_title_placeholder": "Event title",
|
||||||
"event_editor_title_required": "Please enter a title.",
|
"event_editor_title_required": "Please enter a title.",
|
||||||
"event_editor_date_required": "Please enter a date.",
|
"event_editor_date_required": "Please enter a date.",
|
||||||
|
"event_editor_end_date_required": "Please enter an end date.",
|
||||||
"event_editor_type_label": "Type",
|
"event_editor_type_label": "Type",
|
||||||
"event_editor_persons_label": "People",
|
"event_editor_persons_label": "People",
|
||||||
"event_editor_documents_label": "Letters",
|
"event_editor_documents_label": "Letters",
|
||||||
|
|||||||
@@ -1056,6 +1056,7 @@
|
|||||||
"event_editor_title_placeholder": "Título del evento",
|
"event_editor_title_placeholder": "Título del evento",
|
||||||
"event_editor_title_required": "Por favor, introduzca un título.",
|
"event_editor_title_required": "Por favor, introduzca un título.",
|
||||||
"event_editor_date_required": "Por favor, introduzca una fecha.",
|
"event_editor_date_required": "Por favor, introduzca una fecha.",
|
||||||
|
"event_editor_end_date_required": "Por favor, introduzca una fecha de fin.",
|
||||||
"event_editor_type_label": "Tipo",
|
"event_editor_type_label": "Tipo",
|
||||||
"event_editor_persons_label": "Personas",
|
"event_editor_persons_label": "Personas",
|
||||||
"event_editor_documents_label": "Cartas",
|
"event_editor_documents_label": "Cartas",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ let {
|
|||||||
dateLabel = m.form_label_date(),
|
dateLabel = m.form_label_date(),
|
||||||
dateRequired = true,
|
dateRequired = true,
|
||||||
dateError = '',
|
dateError = '',
|
||||||
|
endDateError = '',
|
||||||
onchange = undefined,
|
onchange = undefined,
|
||||||
dateTestId = undefined,
|
dateTestId = undefined,
|
||||||
precisionTestId = undefined,
|
precisionTestId = undefined,
|
||||||
@@ -53,6 +54,8 @@ let {
|
|||||||
dateRequired?: boolean;
|
dateRequired?: boolean;
|
||||||
/** Server-side date error (e.g. blank required field) wired to the field's aria-invalid. */
|
/** Server-side date error (e.g. blank required field) wired to the field's aria-invalid. */
|
||||||
dateError?: string;
|
dateError?: string;
|
||||||
|
/** Server-side end-date error (e.g. RANGE without an end date) wired to the end field. */
|
||||||
|
endDateError?: string;
|
||||||
/** Called on any user edit (date, precision, end-date) — lets a parent track dirtiness. */
|
/** Called on any user edit (date, precision, end-date) — lets a parent track dirtiness. */
|
||||||
onchange?: () => void;
|
onchange?: () => void;
|
||||||
dateTestId?: string;
|
dateTestId?: string;
|
||||||
@@ -99,6 +102,9 @@ const dateFieldInvalid = $derived(dateInvalid || dateError.length > 0);
|
|||||||
const endBeforeStart = $derived(
|
const endBeforeStart = $derived(
|
||||||
showEndDate && endDateIso !== '' && dateIso !== '' && endDateIso < dateIso
|
showEndDate && endDateIso !== '' && dateIso !== '' && endDateIso < dateIso
|
||||||
);
|
);
|
||||||
|
// Either the inline end-before-start cue or a server-provided end-date error
|
||||||
|
// (e.g. a RANGE event missing its end date) marks the end field invalid.
|
||||||
|
const endDateFieldInvalid = $derived(endBeforeStart || endDateError.length > 0);
|
||||||
|
|
||||||
function handleDateInput(e: Event) {
|
function handleDateInput(e: Event) {
|
||||||
const result = handleGermanDateInput(e);
|
const result = handleGermanDateInput(e);
|
||||||
@@ -190,10 +196,10 @@ $effect(() => {
|
|||||||
oninput={handleEndDateInput}
|
oninput={handleEndDateInput}
|
||||||
placeholder={m.form_placeholder_date()}
|
placeholder={m.form_placeholder_date()}
|
||||||
maxlength="10"
|
maxlength="10"
|
||||||
aria-invalid={endBeforeStart ? 'true' : undefined}
|
aria-invalid={endDateFieldInvalid ? 'true' : undefined}
|
||||||
aria-describedby={endBeforeStart ? `${dateInputName}-end-error` : undefined}
|
aria-describedby={endDateFieldInvalid ? `${dateInputName}-end-error` : undefined}
|
||||||
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||||
{endBeforeStart
|
{endDateFieldInvalid
|
||||||
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
|
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
|
||||||
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||||
/>
|
/>
|
||||||
@@ -202,6 +208,10 @@ $effect(() => {
|
|||||||
<p id="{dateInputName}-end-error" class="mt-1 text-xs text-danger">
|
<p id="{dateInputName}-end-error" class="mt-1 text-xs text-danger">
|
||||||
<span aria-hidden="true">⚠ </span>{m.error_invalid_date_range()}
|
<span aria-hidden="true">⚠ </span>{m.error_invalid_date_range()}
|
||||||
</p>
|
</p>
|
||||||
|
{:else if endDateError}
|
||||||
|
<p id="{dateInputName}-end-error" class="mt-1 text-xs text-danger">
|
||||||
|
<span aria-hidden="true">⚠ </span>{endDateError}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface FormResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
titleError?: string;
|
titleError?: string;
|
||||||
dateError?: string;
|
dateError?: string;
|
||||||
|
endDateError?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -104,6 +105,9 @@ const titleError = $derived(
|
|||||||
titleEmpty && (titleTouched || !!form?.titleError) ? m.event_editor_title_required() : ''
|
titleEmpty && (titleTouched || !!form?.titleError) ? m.event_editor_title_required() : ''
|
||||||
);
|
);
|
||||||
const dateError = $derived(dateIso ? '' : (form?.dateError ?? ''));
|
const dateError = $derived(dateIso ? '' : (form?.dateError ?? ''));
|
||||||
|
// Only meaningful for RANGE; clears as soon as an end date is entered. The
|
||||||
|
// end-date field is hidden off-RANGE, so a stale value never renders.
|
||||||
|
const endDateError = $derived(endDateIso ? '' : (form?.endDateError ?? ''));
|
||||||
|
|
||||||
beforeNavigate(({ cancel }) => {
|
beforeNavigate(({ cancel }) => {
|
||||||
if (dirty && !submitting) {
|
if (dirty && !submitting) {
|
||||||
@@ -224,6 +228,7 @@ function markDirty() {
|
|||||||
precisionInputName="precision"
|
precisionInputName="precision"
|
||||||
dateLabel={m.form_label_date()}
|
dateLabel={m.form_label_date()}
|
||||||
dateError={dateError}
|
dateError={dateError}
|
||||||
|
endDateError={endDateError}
|
||||||
onchange={markDirty}
|
onchange={markDirty}
|
||||||
dateTestId="event-date"
|
dateTestId="event-date"
|
||||||
precisionTestId="event-precision"
|
precisionTestId="event-precision"
|
||||||
|
|||||||
@@ -148,6 +148,21 @@ describe('EventForm — seeds the When-section from the fail payload (review #2
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('EventForm — RANGE end-date required error wired per-field (review #3)', () => {
|
||||||
|
it('shows the end-date required message on the end-date field, marked invalid', async () => {
|
||||||
|
renderForm({
|
||||||
|
form: {
|
||||||
|
precision: 'RANGE',
|
||||||
|
eventDate: '1925-04-01',
|
||||||
|
endDateError: 'Bitte ein Enddatum eingeben.'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Bitte ein Enddatum eingeben.')).toBeInTheDocument();
|
||||||
|
const endInput = document.querySelector('#eventDateEnd') as HTMLInputElement;
|
||||||
|
expect(endInput.getAttribute('aria-invalid')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('EventForm — server date error wired per-field (REQ-011)', () => {
|
describe('EventForm — server date error wired per-field (REQ-011)', () => {
|
||||||
it('marks the date field aria-invalid and shows the message on a server date error', async () => {
|
it('marks the date field aria-invalid and shows the message on a server date error', async () => {
|
||||||
renderForm({ form: { dateError: 'Bitte ein Datum eingeben.' } });
|
renderForm({ form: { dateError: 'Bitte ein Datum eingeben.' } });
|
||||||
|
|||||||
@@ -71,17 +71,23 @@ export function parseEventForm(formData: FormData): ParsedEventForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns both failing required-field errors (title + date) simultaneously, or
|
* Returns the failing required-field errors (title + date + RANGE end-date)
|
||||||
* null when the form is valid. The route owns the `fail(400)` so it can enrich
|
* simultaneously, or null when the form is valid. The route owns the `fail(400)`
|
||||||
* the payload with the preserved field values and rehydrated picker selections.
|
* so it can enrich the payload with the preserved field values and rehydrated
|
||||||
|
* picker selections.
|
||||||
*/
|
*/
|
||||||
export function validateEventForm(
|
export function validateEventForm(
|
||||||
parsed: ParsedEventForm
|
parsed: ParsedEventForm
|
||||||
): { titleError: string; dateError: string } | null {
|
): { titleError: string; dateError: string; endDateError: string } | null {
|
||||||
const titleError = parsed.title.length === 0 ? m.event_editor_title_required() : '';
|
const titleError = parsed.title.length === 0 ? m.event_editor_title_required() : '';
|
||||||
const dateError = parsed.eventDate.length === 0 ? m.event_editor_date_required() : '';
|
const dateError = parsed.eventDate.length === 0 ? m.event_editor_date_required() : '';
|
||||||
if (!titleError && !dateError) return null;
|
// A RANGE event requires an end date. Catch it here so it never reaches the
|
||||||
return { titleError, dateError };
|
// backend, which rejects with a generic INVALID_DATE_RANGE mapped to the wrong
|
||||||
|
// "end before start" message and no field-level cue.
|
||||||
|
const endDateError =
|
||||||
|
parsed.precision === 'RANGE' && !parsed.eventDateEnd ? m.event_editor_end_date_required() : '';
|
||||||
|
if (!titleError && !dateError && !endDateError) return null;
|
||||||
|
return { titleError, dateError, endDateError };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The entered field values echoed back in every `fail(...)` so the form re-renders without loss. */
|
/** The entered field values echoed back in every `fail(...)` so the form re-renders without loss. */
|
||||||
|
|||||||
@@ -208,6 +208,27 @@ describe('zeitstrahl/events/new save action (REQ-004/009/010/015)', () => {
|
|||||||
expect(failData(result).eventDateEnd).toBe('1944-03-14');
|
expect(failData(result).eventDateEnd).toBe('1944-03-14');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects a RANGE event with no end date before calling the API (review #3)', async () => {
|
||||||
|
// Without this guard the body reaches the backend, which 400s with a generic
|
||||||
|
// INVALID_DATE_RANGE mapped to the wrong "end before start" message.
|
||||||
|
const post = vi.fn();
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ POST: post, GET: vi.fn() } as never);
|
||||||
|
|
||||||
|
const result = await actions.save(
|
||||||
|
saveEvent({
|
||||||
|
title: 'Umzug',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
eventDate: '1925-04-01',
|
||||||
|
precision: 'RANGE'
|
||||||
|
// eventDateEnd intentionally omitted
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(post).not.toHaveBeenCalled();
|
||||||
|
expect(result).toMatchObject({ status: 400 });
|
||||||
|
expect(failData(result).endDateError).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('surfaces both title and date errors when both blank (REQ-011)', async () => {
|
it('surfaces both title and date errors when both blank (REQ-011)', async () => {
|
||||||
vi.mocked(createApiClient).mockReturnValue({ POST: vi.fn() } as never);
|
vi.mocked(createApiClient).mockReturnValue({ POST: vi.fn() } as never);
|
||||||
const result = await actions.save(saveEvent({ title: '', type: 'PERSONAL', eventDate: '' }));
|
const result = await actions.save(saveEvent({ title: '', type: 'PERSONAL', eventDate: '' }));
|
||||||
|
|||||||
Reference in New Issue
Block a user