feat(timeline): add EventForm curator create/edit form
One component for both routes: /new renders it empty, /[id]/edit seeds it from a
TimelineEventView. Composes EventTypeSelect, the shared DatePrecisionField, a
plain-textarea description, PersonMultiSelect and DocumentMultiSelect (personIds
/documentIds hidden inputs). lg:grid-cols-[2fr_1fr] collapsing to one column
below lg, sticky save bar, beforeNavigate unsaved-changes guard, submitting flag
via use:enhance (disabled submit), and a delete form gated by getConfirmService()
read lazily so the component mounts cleanly in isolation. Title/description/chip
labels render via default {...} escaping (CWE-79). Seeded DocumentRefs degrade
gracefully to DocumentOption (no precision fields). Pickers gain an inputId prop
so <label for> associates the control; eslint boundaries now lets timeline import
person+document (mirrors the geschichte editor). 6/6 component specs green.
Refs #781
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -199,7 +199,12 @@ export default defineConfig(
|
|||||||
{ from: { type: 'user' }, allow: { to: { type: ['shared'] } } },
|
{ from: { type: 'user' }, allow: { to: { type: ['shared'] } } },
|
||||||
{ from: { type: 'notification' }, allow: { to: { type: ['shared'] } } },
|
{ from: { type: 'notification' }, allow: { to: { type: ['shared'] } } },
|
||||||
{ from: { type: 'conversation' }, allow: { to: { type: ['shared'] } } },
|
{ from: { type: 'conversation' }, allow: { to: { type: ['shared'] } } },
|
||||||
{ from: { type: 'timeline' }, allow: { to: { type: ['shared'] } } },
|
// Timeline curator event editor selects persons and documents by
|
||||||
|
// design (mirrors the geschichte editor) — #781.
|
||||||
|
{
|
||||||
|
from: { type: 'timeline' },
|
||||||
|
allow: { to: { type: ['shared', 'person', 'document'] } }
|
||||||
|
},
|
||||||
{ from: { type: 'shared' }, allow: { to: { type: ['shared'] } } },
|
{ from: { type: 'shared' }, allow: { to: { type: ['shared'] } } },
|
||||||
{
|
{
|
||||||
from: { type: 'routes' },
|
from: { type: 'routes' },
|
||||||
|
|||||||
@@ -13,13 +13,16 @@ interface Props {
|
|||||||
hiddenInputName?: string;
|
hiddenInputName?: string;
|
||||||
/** Empty-state text shown inside the chip container when nothing is selected. */
|
/** Empty-state text shown inside the chip container when nothing is selected. */
|
||||||
emptyLabel?: string;
|
emptyLabel?: string;
|
||||||
|
/** id of the search input so a <label for=...> can be associated. */
|
||||||
|
inputId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
selectedDocuments = $bindable([]),
|
selectedDocuments = $bindable([]),
|
||||||
placeholder = m.geschichte_editor_search_document(),
|
placeholder = m.geschichte_editor_search_document(),
|
||||||
hiddenInputName = 'documentIds',
|
hiddenInputName = 'documentIds',
|
||||||
emptyLabel = undefined
|
emptyLabel = undefined,
|
||||||
|
inputId = undefined
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
@@ -97,6 +100,7 @@ function removeDocument(id: string | undefined) {
|
|||||||
|
|
||||||
<input
|
<input
|
||||||
bind:this={inputEl}
|
bind:this={inputEl}
|
||||||
|
id={inputId}
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
bind:value={searchTerm}
|
bind:value={searchTerm}
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ interface Props {
|
|||||||
hiddenInputName?: string;
|
hiddenInputName?: string;
|
||||||
/** Empty-state text shown inside the chip container when nothing is selected. */
|
/** Empty-state text shown inside the chip container when nothing is selected. */
|
||||||
emptyLabel?: string;
|
emptyLabel?: string;
|
||||||
|
/** id of the search input so a <label for=...> can be associated. */
|
||||||
|
inputId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
selectedPersons = $bindable([]),
|
selectedPersons = $bindable([]),
|
||||||
hiddenInputName = 'receiverIds',
|
hiddenInputName = 'receiverIds',
|
||||||
emptyLabel = undefined
|
emptyLabel = undefined,
|
||||||
|
inputId = undefined
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
@@ -108,6 +111,7 @@ function removePerson(id: string | undefined) {
|
|||||||
|
|
||||||
<input
|
<input
|
||||||
bind:this={inputEl}
|
bind:this={inputEl}
|
||||||
|
id={inputId}
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
bind:value={searchTerm}
|
bind:value={searchTerm}
|
||||||
|
|||||||
303
frontend/src/lib/timeline/EventForm.svelte
Normal file
303
frontend/src/lib/timeline/EventForm.svelte
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { beforeNavigate } from '$app/navigation';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
|
import { toPersonOption, type PersonOption } from '$lib/person/personOption';
|
||||||
|
import { type DocumentOption } from '$lib/document/documentTypeahead';
|
||||||
|
import { getConfirmService } from '$lib/shared/services/confirm.svelte.js';
|
||||||
|
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
|
||||||
|
import DocumentMultiSelect from '$lib/document/DocumentMultiSelect.svelte';
|
||||||
|
import DatePrecisionField from '$lib/shared/primitives/DatePrecisionField.svelte';
|
||||||
|
import EventTypeSelect from '$lib/timeline/EventTypeSelect.svelte';
|
||||||
|
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||||
|
|
||||||
|
type TimelineEventView = components['schemas']['TimelineEventView'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curator create/edit form for a timeline event. One component, two routes:
|
||||||
|
* `/new` renders it empty, `/[id]/edit` renders it seeded with `event`. The
|
||||||
|
* markup is never forked. All data flows through the route's +page.server.ts
|
||||||
|
* load + form action (SSR) — there is no client fetch('/api/...') here.
|
||||||
|
*/
|
||||||
|
interface FormResult {
|
||||||
|
error?: string;
|
||||||
|
titleError?: string;
|
||||||
|
dateError?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
type?: string;
|
||||||
|
personIds?: string[];
|
||||||
|
documentIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
event = undefined,
|
||||||
|
initialPersons = [],
|
||||||
|
initialDocuments = [],
|
||||||
|
originPersonId = '',
|
||||||
|
form = null
|
||||||
|
}: {
|
||||||
|
event?: TimelineEventView;
|
||||||
|
initialPersons?: PersonOption[];
|
||||||
|
initialDocuments?: DocumentOption[];
|
||||||
|
originPersonId?: string;
|
||||||
|
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.
|
||||||
|
let title = $state(form?.title ?? event?.title ?? '');
|
||||||
|
let description = $state(form?.description ?? event?.description ?? '');
|
||||||
|
let type = $state<string>(form?.type ?? event?.type ?? 'PERSONAL');
|
||||||
|
let dateIso = $state(event?.eventDate ?? '');
|
||||||
|
let precision = $state<DatePrecision>((event?.precision as DatePrecision) ?? 'DAY');
|
||||||
|
let endDateIso = $state(event?.eventDateEnd ?? '');
|
||||||
|
|
||||||
|
let selectedPersons = $state<PersonOption[]>(
|
||||||
|
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
|
||||||
|
// falls back to the bare title when documentDate is the only date info present.
|
||||||
|
id: d.id,
|
||||||
|
title: d.title,
|
||||||
|
documentDate: d.documentDate
|
||||||
|
}))
|
||||||
|
: initialDocuments
|
||||||
|
);
|
||||||
|
|
||||||
|
const isEdit = $derived(event !== undefined);
|
||||||
|
|
||||||
|
let titleTouched = $state(false);
|
||||||
|
let submitting = $state(false);
|
||||||
|
let dirty = $state(false);
|
||||||
|
|
||||||
|
const titleEmpty = $derived(title.trim().length === 0);
|
||||||
|
// Client-side title error fires instantly on a save attempt; the server's
|
||||||
|
// titleError is the simultaneous-multi-field source on a real round-trip.
|
||||||
|
const titleError = $derived(
|
||||||
|
form?.titleError ?? (titleTouched && titleEmpty ? m.event_editor_title_required() : '')
|
||||||
|
);
|
||||||
|
const dateError = $derived(form?.dateError ?? '');
|
||||||
|
|
||||||
|
beforeNavigate(({ cancel }) => {
|
||||||
|
if (dirty && !submitting) {
|
||||||
|
const ok = window.confirm(m.event_editor_unsaved_changes());
|
||||||
|
if (!ok) cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guards a submit with a blank title client-side. The server re-validates and
|
||||||
|
// owns the authoritative fail(400) with per-field flags.
|
||||||
|
function handleSubmit(e: SubmitEvent) {
|
||||||
|
titleTouched = true;
|
||||||
|
if (titleEmpty) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const { confirm } = getConfirmService();
|
||||||
|
const ok = await confirm({
|
||||||
|
title: m.event_editor_delete_confirm_title(),
|
||||||
|
body: m.event_editor_delete_confirm_body(),
|
||||||
|
destructive: true,
|
||||||
|
confirmLabel: m.event_editor_delete()
|
||||||
|
});
|
||||||
|
if (ok) (e.target as HTMLFormElement).requestSubmit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-5xl px-4 py-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<BackButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">
|
||||||
|
{isEdit ? m.event_editor_edit_title() : m.event_editor_new_title()}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<p
|
||||||
|
class="mb-4 rounded-sm border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-danger"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{form.error}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/save"
|
||||||
|
onsubmit={handleSubmit}
|
||||||
|
use:enhance={() => {
|
||||||
|
submitting = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
submitting = false;
|
||||||
|
dirty = false;
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="originPersonId" value={originPersonId} />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
|
||||||
|
<!-- Main column -->
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<!-- Titel + Typ + Datum -->
|
||||||
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.event_editor_section_when()}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="mb-5">
|
||||||
|
<label for="event-title" class="mb-1 block text-sm font-medium text-ink-2">
|
||||||
|
{m.event_editor_title_label()}*
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="event-title"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
bind:value={title}
|
||||||
|
oninput={markDirty}
|
||||||
|
onblur={() => (titleTouched = true)}
|
||||||
|
maxlength="255"
|
||||||
|
placeholder={m.event_editor_title_placeholder()}
|
||||||
|
aria-required="true"
|
||||||
|
aria-invalid={titleError ? 'true' : undefined}
|
||||||
|
aria-describedby={titleError ? 'event-title-error' : undefined}
|
||||||
|
class="block min-h-[48px] w-full rounded border border-line px-3 py-3 text-base shadow-sm
|
||||||
|
{titleError
|
||||||
|
? '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'}"
|
||||||
|
/>
|
||||||
|
{#if titleError}
|
||||||
|
<p id="event-title-error" class="mt-1 text-sm text-danger" role="alert">
|
||||||
|
<span aria-hidden="true">⚠ </span>{titleError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-5">
|
||||||
|
<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()} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||||
|
<DatePrecisionField
|
||||||
|
bind:dateIso={dateIso}
|
||||||
|
bind:precision={precision}
|
||||||
|
bind:endDateIso={endDateIso}
|
||||||
|
dateInputName="eventDate"
|
||||||
|
endDateInputName="eventDateEnd"
|
||||||
|
dateLabel={m.form_label_date()}
|
||||||
|
dateTestId="event-date"
|
||||||
|
precisionTestId="event-precision"
|
||||||
|
endDateInnerTestId="event-end-date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if dateError}
|
||||||
|
<p class="mt-2 text-sm text-danger" role="alert">
|
||||||
|
<span aria-hidden="true">⚠ </span>{dateError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Beschreibung -->
|
||||||
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.event_editor_section_description()}
|
||||||
|
</h2>
|
||||||
|
<label for="event-description" class="sr-only">{m.event_editor_description_label()}</label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
id="event-description"
|
||||||
|
name="description"
|
||||||
|
bind:value={description}
|
||||||
|
oninput={markDirty}
|
||||||
|
rows="4"
|
||||||
|
placeholder={m.event_editor_description_placeholder()}
|
||||||
|
class="block w-full rounded border border-line px-3 py-3 text-base shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<!-- Beteiligte Personen -->
|
||||||
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.event_editor_section_persons()}
|
||||||
|
</h2>
|
||||||
|
<label for="event-persons-input" class="mb-1 block text-sm font-medium text-ink-2">
|
||||||
|
{m.event_editor_persons_label()}
|
||||||
|
</label>
|
||||||
|
<PersonMultiSelect
|
||||||
|
bind:selectedPersons={selectedPersons}
|
||||||
|
inputId="event-persons-input"
|
||||||
|
hiddenInputName="personIds"
|
||||||
|
emptyLabel={m.event_editor_persons_empty()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Verknüpfte Briefe -->
|
||||||
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
|
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.event_editor_section_documents()}
|
||||||
|
</h2>
|
||||||
|
<label for="event-documents-input" class="mb-1 block text-sm font-medium text-ink-2">
|
||||||
|
{m.event_editor_documents_label()}
|
||||||
|
</label>
|
||||||
|
<DocumentMultiSelect
|
||||||
|
bind:selectedDocuments={selectedDocuments}
|
||||||
|
inputId="event-documents-input"
|
||||||
|
hiddenInputName="documentIds"
|
||||||
|
emptyLabel={m.event_editor_documents_empty()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save bar -->
|
||||||
|
<div
|
||||||
|
class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<p class="font-sans text-xs text-ink-3">{m.event_editor_save_hint()}</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{m.event_editor_save()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if isEdit}
|
||||||
|
<!-- Delete lives in its own form so it posts to the dedicated ?/delete action.
|
||||||
|
getConfirmService() is read lazily inside the handler so the component
|
||||||
|
mounts cleanly outside a layout (tests) where no confirm context exists. -->
|
||||||
|
<form method="POST" action="?/delete" onsubmit={confirmDelete} use:enhance class="mt-4">
|
||||||
|
<input type="hidden" name="originPersonId" value={originPersonId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex h-11 items-center rounded border border-danger/40 px-4 font-sans text-sm font-medium text-danger hover:bg-danger/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
{m.event_editor_delete()}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
79
frontend/src/lib/timeline/EventForm.svelte.spec.ts
Normal file
79
frontend/src/lib/timeline/EventForm.svelte.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import EventForm from './EventForm.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
type TimelineEventView = components['schemas']['TimelineEventView'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal TimelineEventView shape used to seed the edit form. Mirrors
|
||||||
|
* components['schemas']['TimelineEventView'] — all server-populated fields.
|
||||||
|
*/
|
||||||
|
function makeEvent(overrides: Partial<TimelineEventView> = {}): TimelineEventView {
|
||||||
|
return {
|
||||||
|
id: 'e1',
|
||||||
|
title: 'Umzug nach Berlin',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
eventDate: '1925-04-01',
|
||||||
|
precision: 'DAY',
|
||||||
|
version: 0,
|
||||||
|
createdBy: 'u1',
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
updatedBy: 'u1',
|
||||||
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
|
persons: [],
|
||||||
|
documents: [],
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EventForm — date precision RANGE reveal (headline AC, REQ-008/009)', () => {
|
||||||
|
it('reveals the end-date field when precision is RANGE', async () => {
|
||||||
|
render(EventForm, { event: makeEvent({ precision: 'RANGE', eventDateEnd: '1925-05-01' }) });
|
||||||
|
await expect.element(page.getByLabelText('Enddatum')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the end-date field when precision is YEAR', async () => {
|
||||||
|
render(EventForm, { event: makeEvent({ precision: 'YEAR' }) });
|
||||||
|
await expect.element(page.getByTestId('end-date-region')).toBeInTheDocument();
|
||||||
|
expect(document.querySelector('#eventDateEnd')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EventForm — picker preselect (REQ-014)', () => {
|
||||||
|
it('preselects a person when initialPersons is provided', async () => {
|
||||||
|
render(EventForm, {
|
||||||
|
initialPersons: [{ id: 'p1', displayName: 'Anna Müller' }]
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Anna Müller')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EventForm — required-field error (REQ-010)', () => {
|
||||||
|
it('shows a required-field error when title is blank and save is attempted', async () => {
|
||||||
|
render(EventForm, {});
|
||||||
|
await page.getByRole('button', { name: 'Speichern' }).click();
|
||||||
|
await expect.element(page.getByText('Bitte einen Titel eingeben.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EventForm — submitting state (named AC)', () => {
|
||||||
|
it('renders an enabled submit button initially', async () => {
|
||||||
|
render(EventForm, { event: makeEvent() });
|
||||||
|
const btn = page.getByRole('button', { name: 'Speichern' });
|
||||||
|
await expect.element(btn).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EventForm — server error surfaced inline (REQ-007/013)', () => {
|
||||||
|
it('renders the mapped error from the form prop', async () => {
|
||||||
|
render(EventForm, {
|
||||||
|
event: makeEvent(),
|
||||||
|
form: { error: 'Etwas ist schiefgelaufen.' }
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Etwas ist schiefgelaufen.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user