Timeline: curator event create/edit forms (#781) #832

Open
marcel wants to merge 29 commits from feat/issue-781-timeline-curator-forms into main
4 changed files with 51 additions and 6 deletions
Showing only changes of commit cd5649b96e - Show all commits

View File

@@ -16,7 +16,9 @@ import type { DatePrecision } from '$lib/shared/utils/documentDate';
* Exposed (shared contract — both WhoWhenSection and EventForm depend on it):
* - dateIso, precision, endDateIso — $bindable; the parent's binding IS the
* state (no redundant $state mirror).
* - dateInputName / endDateInputName — names of the hidden ISO inputs.
* - dateInputName / endDateInputName / precisionInputName — submitted field
* names; defaults match the document form (`metaDatePrecision`), the timeline
* form overrides precisionInputName to `precision`.
* - initialDateIso / suggestedDateIso — seeding inputs (see onMount + $effect).
* - dateTestId / precisionTestId / endDateInnerTestId — forwarded data-testid
* attributes so existing WhoWhenSection selectors survive the extraction.
@@ -28,6 +30,7 @@ let {
endDateIso = $bindable(''),
dateInputName = 'documentDate',
endDateInputName = 'metaDateEnd',
precisionInputName = 'metaDatePrecision',
initialDateIso = '',
suggestedDateIso = '',
dateLabel = m.form_label_date(),
@@ -41,6 +44,7 @@ let {
endDateIso?: string;
dateInputName?: string;
endDateInputName?: string;
precisionInputName?: string;
initialDateIso?: string;
suggestedDateIso?: string;
dateLabel?: string;
@@ -145,7 +149,7 @@ $effect(() => {
</label>
<select
id="{dateInputName}Precision"
name="metaDatePrecision"
name={precisionInputName}
bind:value={precision}
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>

View File

@@ -62,7 +62,7 @@ let selectedDocuments = $state<DocumentOption[]>(
event?.documents
? event.documents.map((d) => ({
// Graceful degradation: DocumentRef has no precision fields. formatDocumentOption
// falls back to the bare title when documentDate is the only date info present.
// defaults a missing precision to DAY, so the chip shows the full documentDate.
id: d.id,
title: d.title,
documentDate: d.documentDate
@@ -196,7 +196,10 @@ async function confirmDelete(e: SubmitEvent) {
<span class="mb-1 block text-sm font-medium text-ink-2"
>{m.event_editor_type_label()}</span
>
<EventTypeSelect value={type} name="type" onchange={() => markDirty()} />
<EventTypeSelect value={type} name="type" onchange={(t) => {
type = t;
markDirty();
}} />
</div>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
@@ -206,6 +209,7 @@ async function confirmDelete(e: SubmitEvent) {
bind:endDateIso={endDateIso}
dateInputName="eventDate"
endDateInputName="eventDateEnd"
precisionInputName="precision"
dateLabel={m.form_label_date()}
dateTestId="event-date"
precisionTestId="event-precision"

View File

@@ -8,6 +8,20 @@ type TimelineEventRequest = components['schemas']['TimelineEventRequest'];
// 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;
@@ -33,7 +47,9 @@ export interface ParsedEventForm {
export function parseEventForm(formData: FormData): ParsedEventForm {
const rawType = formData.get('type')?.toString();
const type = rawType === 'HISTORICAL' ? 'HISTORICAL' : 'PERSONAL';
const precision = (formData.get('metaDatePrecision')?.toString() as DatePrecision) || 'DAY';
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;

View File

@@ -119,7 +119,7 @@ describe('zeitstrahl/events/new save action (REQ-004/009/010/015)', () => {
title: 'Umzug',
type: 'PERSONAL',
eventDate: '1925-04-01',
metaDatePrecision: 'YEAR',
precision: 'YEAR',
eventDateEnd: '1925-05-01'
})
);
@@ -129,6 +129,27 @@ describe('zeitstrahl/events/new save action (REQ-004/009/010/015)', () => {
expect(post.mock.calls[0][1].body.eventDateEnd).toBeNull();
});
it('falls back to DAY precision when an unknown precision token is submitted (REQ-009 hardening)', async () => {
const post = vi
.fn()
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
try {
await actions.save(
saveEvent({
title: 'Umzug',
type: 'PERSONAL',
eventDate: '1925-04-01',
precision: 'NOT_A_REAL_PRECISION'
})
);
} catch {
// redirect throws on success
}
expect(post.mock.calls[0][1].body.precision).toBe('DAY');
});
it('returns fail(400) with preserved picker arrays on blank title', async () => {
const post = vi.fn();
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);