Compare commits
23 Commits
main
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6150fc7be5 | ||
|
|
0862d43ba3 | ||
|
|
9cb856b376 | ||
|
|
d330510777 | ||
|
|
719274ef88 | ||
|
|
d48a89ba5c | ||
|
|
4dc5e3278f | ||
|
|
c13baa4785 | ||
|
|
cd5649b96e | ||
|
|
9f17c4538f | ||
|
|
068c2ef256 | ||
|
|
94d7d8099f | ||
|
|
a50bdfa7f4 | ||
|
|
be26a2e1b3 | ||
|
|
5cfb4608f6 | ||
|
|
59d78150b3 | ||
|
|
15ff6db1d3 | ||
|
|
54f9d8fdd5 | ||
|
|
423aedcd87 | ||
|
|
0ed7fb4c0e | ||
|
|
62fcc53f5c | ||
|
|
36f7bdad45 | ||
|
|
696a86799d |
@@ -106,3 +106,20 @@
|
||||
| REQ-025 | personId prop declared but undefined in global view; not passed to leaf cards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders all years and undated entries with personId undefined` | Done |
|
||||
| REQ-026 | month-bucket helpers in $lib/shared/utils/monthBuckets.ts; no lib/timeline → lib/document import | #779 | zeitstrahl-global-view | `frontend/src/lib/shared/utils/monthBuckets.ts` | `monthBuckets.spec.ts` (relocated) + eslint boundary + `grep lib/document` → zero | Done |
|
||||
| REQ-027 | monthHistogram returns 12 MonthBuckets for the band year via shared fillDensityGaps | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/timelineDensity.ts` | `timelineDensity.spec.ts#monthHistogram` | Done |
|
||||
| REQ-001 | curator with WRITE_ALL granted access to /zeitstrahl/events/new + /[id]/edit | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `[id]/edit/+page.server.ts` | `new/page.server.spec.ts#allows a curator with WRITE_ALL`, `[id]/edit/page.server.spec.ts#seeds the form with the event on an ok GET` | Done |
|
||||
| REQ-002 | unauthenticated (null user) → 403 (null-user guard before groups deref) | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts`, `[id]/edit/+page.server.ts` | `new/page.server.spec.ts#throws 403 for an unauthenticated (null) user`, `[id]/edit/page.server.spec.ts#throws 403 for an unauthenticated (null) user` | Done |
|
||||
| REQ-003 | authenticated without WRITE_ALL → 403 | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (hasWriteAll) | `new/page.server.spec.ts#throws 403 for an authenticated user without WRITE_ALL`, `[id]/edit/page.server.spec.ts#throws 403 for a user without WRITE_ALL` | Done |
|
||||
| REQ-004 | valid create → POST + redirect to resolved target | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (save), `lib/timeline/eventFormServer.ts#toEventRequest` | `new/page.server.spec.ts#posts a TimelineEventRequest and redirects on success` | Done |
|
||||
| REQ-005 | valid edit → PUT + redirect to resolved target | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (save) | `[id]/edit/page.server.spec.ts#updates via PUT (with version) and redirects on success` | Done |
|
||||
| REQ-006 | confirmed delete → DELETE + redirect | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (delete), `lib/timeline/EventForm.svelte` (getConfirmService) | `[id]/edit/page.server.spec.ts#deletes via DELETE and redirects to the resolved target on success` | Done |
|
||||
| REQ-007 | non-ok DELETE → surface mapped error, no redirect | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (delete) | `[id]/edit/page.server.spec.ts#returns fail(status) and does not redirect when DELETE is not ok` | Done |
|
||||
| REQ-008 | precision = RANGE → end-date field visible | #781 | timeline-curator-forms | `frontend/src/lib/shared/primitives/DatePrecisionField.svelte`, `lib/timeline/EventForm.svelte` | `EventForm.svelte.spec.ts#reveals the end-date field when precision is RANGE`, `WhoWhenSection.svelte.spec.ts#reveals the end-date field when precision is RANGE` | Done |
|
||||
| REQ-009 | precision ≠ RANGE → end-date hidden, eventDateEnd submitted null | #781 | timeline-curator-forms | `frontend/src/lib/shared/primitives/DatePrecisionField.svelte`, `lib/timeline/eventFormServer.ts#parseEventForm` | `EventForm.svelte.spec.ts#hides the end-date field when precision is YEAR`, `new/page.server.spec.ts#sends eventDateEnd: null when precision is not RANGE` | Done |
|
||||
| REQ-010 | blank title → localized required error, no nav, picker values preserved | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#validateEventForm`, `EventForm.svelte` | `EventForm.svelte.spec.ts#shows a required-field error when title is blank`, `new/page.server.spec.ts#returns fail(400) with preserved picker arrays on blank title` | Done |
|
||||
| REQ-011 | blank title + date → both errors via per-field aria-invalid | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#validateEventForm` | `new/page.server.spec.ts#surfaces both title and date errors when both blank` | Done |
|
||||
| REQ-012 | unknown/derived event id (non-ok GET) → 404, never blank create form | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (load) | `[id]/edit/page.server.spec.ts#throws 404 when the GET is not ok (unknown or derived id)` | Done |
|
||||
| REQ-013 | 409 Conflict → generic conflict message, no redirect (no merge UI) | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts` (save) | `[id]/edit/page.server.spec.ts#maps a 409 conflict and does not redirect`, `new/page.server.spec.ts#maps the API error and does not redirect on a non-ok save (incl. 409)` | Done |
|
||||
| REQ-014 | valid ?personId/?documentId prefill pre-selected; unknown id silently ignored | #781 | timeline-curator-forms | `frontend/src/routes/zeitstrahl/events/new/+page.server.ts` (load Promise.all), `EventForm.svelte` | `new/page.server.spec.ts#preselects a valid person and ignores an unknown document`, `EventForm.svelte.spec.ts#preselects a person when initialPersons is provided` | Done |
|
||||
| REQ-015 | absent/empty/non-UUID originPersonId → redirect /zeitstrahl (CWE-601) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#resolveNavTarget` | `new/page.server.spec.ts#defaults to /zeitstrahl when originPersonId is not a valid UUID`, `#redirects to /persons/{id} when originPersonId is a valid UUID` | Done |
|
||||
| REQ-016 | title/description/chip labels via default `{...}` escaping, never `{@html}` (CWE-79) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/EventForm.svelte` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero | Done |
|
||||
| REQ-017 | labelled pickers, visible empty states, ≥44px chip remove targets | #781 | timeline-curator-forms | `frontend/src/lib/person/PersonMultiSelect.svelte`, `document/DocumentMultiSelect.svelte`, `EventForm.svelte` | `PersonMultiSelect.svelte.spec.ts`, `DocumentMultiSelect.svelte.spec.ts` (green post-44px fix), `EventForm.svelte.spec.ts#preselects a person when initialPersons is provided` | Done |
|
||||
|
||||
@@ -208,6 +208,7 @@ frontend/src/routes/
|
||||
├── geschichten/ Stories — list, [id], [id]/edit, new
|
||||
├── stammbaum/ Family tree (Stammbaum)
|
||||
├── zeitstrahl/ Global timeline (Zeitstrahl) — life-events + events + letters woven in time; SSR-loads GET /api/timeline, renders lib/timeline/TimelineView (Datum mode)
|
||||
│ └── events/ Curator event editor (WRITE_ALL-gated) — new (create) + [id]/edit (edit + delete); reuses lib/timeline/EventForm
|
||||
├── themen/ Topics directory — browsable tag index
|
||||
├── enrich/ Enrichment workflow — [id], done
|
||||
├── admin/ User, group, tag, OCR, system management
|
||||
|
||||
@@ -15,6 +15,7 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar incl. StoryDocumentPanel: document linking via POST/DELETE /items); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.")
|
||||
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
||||
Component(zeitstrahl, "/zeitstrahl", "SvelteKit Route", "Global timeline (Zeitstrahl). SSR loader: GET /api/timeline -> TimelineDTO. Renders lib/timeline/TimelineView (Datum mode): year bands (YearBand) with EventPill / WorldBand / LetterCard, dense-year YearLetterStrip (shared Sparkline + monthHistogram), folded GapSpan for empty-year runs, and an undated bucket. personId prop is the per-person Lebensweg seam (issue #10), undefined here.")
|
||||
Component(zeitstrahlEvents, "/zeitstrahl/events/new and /zeitstrahl/events/[id]/edit", "SvelteKit Routes", "Curator event editor (WRITE_ALL-gated via server load, 403 error page). One lib/timeline/EventForm for both routes: title, EventTypeSelect (PERSONAL/HISTORICAL segmented radio), shared DatePrecisionField (RANGE reveals end date), plain-text description, PersonMultiSelect + DocumentMultiSelect. New: ?personId/?documentId prefill via Promise.all (404/403 swallowed), POST /api/timeline/events. Edit: load seeds from GET /api/timeline/events/{id} (404 on any non-ok — fails closed against derived events), PUT (optimistic-lock version) + DELETE behind ConfirmDialog. Context-aware redirect via UUID-validated originPersonId.")
|
||||
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
|
||||
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
||||
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
|
||||
@@ -30,6 +31,7 @@ Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/g
|
||||
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
||||
Rel(user, zeitstrahl, "Reads the family timeline", "HTTPS / Browser")
|
||||
Rel(zeitstrahl, backend, "GET /api/timeline -> TimelineDTO", "HTTP / JSON")
|
||||
Rel(zeitstrahlEvents, backend, "GET /api/timeline/events/{id}, POST /api/timeline/events, PUT/DELETE /api/timeline/events/{id}, GET /api/persons/{id} + /api/documents/{id} (prefill)", "HTTP / JSON")
|
||||
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
|
||||
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
||||
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")
|
||||
|
||||
@@ -34,7 +34,7 @@ src/
|
||||
│ ├── api/ # Internal API proxies (server-side only)
|
||||
│ ├── geschichten/ # Stories (list, [id], [id]/edit, new)
|
||||
│ ├── stammbaum/ # Family tree
|
||||
│ ├── zeitstrahl/ # Global timeline (Zeitstrahl) — SSR loads /api/timeline, renders lib/timeline
|
||||
│ ├── zeitstrahl/ # Global timeline (Zeitstrahl) — SSR loads /api/timeline, renders lib/timeline; events/new + events/[id]/edit curator editor (WRITE_ALL-gated)
|
||||
│ ├── enrich/ # Enrichment workflow ([id], done)
|
||||
│ ├── hilfe/transkription/ # Transcription help page
|
||||
│ ├── profile/ # User profile settings
|
||||
|
||||
@@ -26,7 +26,7 @@ test.describe('Document auto-title sync (#726)', () => {
|
||||
|
||||
// 3. Add a YEAR-precision date WITHOUT touching the title, then save.
|
||||
await page.locator('#documentDate').fill('15.01.1928');
|
||||
await page.locator('#metaDatePrecision').selectOption('YEAR');
|
||||
await page.locator('#documentDatePrecision').selectOption('YEAR');
|
||||
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||
|
||||
// 4. The detail page shows the regenerated title carrying the new year.
|
||||
|
||||
65
frontend/e2e/zeitstrahl-event-editor.spec.ts
Normal file
65
frontend/e2e/zeitstrahl-event-editor.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Curator timeline event editor (#781) — intentionally thin. The component +
|
||||
* server specs carry the real regression coverage (they run in CI's "Unit &
|
||||
* Component Tests" job); ci.yml does NOT invoke test:e2e today, so this file
|
||||
* runs only locally/manually against the full Docker Compose stack.
|
||||
*
|
||||
* Three checks: one critical create journey (→ HTTP 200 on /zeitstrahl; the full
|
||||
* "sees the event card" assertion depends on #7), one security counterpart
|
||||
* (logged-out → 403), and one 320px no-overflow guarantee for the 60+ author
|
||||
* audience.
|
||||
*/
|
||||
|
||||
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
|
||||
|
||||
test.describe('Curator creates a timeline event', () => {
|
||||
test('fills the create form with precision RANGE and lands on /zeitstrahl (HTTP 200)', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/zeitstrahl/events/new');
|
||||
|
||||
await page.getByLabel(/Titel/i).fill(`E2E Ereignis ${stamp()}`);
|
||||
await page.getByRole('radio', { name: /Historisch/i }).click();
|
||||
|
||||
// Date + RANGE end date via the shared German dd.mm.yyyy inputs.
|
||||
await page.locator('#eventDate').fill('01.04.1925');
|
||||
await page.locator('#eventDatePrecision').selectOption('RANGE');
|
||||
await expect(page.getByLabel('Enddatum')).toBeVisible();
|
||||
await page.locator('#eventDateEnd').fill('01.05.1925');
|
||||
|
||||
// Submitting redirects to the resolved nav target (/zeitstrahl) — assert the
|
||||
// route responds 200, not a DOM card (card rendering is #7's concern).
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/zeitstrahl$/),
|
||||
page.getByRole('button', { name: 'Speichern' }).click()
|
||||
]);
|
||||
const response = await page.goto('/zeitstrahl');
|
||||
expect(response?.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Logged-out user is blocked from the curator route', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('navigating to /zeitstrahl/events/new is blocked with 403', async ({ page }) => {
|
||||
await page.goto('/zeitstrahl/events/new');
|
||||
// The load guard throws 403 before any form renders.
|
||||
await expect(page.getByLabel(/Titel/i)).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText(/403|Zugriff verweigert|Forbidden/i)).toBeVisible({
|
||||
timeout: 5000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Responsive — 60+ author audience', () => {
|
||||
test('no horizontal overflow on the create form at 320px', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 900 });
|
||||
await page.goto('/zeitstrahl/events/new');
|
||||
await expect(page.getByLabel(/Titel/i)).toBeVisible();
|
||||
|
||||
const scrollWidth = await page.evaluate(() => document.body.scrollWidth);
|
||||
expect(scrollWidth).toBe(320);
|
||||
});
|
||||
});
|
||||
@@ -199,7 +199,12 @@ export default defineConfig(
|
||||
{ from: { type: 'user' }, allow: { to: { type: ['shared'] } } },
|
||||
{ from: { type: 'notification' }, 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: 'routes' },
|
||||
|
||||
@@ -1046,6 +1046,31 @@
|
||||
"timeline_derived_birth": "Geburt",
|
||||
"timeline_derived_death": "Tod",
|
||||
"timeline_derived_marriage": "Heirat",
|
||||
"event_editor_new_title": "Neues Ereignis",
|
||||
"event_editor_edit_title": "Ereignis bearbeiten",
|
||||
"event_editor_section_when": "Wann",
|
||||
"event_editor_section_persons": "Beteiligte Personen",
|
||||
"event_editor_section_documents": "Verknüpfte Briefe",
|
||||
"event_editor_section_description": "Beschreibung",
|
||||
"event_editor_title_label": "Titel",
|
||||
"event_editor_title_placeholder": "Titel des Ereignisses",
|
||||
"event_editor_title_required": "Bitte einen Titel eingeben.",
|
||||
"event_editor_date_required": "Bitte ein Datum eingeben.",
|
||||
"event_editor_type_label": "Typ",
|
||||
"event_editor_persons_label": "Personen",
|
||||
"event_editor_documents_label": "Briefe",
|
||||
"event_editor_description_label": "Beschreibung",
|
||||
"event_editor_description_placeholder": "Optionale Beschreibung",
|
||||
"event_editor_persons_empty": "Noch keine Person verknüpft",
|
||||
"event_editor_documents_empty": "Noch kein Dokument verknüpft",
|
||||
"event_type_PERSONAL": "Persönlich",
|
||||
"event_type_HISTORICAL": "Historisch",
|
||||
"event_editor_save": "Speichern",
|
||||
"event_editor_save_hint": "Ereignisse erscheinen im Zeitstrahl.",
|
||||
"event_editor_delete": "Löschen",
|
||||
"event_editor_delete_confirm_title": "Ereignis löschen?",
|
||||
"event_editor_delete_confirm_body": "Dieses Ereignis wird dauerhaft entfernt.",
|
||||
"event_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?",
|
||||
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
|
||||
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
|
||||
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert – bitte laden Sie die Seite neu.",
|
||||
|
||||
@@ -1046,6 +1046,31 @@
|
||||
"timeline_derived_birth": "Birth",
|
||||
"timeline_derived_death": "Death",
|
||||
"timeline_derived_marriage": "Marriage",
|
||||
"event_editor_new_title": "New event",
|
||||
"event_editor_edit_title": "Edit event",
|
||||
"event_editor_section_when": "When",
|
||||
"event_editor_section_persons": "People involved",
|
||||
"event_editor_section_documents": "Linked letters",
|
||||
"event_editor_section_description": "Description",
|
||||
"event_editor_title_label": "Title",
|
||||
"event_editor_title_placeholder": "Event title",
|
||||
"event_editor_title_required": "Please enter a title.",
|
||||
"event_editor_date_required": "Please enter a date.",
|
||||
"event_editor_type_label": "Type",
|
||||
"event_editor_persons_label": "People",
|
||||
"event_editor_documents_label": "Letters",
|
||||
"event_editor_description_label": "Description",
|
||||
"event_editor_description_placeholder": "Optional description",
|
||||
"event_editor_persons_empty": "No person linked yet",
|
||||
"event_editor_documents_empty": "No document linked yet",
|
||||
"event_type_PERSONAL": "Personal",
|
||||
"event_type_HISTORICAL": "Historical",
|
||||
"event_editor_save": "Save",
|
||||
"event_editor_save_hint": "Events appear on the timeline.",
|
||||
"event_editor_delete": "Delete",
|
||||
"event_editor_delete_confirm_title": "Delete event?",
|
||||
"event_editor_delete_confirm_body": "This event will be permanently removed.",
|
||||
"event_editor_unsaved_changes": "You have unsaved changes — really leave?",
|
||||
"error_geschichte_not_found": "The story was not found.",
|
||||
"error_journey_item_not_found": "The journey item was not found.",
|
||||
"error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.",
|
||||
|
||||
@@ -1046,6 +1046,31 @@
|
||||
"timeline_derived_birth": "Nacimiento",
|
||||
"timeline_derived_death": "Fallecimiento",
|
||||
"timeline_derived_marriage": "Matrimonio",
|
||||
"event_editor_new_title": "Nuevo evento",
|
||||
"event_editor_edit_title": "Editar evento",
|
||||
"event_editor_section_when": "Cuándo",
|
||||
"event_editor_section_persons": "Personas involucradas",
|
||||
"event_editor_section_documents": "Cartas vinculadas",
|
||||
"event_editor_section_description": "Descripción",
|
||||
"event_editor_title_label": "Título",
|
||||
"event_editor_title_placeholder": "Título del evento",
|
||||
"event_editor_title_required": "Por favor, introduzca un título.",
|
||||
"event_editor_date_required": "Por favor, introduzca una fecha.",
|
||||
"event_editor_type_label": "Tipo",
|
||||
"event_editor_persons_label": "Personas",
|
||||
"event_editor_documents_label": "Cartas",
|
||||
"event_editor_description_label": "Descripción",
|
||||
"event_editor_description_placeholder": "Descripción opcional",
|
||||
"event_editor_persons_empty": "Aún no hay ninguna persona vinculada",
|
||||
"event_editor_documents_empty": "Aún no hay ningún documento vinculado",
|
||||
"event_type_PERSONAL": "Personal",
|
||||
"event_type_HISTORICAL": "Histórico",
|
||||
"event_editor_save": "Guardar",
|
||||
"event_editor_save_hint": "Los eventos aparecen en la cronología.",
|
||||
"event_editor_delete": "Eliminar",
|
||||
"event_editor_delete_confirm_title": "¿Eliminar evento?",
|
||||
"event_editor_delete_confirm_body": "Este evento se eliminará de forma permanente.",
|
||||
"event_editor_unsaved_changes": "Tienes cambios sin guardar — ¿salir de todos modos?",
|
||||
"error_geschichte_not_found": "No se encontró la historia.",
|
||||
"error_journey_item_not_found": "No se encontró el elemento del viaje.",
|
||||
"error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.",
|
||||
|
||||
@@ -11,12 +11,21 @@ interface Props {
|
||||
selectedDocuments?: DocumentOption[];
|
||||
placeholder?: string;
|
||||
hiddenInputName?: string;
|
||||
/** Empty-state text shown inside the chip container when nothing is selected. */
|
||||
emptyLabel?: string;
|
||||
/** id of the search input so a <label for=...> can be associated. */
|
||||
inputId?: string;
|
||||
/** Called when the selection changes (add/remove) — lets a parent track dirtiness. */
|
||||
onchange?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
selectedDocuments = $bindable([]),
|
||||
placeholder = m.geschichte_editor_search_document(),
|
||||
hiddenInputName = 'documentIds'
|
||||
hiddenInputName = 'documentIds',
|
||||
emptyLabel = undefined,
|
||||
inputId = undefined,
|
||||
onchange = undefined
|
||||
}: Props = $props();
|
||||
|
||||
let searchTerm = $state('');
|
||||
@@ -48,10 +57,12 @@ function selectDocument(doc: DocumentOption) {
|
||||
selectedDocuments = [...selectedDocuments, doc];
|
||||
searchTerm = '';
|
||||
picker.close();
|
||||
onchange?.();
|
||||
}
|
||||
|
||||
function removeDocument(id: string | undefined) {
|
||||
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
|
||||
onchange?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -73,7 +84,7 @@ function removeDocument(id: string | undefined) {
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeDocument(doc.id)}
|
||||
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
|
||||
class="ml-0.5 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm text-ink/50 hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
aria-label={m.comp_multiselect_remove()}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -88,8 +99,13 @@ function removeDocument(id: string | undefined) {
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
{#if emptyLabel && selectedDocuments.length === 0}
|
||||
<span class="px-1 py-1 font-sans text-sm text-ink-3 italic">{emptyLabel}</span>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
id={inputId}
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
|
||||
@@ -157,4 +157,14 @@ describe('DocumentMultiSelect — remove', () => {
|
||||
document.querySelector<HTMLInputElement>('input[type="hidden"][name="documentIds"]')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// REQ-017 (#781): chip remove targets must be ≥44px for the 60+ audience.
|
||||
it('renders a ≥44px touch target on the chip remove button', async () => {
|
||||
render(DocumentMultiSelect, {
|
||||
selectedDocuments: [docFactory('d1', 'Brief A')]
|
||||
});
|
||||
const removeBtn = (await page.getByLabelText('Entfernen').element()) as HTMLElement;
|
||||
expect(removeBtn.className).toContain('min-h-[44px]');
|
||||
expect(removeBtn.className).toContain('min-w-[44px]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
||||
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
|
||||
import FieldLabelBadge from '$lib/shared/primitives/FieldLabelBadge.svelte';
|
||||
import { isoToGerman, handleGermanDateInput } from '$lib/shared/utils/date';
|
||||
import DatePrecisionField from '$lib/shared/primitives/DatePrecisionField.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
@@ -37,64 +36,6 @@ let {
|
||||
hideDate?: boolean;
|
||||
editMode?: boolean;
|
||||
} = $props();
|
||||
|
||||
const PRECISIONS: { value: DatePrecision; label: () => string }[] = [
|
||||
{ value: 'DAY', label: m.date_precision_option_day },
|
||||
{ value: 'MONTH', label: m.date_precision_option_month },
|
||||
{ value: 'SEASON', label: m.date_precision_option_season },
|
||||
{ value: 'YEAR', label: m.date_precision_option_year },
|
||||
{ value: 'RANGE', label: m.date_precision_option_range },
|
||||
{ value: 'APPROX', label: m.date_precision_option_approx },
|
||||
{ value: 'UNKNOWN', label: m.date_precision_option_unknown }
|
||||
];
|
||||
|
||||
const showEndDate = $derived(precision === 'RANGE');
|
||||
|
||||
// dateDisplay seeds from the bindable's value or initialDateIso once at mount
|
||||
// and is then user-driven. onMount runs exactly once, so this never stomps
|
||||
// the parent's dateIso on a later prop change.
|
||||
let dateDisplay = $state('');
|
||||
let dateDirty = $state(false);
|
||||
let endDisplay = $state('');
|
||||
|
||||
onMount(() => {
|
||||
const seed = dateIso || initialDateIso;
|
||||
if (seed) {
|
||||
dateDisplay = isoToGerman(seed);
|
||||
if (!dateIso) dateIso = seed;
|
||||
}
|
||||
if (endDateIso) endDisplay = isoToGerman(endDateIso);
|
||||
});
|
||||
|
||||
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
||||
|
||||
// Inline mirror of the server guard (#678). ISO YYYY-MM-DD strings compare
|
||||
// lexicographically, so no Date object is needed. Server stays the gate —
|
||||
// this only surfaces the error early; it never disables Save.
|
||||
const endBeforeStart = $derived(
|
||||
showEndDate && endDateIso !== '' && dateIso !== '' && endDateIso < dateIso
|
||||
);
|
||||
|
||||
function handleDateInput(e: Event) {
|
||||
const result = handleGermanDateInput(e);
|
||||
dateDisplay = result.display;
|
||||
dateIso = result.iso;
|
||||
dateDirty = true;
|
||||
}
|
||||
|
||||
function handleEndDateInput(e: Event) {
|
||||
const result = handleGermanDateInput(e);
|
||||
endDisplay = result.display;
|
||||
endDateIso = result.iso;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const suggested = suggestedDateIso;
|
||||
if (suggested && !untrack(() => dateDirty)) {
|
||||
dateDisplay = isoToGerman(suggested);
|
||||
dateIso = suggested;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
@@ -104,79 +45,22 @@ $effect(() => {
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
{#if !hideDate}
|
||||
<!-- Datum (required — row 1, col 1) -->
|
||||
<div data-testid="who-when-date">
|
||||
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_date()}*</label
|
||||
>
|
||||
<input
|
||||
id="documentDate"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={dateDisplay}
|
||||
oninput={handleDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||
{dateInvalid
|
||||
? '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'}"
|
||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||
/>
|
||||
<input type="hidden" name="documentDate" value={dateIso} />
|
||||
{#if dateInvalid}
|
||||
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Datumsgenauigkeit (precision) -->
|
||||
<div data-testid="who-when-precision">
|
||||
<label for="metaDatePrecision" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_date_precision()}
|
||||
</label>
|
||||
<select
|
||||
id="metaDatePrecision"
|
||||
name="metaDatePrecision"
|
||||
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"
|
||||
>
|
||||
{#each PRECISIONS as p (p.value)}
|
||||
<option value={p.value}>{p.label()}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Enddatum: progressive disclosure, revealed only for RANGE, announced politely. -->
|
||||
<div aria-live="polite">
|
||||
{#if showEndDate}
|
||||
<div data-testid="who-when-end-date">
|
||||
<label for="metaDateEnd" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_date_end()}
|
||||
</label>
|
||||
<input
|
||||
id="metaDateEnd"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={endDisplay}
|
||||
oninput={handleEndDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
aria-invalid={endBeforeStart ? 'true' : undefined}
|
||||
aria-describedby={endBeforeStart ? 'end-date-error' : undefined}
|
||||
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||
{endBeforeStart
|
||||
? '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 endBeforeStart}
|
||||
<!-- Non-colour cue (WCAG 1.4.1): warning glyph + text, not red alone. -->
|
||||
<p id="end-date-error" class="mt-1 text-xs text-red-600">
|
||||
<span aria-hidden="true">⚠ </span>{m.error_invalid_date_range()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<input type="hidden" name="metaDateEnd" value={showEndDate ? endDateIso : ''} />
|
||||
<!-- Datum + Präzision + Enddatum (shared primitive, #781). The three grid
|
||||
cells slot directly into this grid; testids are forwarded so the
|
||||
existing WhoWhenSection selectors survive the extraction. -->
|
||||
<DatePrecisionField
|
||||
bind:dateIso={dateIso}
|
||||
bind:precision={precision}
|
||||
bind:endDateIso={endDateIso}
|
||||
initialDateIso={initialDateIso}
|
||||
suggestedDateIso={suggestedDateIso}
|
||||
dateInputName="documentDate"
|
||||
endDateInputName="metaDateEnd"
|
||||
dateLabel={m.form_label_date()}
|
||||
dateTestId="who-when-date"
|
||||
precisionTestId="who-when-precision"
|
||||
endDateInnerTestId="who-when-end-date"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Absender (required in upload mode — row 1, col 2) -->
|
||||
|
||||
@@ -39,4 +39,17 @@ describe('WhoWhenSection — onMount seeding (Felix B1 fix regression fence)', (
|
||||
const locationInput = document.querySelector('input#location') as HTMLInputElement;
|
||||
expect(locationInput.value).toBe('Berlin');
|
||||
});
|
||||
|
||||
// Regression fence for the DatePrecisionField extraction (#781): the existing
|
||||
// spec covered only date pre-fill / hideDate / location, so the RANGE end-date
|
||||
// reveal had no red signal. This test must stay green across the extraction.
|
||||
it('reveals the end-date field when precision is RANGE', async () => {
|
||||
render(WhoWhenSection, { precision: 'RANGE' });
|
||||
await expect.element(page.getByLabelText('Enddatum')).toBeVisible();
|
||||
});
|
||||
|
||||
it('hides the end-date field when precision is not RANGE', async () => {
|
||||
render(WhoWhenSection, { precision: 'YEAR' });
|
||||
await expect.element(page.getByTestId('who-when-end-date')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,14 +15,14 @@ describe('WhoWhenSection — date input behavior', () => {
|
||||
await vi.waitFor(() => {
|
||||
// Invalid → border-red-400 class
|
||||
expect(dateInput.className).toContain('border-red-400');
|
||||
expect(document.querySelector('#date-error')).not.toBeNull();
|
||||
expect(document.querySelector('#documentDate-error')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show the error before the user has typed', async () => {
|
||||
render(WhoWhenSection, {});
|
||||
|
||||
const error = document.querySelector('#date-error');
|
||||
const error = document.querySelector('#documentDate-error');
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
@@ -77,20 +77,20 @@ describe('WhoWhenSection — precision controls', () => {
|
||||
it('renders a labelled precision select', async () => {
|
||||
render(WhoWhenSection, {});
|
||||
|
||||
const label = document.querySelector('label[for="metaDatePrecision"]');
|
||||
const select = document.querySelector('select#metaDatePrecision[name="metaDatePrecision"]');
|
||||
const label = document.querySelector('label[for="documentDatePrecision"]');
|
||||
const select = document.querySelector('select#documentDatePrecision[name="metaDatePrecision"]');
|
||||
expect(label).not.toBeNull();
|
||||
expect(select).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides the end-date field unless precision is RANGE', async () => {
|
||||
render(WhoWhenSection, { precision: 'DAY' });
|
||||
expect(document.querySelector('input#metaDateEnd')).toBeNull();
|
||||
expect(document.querySelector('input#documentDateEnd')).toBeNull();
|
||||
});
|
||||
|
||||
it('reveals the end-date field when precision is RANGE', async () => {
|
||||
render(WhoWhenSection, { precision: 'RANGE' });
|
||||
expect(document.querySelector('input#metaDateEnd')).not.toBeNull();
|
||||
expect(document.querySelector('input#documentDateEnd')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('never renders the raw cell, and never re-submits it via a hidden input', async () => {
|
||||
@@ -110,9 +110,9 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => {
|
||||
endDateIso: '1917-01-10'
|
||||
});
|
||||
|
||||
const end = document.querySelector('input#metaDateEnd') as HTMLInputElement;
|
||||
const end = document.querySelector('input#documentDateEnd') as HTMLInputElement;
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('#end-date-error')).not.toBeNull();
|
||||
expect(document.querySelector('#documentDate-end-error')).not.toBeNull();
|
||||
expect(end.getAttribute('aria-invalid')).toBe('true');
|
||||
expect(end.className).toContain('border-red-400');
|
||||
});
|
||||
@@ -125,14 +125,16 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => {
|
||||
endDateIso: '1917-01-10'
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(document.querySelector('#end-date-error')).not.toBeNull());
|
||||
await vi.waitFor(() =>
|
||||
expect(document.querySelector('#documentDate-end-error')).not.toBeNull()
|
||||
);
|
||||
|
||||
const end = document.querySelector('input#metaDateEnd') as HTMLInputElement;
|
||||
const end = document.querySelector('input#documentDateEnd') as HTMLInputElement;
|
||||
end.value = '12.01.1917'; // now after the start
|
||||
end.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('#end-date-error')).toBeNull();
|
||||
expect(document.querySelector('#documentDate-end-error')).toBeNull();
|
||||
expect(end.getAttribute('aria-invalid')).not.toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -144,6 +146,6 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => {
|
||||
endDateIso: '1917-01-10'
|
||||
});
|
||||
|
||||
expect(document.querySelector('#end-date-error')).toBeNull();
|
||||
expect(document.querySelector('#documentDate-end-error')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
20
frontend/src/lib/document/documentTypeahead.spec.ts
Normal file
20
frontend/src/lib/document/documentTypeahead.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatDocumentOption, type DocumentOption } from './documentTypeahead';
|
||||
|
||||
describe('formatDocumentOption', () => {
|
||||
it('returns the bare title when no documentDate is present', () => {
|
||||
const doc: DocumentOption = { id: 'd1', title: 'Brief ohne Datum' };
|
||||
expect(formatDocumentOption(doc)).toBe('Brief ohne Datum');
|
||||
});
|
||||
|
||||
// #781: a TimelineEvent's DocumentRef carries documentDate but no precision.
|
||||
// Missing precision must degrade to the full date (DAY), never the UNKNOWN label.
|
||||
it('renders the full date when precision is absent (DocumentRef chip)', () => {
|
||||
const doc: DocumentOption = { id: 'd1', title: 'Umzugsbrief', documentDate: '1925-04-01' };
|
||||
const label = formatDocumentOption(doc);
|
||||
expect(label.startsWith('Umzugsbrief · ')).toBe(true);
|
||||
expect(label).toContain('1925');
|
||||
// The undefined-precision fallback would otherwise surface the UNKNOWN word.
|
||||
expect(label.toLowerCase()).not.toContain('unbekannt');
|
||||
});
|
||||
});
|
||||
@@ -5,13 +5,21 @@ import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
|
||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||
|
||||
export type DocumentOption = Pick<
|
||||
DocumentListItem,
|
||||
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
|
||||
>;
|
||||
/**
|
||||
* Chip/dedup contract for document pickers. `metaDatePrecision`/`metaDateEnd`
|
||||
* are optional: the typeahead always populates them, but a TimelineEvent's
|
||||
* DocumentRef (#781) carries only id/title/documentDate — formatDocumentOption
|
||||
* degrades gracefully (bare title or plain date) when precision is absent.
|
||||
*/
|
||||
export type DocumentOption = Pick<DocumentListItem, 'id' | 'title' | 'documentDate'> &
|
||||
Partial<Pick<DocumentListItem, 'metaDatePrecision' | 'metaDateEnd'>>;
|
||||
|
||||
export function createDocumentTypeahead() {
|
||||
return createTypeahead<DocumentOption>({
|
||||
// Intentional bare browser fetch (matches the Geschichte editor): in dev the
|
||||
// Vite proxy forwards /api and injects the auth header; in prod the app is
|
||||
// same-origin so the auth cookie travels automatically. An internal
|
||||
// +server.ts proxy would add complexity with no practical security benefit.
|
||||
fetchUrl: (q) =>
|
||||
fetch(`/api/documents/search?q=${encodeURIComponent(q)}&size=10`)
|
||||
.then((r) => {
|
||||
@@ -34,9 +42,12 @@ export function createDocumentTypeahead() {
|
||||
|
||||
export function formatDocumentOption(doc: DocumentOption): string {
|
||||
if (!doc.documentDate) return doc.title;
|
||||
// A DocumentRef (#781 timeline chips) carries documentDate but no precision —
|
||||
// default to DAY so the full date renders, rather than the UNKNOWN fallback
|
||||
// formatDocumentDate would otherwise hit for an undefined precision.
|
||||
const label = formatDocumentDate(
|
||||
doc.documentDate,
|
||||
doc.metaDatePrecision as DatePrecision,
|
||||
(doc.metaDatePrecision as DatePrecision) ?? 'DAY',
|
||||
doc.metaDateEnd,
|
||||
null,
|
||||
getLocale()
|
||||
|
||||
@@ -7,9 +7,23 @@ type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
selectedPersons?: PersonOption[];
|
||||
/** Name of the hidden inputs carrying selected ids. Mirrors DocumentMultiSelect. */
|
||||
hiddenInputName?: string;
|
||||
/** Empty-state text shown inside the chip container when nothing is selected. */
|
||||
emptyLabel?: string;
|
||||
/** id of the search input so a <label for=...> can be associated. */
|
||||
inputId?: string;
|
||||
/** Called when the selection changes (add/remove) — lets a parent track dirtiness. */
|
||||
onchange?: () => void;
|
||||
}
|
||||
|
||||
let { selectedPersons = $bindable([]) }: Props = $props();
|
||||
let {
|
||||
selectedPersons = $bindable([]),
|
||||
hiddenInputName = 'receiverIds',
|
||||
emptyLabel = undefined,
|
||||
inputId = undefined,
|
||||
onchange = undefined
|
||||
}: Props = $props();
|
||||
|
||||
let searchTerm = $state('');
|
||||
let results: Person[] = $state([]);
|
||||
@@ -54,17 +68,19 @@ function selectPerson(person: Person) {
|
||||
searchTerm = '';
|
||||
showDropdown = false;
|
||||
results = [];
|
||||
onchange?.();
|
||||
}
|
||||
|
||||
function removePerson(id: string | undefined) {
|
||||
selectedPersons = selectedPersons.filter((p) => p.id !== id);
|
||||
onchange?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
|
||||
{#each selectedPersons as person (person.id)}
|
||||
<input type="hidden" name="receiverIds" value={person.id} />
|
||||
<input type="hidden" name={hiddenInputName} value={person.id} />
|
||||
{/each}
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||
@@ -79,7 +95,7 @@ function removePerson(id: string | undefined) {
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removePerson(person.id)}
|
||||
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
|
||||
class="ml-0.5 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm text-ink/50 hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
aria-label={m.comp_multiselect_remove()}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -94,8 +110,13 @@ function removePerson(id: string | undefined) {
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
{#if emptyLabel && selectedPersons.length === 0}
|
||||
<span class="px-1 py-1 font-sans text-sm text-ink-3 italic">{emptyLabel}</span>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
id={inputId}
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
|
||||
@@ -258,6 +258,19 @@ describe('PersonMultiSelect – removing persons', () => {
|
||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// REQ-017 (#781): chip remove targets must be ≥44px for the 60+ audience.
|
||||
it('renders a ≥44px touch target on the chip remove button', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [{ id: '1', displayName: 'Max Mustermann' }]
|
||||
});
|
||||
const removeBtn = (await page
|
||||
.getByRole('button', { name: 'Entfernen' })
|
||||
.first()
|
||||
.element()) as HTMLElement;
|
||||
expect(removeBtn.className).toContain('min-h-[44px]');
|
||||
expect(removeBtn.className).toContain('min-w-[44px]');
|
||||
});
|
||||
|
||||
it('removes the corresponding hidden input when a chip is removed', async () => {
|
||||
render(PersonMultiSelect, {
|
||||
selectedPersons: [
|
||||
|
||||
@@ -19,6 +19,7 @@ $effect(() => {
|
||||
<dialog
|
||||
bind:this={dialogEl}
|
||||
class="m-auto w-full max-w-sm rounded-sm border border-line bg-surface p-6 shadow-lg backdrop:bg-black/50"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
oncancel={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
211
frontend/src/lib/shared/primitives/DatePrecisionField.svelte
Normal file
211
frontend/src/lib/shared/primitives/DatePrecisionField.svelte
Normal file
@@ -0,0 +1,211 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { isoToGerman, handleGermanDateInput } from '$lib/shared/utils/date';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
|
||||
/**
|
||||
* Generic date + precision input primitive shared by two domains:
|
||||
* `document/` (via WhoWhenSection) and `timeline/` (via EventForm).
|
||||
*
|
||||
* Renders three grid cells — a German `dd.mm.yyyy` text input backed by a hidden
|
||||
* ISO input, a precision <select>, and a progressively-disclosed end-date input
|
||||
* shown only for RANGE. Living in `$lib/shared/primitives/` keeps it out of either
|
||||
* consumer's domain so neither incurs a cross-domain import (eslint boundaries).
|
||||
*
|
||||
* 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 / 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.
|
||||
* - `end-date-region` is always on the OUTER aria-live wrapper of the end block.
|
||||
*/
|
||||
let {
|
||||
dateIso = $bindable(''),
|
||||
precision = $bindable<DatePrecision>('DAY'),
|
||||
endDateIso = $bindable(''),
|
||||
dateInputName = 'documentDate',
|
||||
endDateInputName = 'metaDateEnd',
|
||||
precisionInputName = 'metaDatePrecision',
|
||||
initialDateIso = '',
|
||||
suggestedDateIso = '',
|
||||
dateLabel = m.form_label_date(),
|
||||
dateRequired = true,
|
||||
dateError = '',
|
||||
onchange = undefined,
|
||||
dateTestId = undefined,
|
||||
precisionTestId = undefined,
|
||||
endDateInnerTestId = undefined
|
||||
}: {
|
||||
dateIso?: string;
|
||||
precision?: DatePrecision;
|
||||
endDateIso?: string;
|
||||
dateInputName?: string;
|
||||
endDateInputName?: string;
|
||||
precisionInputName?: string;
|
||||
initialDateIso?: string;
|
||||
suggestedDateIso?: string;
|
||||
dateLabel?: string;
|
||||
dateRequired?: boolean;
|
||||
/** Server-side date error (e.g. blank required field) wired to the field's aria-invalid. */
|
||||
dateError?: string;
|
||||
/** Called on any user edit (date, precision, end-date) — lets a parent track dirtiness. */
|
||||
onchange?: () => void;
|
||||
dateTestId?: string;
|
||||
precisionTestId?: string;
|
||||
endDateInnerTestId?: string;
|
||||
} = $props();
|
||||
|
||||
const PRECISIONS: { value: DatePrecision; label: () => string }[] = [
|
||||
{ value: 'DAY', label: m.date_precision_option_day },
|
||||
{ value: 'MONTH', label: m.date_precision_option_month },
|
||||
{ value: 'SEASON', label: m.date_precision_option_season },
|
||||
{ value: 'YEAR', label: m.date_precision_option_year },
|
||||
{ value: 'RANGE', label: m.date_precision_option_range },
|
||||
{ value: 'APPROX', label: m.date_precision_option_approx },
|
||||
{ value: 'UNKNOWN', label: m.date_precision_option_unknown }
|
||||
];
|
||||
|
||||
const showEndDate = $derived(precision === 'RANGE');
|
||||
|
||||
// dateDisplay seeds from the bindable's value or initialDateIso once at mount
|
||||
// and is then user-driven. onMount runs exactly once, so this never stomps
|
||||
// the parent's dateIso on a later prop change.
|
||||
let dateDisplay = $state('');
|
||||
let dateDirty = $state(false);
|
||||
let endDisplay = $state('');
|
||||
|
||||
onMount(() => {
|
||||
const seed = dateIso || initialDateIso;
|
||||
if (seed) {
|
||||
dateDisplay = isoToGerman(seed);
|
||||
if (!dateIso) dateIso = seed;
|
||||
}
|
||||
if (endDateIso) endDisplay = isoToGerman(endDateIso);
|
||||
});
|
||||
|
||||
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
||||
// Either the client-side malformed-date cue or a server-provided required-field
|
||||
// error marks the field invalid (REQ-011 per-field aria-invalid).
|
||||
const dateFieldInvalid = $derived(dateInvalid || dateError.length > 0);
|
||||
|
||||
// Inline mirror of the server guard (#678). ISO YYYY-MM-DD strings compare
|
||||
// lexicographically, so no Date object is needed. Server stays the gate —
|
||||
// this only surfaces the error early; it never disables Save.
|
||||
const endBeforeStart = $derived(
|
||||
showEndDate && endDateIso !== '' && dateIso !== '' && endDateIso < dateIso
|
||||
);
|
||||
|
||||
function handleDateInput(e: Event) {
|
||||
const result = handleGermanDateInput(e);
|
||||
dateDisplay = result.display;
|
||||
dateIso = result.iso;
|
||||
dateDirty = true;
|
||||
onchange?.();
|
||||
}
|
||||
|
||||
function handleEndDateInput(e: Event) {
|
||||
const result = handleGermanDateInput(e);
|
||||
endDisplay = result.display;
|
||||
endDateIso = result.iso;
|
||||
onchange?.();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const suggested = suggestedDateIso;
|
||||
if (suggested && !untrack(() => dateDirty)) {
|
||||
dateDisplay = isoToGerman(suggested);
|
||||
dateIso = suggested;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Datum (required) -->
|
||||
<div data-testid={dateTestId}>
|
||||
<label for={dateInputName} class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{dateLabel}{#if dateRequired}*{/if}</label
|
||||
>
|
||||
<input
|
||||
id={dateInputName}
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={dateDisplay}
|
||||
oninput={handleDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
aria-required={dateRequired ? 'true' : undefined}
|
||||
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||
{dateFieldInvalid
|
||||
? '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'}"
|
||||
aria-invalid={dateFieldInvalid ? 'true' : undefined}
|
||||
aria-describedby={dateFieldInvalid ? `${dateInputName}-error` : undefined}
|
||||
/>
|
||||
<input type="hidden" name={dateInputName} value={dateIso} />
|
||||
{#if dateInvalid}
|
||||
<p id="{dateInputName}-error" class="mt-1 text-xs text-danger">
|
||||
<span aria-hidden="true">⚠ </span>{m.form_date_error()}
|
||||
</p>
|
||||
{:else if dateError}
|
||||
<p id="{dateInputName}-error" class="mt-1 text-xs text-danger">
|
||||
<span aria-hidden="true">⚠ </span>{dateError}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Datumsgenauigkeit (precision) -->
|
||||
<div data-testid={precisionTestId}>
|
||||
<label for="{dateInputName}Precision" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_date_precision()}
|
||||
</label>
|
||||
<select
|
||||
id="{dateInputName}Precision"
|
||||
name={precisionInputName}
|
||||
bind:value={precision}
|
||||
onchange={() => onchange?.()}
|
||||
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"
|
||||
>
|
||||
{#each PRECISIONS as p (p.value)}
|
||||
<option value={p.value}>{p.label()}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Enddatum: progressive disclosure, revealed only for RANGE, announced politely. -->
|
||||
<div aria-live="polite" data-testid="end-date-region">
|
||||
{#if showEndDate}
|
||||
<div data-testid={endDateInnerTestId}>
|
||||
<label for="{dateInputName}End" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_date_end()}
|
||||
</label>
|
||||
<input
|
||||
id="{dateInputName}End"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={endDisplay}
|
||||
oninput={handleEndDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
aria-invalid={endBeforeStart ? 'true' : undefined}
|
||||
aria-describedby={endBeforeStart ? `${dateInputName}-end-error` : undefined}
|
||||
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||
{endBeforeStart
|
||||
? '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 endBeforeStart}
|
||||
<!-- Non-colour cue (WCAG 1.4.1): warning glyph + text, not red alone. -->
|
||||
<p id="{dateInputName}-end-error" class="mt-1 text-xs text-danger">
|
||||
<span aria-hidden="true">⚠ </span>{m.error_invalid_date_range()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Off-RANGE submits an empty string so a stale end-date never persists; the
|
||||
form action converts '' → null before sending the request body. -->
|
||||
<input type="hidden" name={endDateInputName} value={showEndDate ? endDateIso : ''} />
|
||||
@@ -1,3 +1,5 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* Server-side permission predicates derived from the authenticated user in `locals`.
|
||||
*
|
||||
@@ -12,3 +14,17 @@ type PermissionLocals = {
|
||||
export function hasWriteAll(locals: PermissionLocals): boolean {
|
||||
return locals.user?.groups?.some((group) => group.permissions.includes('WRITE_ALL')) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a 403 unless the user holds WRITE_ALL. Anonymous users are rejected too
|
||||
* — `hasWriteAll` returns false for a null user, so a single check covers both
|
||||
* the unauthenticated and the under-privileged case. Server-side gate; the
|
||||
* frontend canWrite flag only hides entry-point buttons.
|
||||
*
|
||||
* Other WRITE_ALL-gated author loads (e.g. `documents/[id]/edit`) still inline
|
||||
* `if (!hasWriteAll(locals)) throw error(403)` — they can adopt this helper so
|
||||
* the guard doesn't quietly diverge across routes.
|
||||
*/
|
||||
export function requireWriteAll(locals: PermissionLocals): void {
|
||||
if (!hasWriteAll(locals)) throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
326
frontend/src/lib/timeline/EventForm.svelte
Normal file
326
frontend/src/lib/timeline/EventForm.svelte
Normal file
@@ -0,0 +1,326 @@
|
||||
<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[];
|
||||
// Rehydrated chip data (id + label) so the pickers re-render after a fail(400)
|
||||
// even on a no-JS full reload — bare ids alone can't rebuild a chip (REQ-010).
|
||||
persons?: PersonOption[];
|
||||
documents?: DocumentOption[];
|
||||
}
|
||||
|
||||
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, preferring a preserved fail payload
|
||||
// over the seeded `event`. This component is intentionally single-shot: props are
|
||||
// snapshotted into $state once, so a parent re-render with a different `event`
|
||||
// won't update the form — the two dedicated routes always remount, which is fine.
|
||||
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 ?? '');
|
||||
|
||||
// On a fail(400) the server returns rehydrated chip data (form.persons/documents)
|
||||
// so the pickers survive the round-trip — even without JS — ahead of the seeded
|
||||
// `event` or the prefill initials (REQ-010 / Decision 6).
|
||||
let selectedPersons = $state<PersonOption[]>(
|
||||
form?.persons ?? (event?.persons ? event.persons.map(toPersonOption) : initialPersons)
|
||||
);
|
||||
let selectedDocuments = $state<DocumentOption[]>(
|
||||
form?.documents ??
|
||||
(event?.documents
|
||||
? event.documents.map((d) => ({
|
||||
// Graceful degradation: DocumentRef has no precision fields. formatDocumentOption
|
||||
// defaults a missing precision to DAY, so the chip shows the full documentDate.
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
// Every editable control routes its change through markDirty so the
|
||||
// beforeNavigate guard catches edits to the date/precision/end-date and the
|
||||
// pickers too — not just title/type/description (their onchange callbacks call
|
||||
// this). No $effect: marking dirty from the actual edit events avoids a
|
||||
// snapshot-vs-effect mount-timing trap.
|
||||
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} />
|
||||
{#if event}
|
||||
<!-- Optimistic-lock version travels back to the PUT so #3 can reject a
|
||||
stale edit with 409. -->
|
||||
<input type="hidden" name="version" value={event.version} />
|
||||
{/if}
|
||||
|
||||
<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={(t) => {
|
||||
type = t;
|
||||
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"
|
||||
precisionInputName="precision"
|
||||
dateLabel={m.form_label_date()}
|
||||
dateError={dateError}
|
||||
onchange={markDirty}
|
||||
dateTestId="event-date"
|
||||
precisionTestId="event-precision"
|
||||
endDateInnerTestId="event-end-date"
|
||||
/>
|
||||
</div>
|
||||
</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()}
|
||||
onchange={markDirty}
|
||||
/>
|
||||
</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()}
|
||||
onchange={markDirty}
|
||||
/>
|
||||
</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>
|
||||
113
frontend/src/lib/timeline/EventForm.svelte.spec.ts
Normal file
113
frontend/src/lib/timeline/EventForm.svelte.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { afterEach, describe, expect, it, vi } 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();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
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();
|
||||
await expect.element(page.getByLabelText('Enddatum')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('rehydrates the pickers from the fail payload (Decision 6)', async () => {
|
||||
render(EventForm, {
|
||||
form: {
|
||||
titleError: 'Bitte einen Titel eingeben.',
|
||||
title: '',
|
||||
persons: [{ id: 'p1', displayName: 'Anna Müller' }],
|
||||
documents: [{ id: 'd1', title: 'Brief A', documentDate: '1925-04-01' }]
|
||||
}
|
||||
});
|
||||
await expect.element(page.getByText('Anna Müller')).toBeInTheDocument();
|
||||
await expect.element(page.getByText(/Brief A/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
render(EventForm, { form: { dateError: 'Bitte ein Datum eingeben.' } });
|
||||
await expect.element(page.getByText('Bitte ein Datum eingeben.')).toBeInTheDocument();
|
||||
const dateInput = document.querySelector('#eventDate') as HTMLInputElement;
|
||||
expect(dateInput.getAttribute('aria-invalid')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EventForm — submitting state (named AC, Decision 8)', () => {
|
||||
it('disables the submit button while submitting', async () => {
|
||||
// A never-resolving fetch keeps use:enhance in flight so the disabled
|
||||
// transition (the double-submit guard) is observable rather than racing the
|
||||
// reset in the result callback.
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => new Promise(() => {}))
|
||||
);
|
||||
render(EventForm, { event: makeEvent() });
|
||||
const btn = page.getByRole('button', { name: 'Speichern' });
|
||||
await expect.element(btn).not.toBeDisabled();
|
||||
await btn.click();
|
||||
await expect.element(btn).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();
|
||||
});
|
||||
});
|
||||
69
frontend/src/lib/timeline/EventTypeSelect.svelte
Normal file
69
frontend/src/lib/timeline/EventTypeSelect.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { radioGroupNav } from '$lib/shared/actions/radioGroupNav';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type EventType = 'PERSONAL' | 'HISTORICAL';
|
||||
const TYPES: EventType[] = ['PERSONAL', 'HISTORICAL'];
|
||||
|
||||
let {
|
||||
value = 'PERSONAL',
|
||||
name = 'type',
|
||||
onchange
|
||||
}: { value?: string; name?: string; onchange?: (type: EventType) => void } = $props();
|
||||
|
||||
let selected = $state<EventType>(
|
||||
untrack(() => (TYPES.includes(value as EventType) ? (value as EventType) : 'PERSONAL'))
|
||||
);
|
||||
|
||||
let announcement = $state('');
|
||||
|
||||
const labels: Record<EventType, () => string> = {
|
||||
PERSONAL: m.event_type_PERSONAL,
|
||||
HISTORICAL: m.event_type_HISTORICAL
|
||||
};
|
||||
|
||||
// Decorative accents only — never the sole differentiator (text label is always
|
||||
// present). aria-hidden so AT announces the label, not the glyph.
|
||||
const icons: Record<EventType, string> = {
|
||||
PERSONAL: '👤',
|
||||
HISTORICAL: '🏛'
|
||||
};
|
||||
|
||||
function select(type: EventType) {
|
||||
selected = type;
|
||||
announcement = m.a11y_type_changed({ type: labels[type]() });
|
||||
onchange?.(type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={m.event_editor_type_label()}
|
||||
class="grid grid-cols-2 gap-2"
|
||||
use:radioGroupNav={(v) => {
|
||||
if (TYPES.includes(v as EventType)) select(v as EventType);
|
||||
}}
|
||||
>
|
||||
{#each TYPES as type (type)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
value={type}
|
||||
aria-checked={selected === type}
|
||||
tabindex={selected === type ? 0 : -1}
|
||||
onclick={() => select(type)}
|
||||
class="flex min-h-[48px] cursor-pointer items-center gap-2 rounded-sm border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none {selected ===
|
||||
type
|
||||
? 'border-primary bg-primary text-primary-fg'
|
||||
: 'border-line bg-surface text-ink hover:border-primary/50'}"
|
||||
>
|
||||
<span class="text-lg leading-none" aria-hidden="true">{icons[type]}</span>
|
||||
<span>{labels[type]()}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<input type="hidden" name={name} value={selected} />
|
||||
|
||||
<div class="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div>
|
||||
27
frontend/src/lib/timeline/EventTypeSelect.svelte.spec.ts
Normal file
27
frontend/src/lib/timeline/EventTypeSelect.svelte.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import EventTypeSelect from './EventTypeSelect.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('EventTypeSelect — segmented PERSONAL/HISTORICAL radio', () => {
|
||||
it('renders exactly two radio options', async () => {
|
||||
render(EventTypeSelect, { value: 'PERSONAL' });
|
||||
const radios = document.querySelectorAll('[role="radio"]');
|
||||
expect(radios.length).toBe(2);
|
||||
});
|
||||
|
||||
it('marks the initial value as checked and seeds the hidden input', async () => {
|
||||
render(EventTypeSelect, { value: 'HISTORICAL', name: 'type' });
|
||||
const hidden = document.querySelector('input[type="hidden"][name="type"]') as HTMLInputElement;
|
||||
expect(hidden.value).toBe('HISTORICAL');
|
||||
});
|
||||
|
||||
it('selects HISTORICAL and updates the hidden input when clicked', async () => {
|
||||
render(EventTypeSelect, { value: 'PERSONAL', name: 'type' });
|
||||
await page.getByRole('radio', { name: 'Historisch' }).click();
|
||||
const hidden = document.querySelector('input[type="hidden"][name="type"]') as HTMLInputElement;
|
||||
expect(hidden.value).toBe('HISTORICAL');
|
||||
});
|
||||
});
|
||||
143
frontend/src/lib/timeline/eventFormServer.ts
Normal file
143
frontend/src/lib/timeline/eventFormServer.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
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;
|
||||
}
|
||||
107
frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts
Normal file
107
frontend/src/routes/zeitstrahl/events/[id]/edit/+page.server.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { requireWriteAll } from '$lib/shared/server/permissions';
|
||||
import {
|
||||
parseEventForm,
|
||||
validateEventForm,
|
||||
preservedFormFields,
|
||||
lookupSelections,
|
||||
toEventRequest,
|
||||
resolveNavTarget
|
||||
} from '$lib/timeline/eventFormServer';
|
||||
|
||||
export async function load({
|
||||
locals,
|
||||
params,
|
||||
url,
|
||||
fetch
|
||||
}: {
|
||||
locals: App.Locals;
|
||||
params: { id: string };
|
||||
url: URL;
|
||||
fetch: typeof globalThis.fetch;
|
||||
}) {
|
||||
requireWriteAll(locals);
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.GET('/api/timeline/events/{id}', {
|
||||
params: { path: { id: params.id } }
|
||||
});
|
||||
|
||||
// Fail closed: derived person-events (Geburt/Tod/Heirat) are not persisted and
|
||||
// have no UUID, so the API 404s for them. Any non-ok response → 404; never
|
||||
// render a blank editable form that silently POSTs a new event.
|
||||
if (!result.response.ok) throw error(404, 'Not found');
|
||||
|
||||
return { event: result.data!, originPersonId: url.searchParams.get('personId') ?? '' };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
save: async ({
|
||||
request,
|
||||
params,
|
||||
fetch
|
||||
}: {
|
||||
request: Request;
|
||||
params: { id: string };
|
||||
fetch: typeof globalThis.fetch;
|
||||
}) => {
|
||||
const formData = await request.formData();
|
||||
const parsed = parseEventForm(formData);
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const errors = validateEventForm(parsed);
|
||||
if (errors) {
|
||||
const { persons, documents } = await lookupSelections(
|
||||
api,
|
||||
parsed.personIds,
|
||||
parsed.documentIds
|
||||
);
|
||||
return fail(400, { ...errors, ...preservedFormFields(parsed), persons, documents });
|
||||
}
|
||||
|
||||
const versionRaw = formData.get('version')?.toString();
|
||||
const version = versionRaw ? Number(versionRaw) : undefined;
|
||||
|
||||
const result = await api.PUT('/api/timeline/events/{id}', {
|
||||
params: { path: { id: params.id } },
|
||||
body: toEventRequest(parsed, version)
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(result.error)),
|
||||
...preservedFormFields(parsed)
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect(303, resolveNavTarget(parsed.originPersonId));
|
||||
},
|
||||
|
||||
delete: async ({
|
||||
request,
|
||||
params,
|
||||
fetch
|
||||
}: {
|
||||
request: Request;
|
||||
params: { id: string };
|
||||
fetch: typeof globalThis.fetch;
|
||||
}) => {
|
||||
const formData = await request.formData();
|
||||
const originPersonId = formData.get('originPersonId')?.toString() ?? '';
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.DELETE('/api/timeline/events/{id}', {
|
||||
params: { path: { id: params.id } }
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect(303, resolveNavTarget(originPersonId));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import EventForm from '$lib/timeline/EventForm.svelte';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<EventForm event={data.event} originPersonId={data.originPersonId} form={form} />
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
import { load, actions } from './+page.server';
|
||||
|
||||
const mockFetch = vi.fn() as unknown as typeof fetch;
|
||||
|
||||
// fail() returns a union type that TS won't narrow; read its data loosely.
|
||||
const failData = (r: unknown) => (r as { data: Record<string, unknown> }).data;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function localsWith(perms: string[] | null) {
|
||||
if (perms === null) return { user: null };
|
||||
return { user: { groups: [{ permissions: perms }] } };
|
||||
}
|
||||
|
||||
function loadEvent(perms: string[] | null, id = 'e1') {
|
||||
const url = new URL(`http://localhost/zeitstrahl/events/${id}/edit`);
|
||||
return {
|
||||
locals: localsWith(perms),
|
||||
url,
|
||||
params: { id },
|
||||
fetch: mockFetch,
|
||||
request: new Request(url),
|
||||
route: { id: '/zeitstrahl/events/[id]/edit' }
|
||||
} as never;
|
||||
}
|
||||
|
||||
function actionEvent(
|
||||
method: 'save' | 'delete',
|
||||
fields: Record<string, string | string[]>,
|
||||
id = 'e1'
|
||||
) {
|
||||
const fd = new FormData();
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (Array.isArray(v)) v.forEach((x) => fd.append(k, x));
|
||||
else fd.set(k, v);
|
||||
}
|
||||
return {
|
||||
request: new Request(`http://localhost/zeitstrahl/events/${id}/edit?/${method}`, {
|
||||
method: 'POST',
|
||||
body: fd
|
||||
}),
|
||||
params: { id },
|
||||
fetch: mockFetch,
|
||||
route: { id: '/zeitstrahl/events/[id]/edit' }
|
||||
} as never;
|
||||
}
|
||||
|
||||
const EVENT_VIEW = {
|
||||
id: 'e1',
|
||||
title: 'Umzug',
|
||||
type: 'PERSONAL',
|
||||
eventDate: '1925-04-01',
|
||||
precision: 'DAY',
|
||||
version: 2,
|
||||
createdBy: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedBy: 'u1',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
persons: [],
|
||||
documents: []
|
||||
};
|
||||
|
||||
describe('zeitstrahl/events/[id]/edit load — gating (REQ-002/003)', () => {
|
||||
it('throws 403 for an unauthenticated (null) user', async () => {
|
||||
await expect(load(loadEvent(null))).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('throws 403 for a user without WRITE_ALL', async () => {
|
||||
await expect(load(loadEvent(['READ_ALL']))).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('zeitstrahl/events/[id]/edit load — fail closed (REQ-012)', () => {
|
||||
it('throws 404 when the GET is not ok (unknown or derived id)', async () => {
|
||||
vi.mocked(createApiClient).mockReturnValue({
|
||||
GET: vi.fn().mockResolvedValue({ response: { ok: false, status: 404 }, data: undefined })
|
||||
} as never);
|
||||
await expect(load(loadEvent(['WRITE_ALL']))).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('seeds the form with the event on an ok GET', async () => {
|
||||
vi.mocked(createApiClient).mockReturnValue({
|
||||
GET: vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: EVENT_VIEW })
|
||||
} as never);
|
||||
const result = await load(loadEvent(['WRITE_ALL']));
|
||||
expect(result.event).toMatchObject({ id: 'e1', title: 'Umzug' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('zeitstrahl/events/[id]/edit save action (REQ-005/013)', () => {
|
||||
it('updates via PUT (with version) and redirects on success', async () => {
|
||||
const put = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: true, status: 200 }, data: EVENT_VIEW });
|
||||
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as never);
|
||||
|
||||
await expect(
|
||||
actions.save(
|
||||
actionEvent('save', {
|
||||
title: 'Umzug II',
|
||||
type: 'PERSONAL',
|
||||
eventDate: '1925-04-01',
|
||||
version: '2'
|
||||
})
|
||||
)
|
||||
).rejects.toMatchObject({ status: 303, location: '/zeitstrahl' });
|
||||
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
expect(put.mock.calls[0][1].params.path.id).toBe('e1');
|
||||
expect(put.mock.calls[0][1].body).toMatchObject({ title: 'Umzug II', version: 2 });
|
||||
});
|
||||
|
||||
it('maps a 409 conflict and does not redirect', async () => {
|
||||
const put = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: false, status: 409 }, error: { code: 'CONFLICT' } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as never);
|
||||
|
||||
const result = await actions.save(
|
||||
actionEvent('save', { title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01' })
|
||||
);
|
||||
expect(result).toMatchObject({ status: 409 });
|
||||
expect(failData(result).error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('zeitstrahl/events/[id]/edit delete action (REQ-006/007)', () => {
|
||||
const validUuid = '22222222-2222-2222-2222-222222222222';
|
||||
|
||||
it('deletes via DELETE and redirects to the resolved target on success', async () => {
|
||||
const del = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ DELETE: del } as never);
|
||||
|
||||
await expect(
|
||||
actions.delete(actionEvent('delete', { originPersonId: validUuid }))
|
||||
).rejects.toMatchObject({ status: 303, location: `/persons/${validUuid}` });
|
||||
expect(del.mock.calls[0][1].params.path.id).toBe('e1');
|
||||
});
|
||||
|
||||
it('returns fail(status) and does not redirect when DELETE is not ok', async () => {
|
||||
const del = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: false, status: 500 }, error: { code: 'INTERNAL' } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ DELETE: del } as never);
|
||||
|
||||
const result = await actions.delete(actionEvent('delete', {}));
|
||||
expect(result).toMatchObject({ status: 500 });
|
||||
expect(failData(result).error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
81
frontend/src/routes/zeitstrahl/events/new/+page.server.ts
Normal file
81
frontend/src/routes/zeitstrahl/events/new/+page.server.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { requireWriteAll } from '$lib/shared/server/permissions';
|
||||
import {
|
||||
parseEventForm,
|
||||
validateEventForm,
|
||||
preservedFormFields,
|
||||
lookupSelections,
|
||||
toEventRequest,
|
||||
resolveNavTarget
|
||||
} from '$lib/timeline/eventFormServer';
|
||||
import type { PersonOption } from '$lib/person/personOption';
|
||||
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
||||
|
||||
export async function load({
|
||||
locals,
|
||||
url,
|
||||
fetch
|
||||
}: {
|
||||
locals: App.Locals;
|
||||
url: URL;
|
||||
fetch: typeof globalThis.fetch;
|
||||
}) {
|
||||
requireWriteAll(locals);
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const personId = url.searchParams.get('personId');
|
||||
const documentId = url.searchParams.get('documentId');
|
||||
|
||||
const [personResult, documentResult] = await Promise.all([
|
||||
personId ? api.GET('/api/persons/{id}', { params: { path: { id: personId } } }) : null,
|
||||
documentId ? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } }) : null
|
||||
]);
|
||||
|
||||
// Silently ignore 404/403 on prefill lookups to avoid leaking entity existence.
|
||||
const initialPersons: PersonOption[] =
|
||||
personResult && personResult.response.ok && personResult.data ? [personResult.data] : [];
|
||||
const initialDocuments: DocumentOption[] =
|
||||
documentResult && documentResult.response.ok && documentResult.data
|
||||
? [
|
||||
{
|
||||
id: documentResult.data.id,
|
||||
title: documentResult.data.title,
|
||||
documentDate: documentResult.data.documentDate,
|
||||
metaDatePrecision: documentResult.data.metaDatePrecision,
|
||||
metaDateEnd: documentResult.data.metaDateEnd
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
return { initialPersons, initialDocuments, originPersonId: personId ?? '' };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
save: async ({ request, fetch }: { request: Request; fetch: typeof globalThis.fetch }) => {
|
||||
const parsed = parseEventForm(await request.formData());
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const errors = validateEventForm(parsed);
|
||||
if (errors) {
|
||||
const { persons, documents } = await lookupSelections(
|
||||
api,
|
||||
parsed.personIds,
|
||||
parsed.documentIds
|
||||
);
|
||||
return fail(400, { ...errors, ...preservedFormFields(parsed), persons, documents });
|
||||
}
|
||||
|
||||
const result = await api.POST('/api/timeline/events', { body: toEventRequest(parsed) });
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(result.error)),
|
||||
...preservedFormFields(parsed)
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect(303, resolveNavTarget(parsed.originPersonId));
|
||||
}
|
||||
};
|
||||
13
frontend/src/routes/zeitstrahl/events/new/+page.svelte
Normal file
13
frontend/src/routes/zeitstrahl/events/new/+page.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import EventForm from '$lib/timeline/EventForm.svelte';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<EventForm
|
||||
initialPersons={data.initialPersons}
|
||||
initialDocuments={data.initialDocuments}
|
||||
originPersonId={data.originPersonId}
|
||||
form={form}
|
||||
/>
|
||||
246
frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts
Normal file
246
frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
import { load, actions } from './+page.server';
|
||||
|
||||
const mockFetch = vi.fn() as unknown as typeof fetch;
|
||||
|
||||
// fail() returns a union type that TS won't narrow; read its data loosely.
|
||||
const failData = (r: unknown) => (r as { data: Record<string, unknown> }).data;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function localsWith(perms: string[] | null) {
|
||||
if (perms === null) return { user: null };
|
||||
return { user: { groups: [{ permissions: perms }] } };
|
||||
}
|
||||
|
||||
function loadEvent(perms: string[] | null, search = '') {
|
||||
const url = new URL(`http://localhost/zeitstrahl/events/new${search}`);
|
||||
return {
|
||||
locals: localsWith(perms),
|
||||
url,
|
||||
fetch: mockFetch,
|
||||
request: new Request(url),
|
||||
route: { id: '/zeitstrahl/events/new' },
|
||||
params: {}
|
||||
} as never;
|
||||
}
|
||||
|
||||
function saveRequest(fields: Record<string, string | string[]>): Request {
|
||||
const fd = new FormData();
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (Array.isArray(v)) v.forEach((x) => fd.append(k, x));
|
||||
else fd.set(k, v);
|
||||
}
|
||||
return new Request('http://localhost/zeitstrahl/events/new', { method: 'POST', body: fd });
|
||||
}
|
||||
|
||||
function saveEvent(fields: Record<string, string | string[]>) {
|
||||
return {
|
||||
request: saveRequest(fields),
|
||||
fetch: mockFetch,
|
||||
route: { id: '/zeitstrahl/events/new' },
|
||||
params: {}
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe('zeitstrahl/events/new load — gating (REQ-002/003)', () => {
|
||||
it('throws 403 for an unauthenticated (null) user', async () => {
|
||||
await expect(load(loadEvent(null))).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('throws 403 for an authenticated user without WRITE_ALL', async () => {
|
||||
await expect(load(loadEvent(['READ_ALL']))).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('allows a curator with WRITE_ALL', async () => {
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: vi.fn() } as never);
|
||||
const result = await load(loadEvent(['WRITE_ALL']));
|
||||
expect(result.initialPersons).toEqual([]);
|
||||
expect(result.initialDocuments).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('zeitstrahl/events/new load — prefill (REQ-014)', () => {
|
||||
it('preselects a valid person and ignores an unknown document', async () => {
|
||||
const get = vi.fn((path: string) => {
|
||||
if (path === '/api/persons/{id}')
|
||||
return Promise.resolve({
|
||||
response: { ok: true },
|
||||
data: { id: 'p1', displayName: 'Anna' }
|
||||
});
|
||||
return Promise.resolve({ response: { ok: false }, data: null });
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: get } as never);
|
||||
|
||||
const result = await load(loadEvent(['WRITE_ALL'], '?personId=p1&documentId=missing'));
|
||||
expect(result.initialPersons).toHaveLength(1);
|
||||
expect(result.initialDocuments).toEqual([]);
|
||||
expect(result.originPersonId).toBe('p1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('zeitstrahl/events/new save action (REQ-004/009/010/015)', () => {
|
||||
const validUuid = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
it('posts a TimelineEventRequest and redirects on success', async () => {
|
||||
const post = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
|
||||
|
||||
await expect(
|
||||
actions.save(saveEvent({ title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01' }))
|
||||
).rejects.toMatchObject({ status: 303, location: '/zeitstrahl' });
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
expect(post.mock.calls[0][1].body).toMatchObject({
|
||||
title: 'Umzug',
|
||||
type: 'PERSONAL',
|
||||
eventDate: '1925-04-01'
|
||||
});
|
||||
});
|
||||
|
||||
it('sends eventDateEnd: null when precision is not RANGE', async () => {
|
||||
const post = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
|
||||
|
||||
await expect(
|
||||
actions.save(
|
||||
saveEvent({
|
||||
title: 'Umzug',
|
||||
type: 'PERSONAL',
|
||||
eventDate: '1925-04-01',
|
||||
precision: 'YEAR',
|
||||
eventDateEnd: '1925-05-01'
|
||||
})
|
||||
)
|
||||
).rejects.toMatchObject({ status: 303 });
|
||||
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);
|
||||
|
||||
await expect(
|
||||
actions.save(
|
||||
saveEvent({
|
||||
title: 'Umzug',
|
||||
type: 'PERSONAL',
|
||||
eventDate: '1925-04-01',
|
||||
precision: 'NOT_A_REAL_PRECISION'
|
||||
})
|
||||
)
|
||||
).rejects.toMatchObject({ status: 303 });
|
||||
expect(post.mock.calls[0][1].body.precision).toBe('DAY');
|
||||
});
|
||||
|
||||
it('returns fail(400) with preserved + rehydrated pickers on blank title', async () => {
|
||||
const post = vi.fn();
|
||||
// On validation failure the action re-fetches the selected persons/documents
|
||||
// by id so the fail payload can rebuild full chips (Decision 6 / REQ-010).
|
||||
const get = vi.fn((path: string, opts: { params: { path: { id: string } } }) => {
|
||||
const id = opts.params.path.id;
|
||||
if (path === '/api/persons/{id}')
|
||||
return Promise.resolve({
|
||||
response: { ok: true },
|
||||
data: { id, displayName: `Person ${id}` }
|
||||
});
|
||||
return Promise.resolve({
|
||||
response: { ok: true },
|
||||
data: { id, title: `Doc ${id}`, documentDate: '1925-04-01' }
|
||||
});
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ POST: post, GET: get } as never);
|
||||
|
||||
const result = await actions.save(
|
||||
saveEvent({
|
||||
title: ' ',
|
||||
type: 'PERSONAL',
|
||||
eventDate: '1925-04-01',
|
||||
personIds: ['p1', 'p2'],
|
||||
documentIds: ['d1']
|
||||
})
|
||||
);
|
||||
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
expect(failData(result).personIds).toEqual(['p1', 'p2']);
|
||||
expect(failData(result).documentIds).toEqual(['d1']);
|
||||
expect(failData(result).titleError).toBeTruthy();
|
||||
// Rehydrated chips carry labels, not just ids.
|
||||
expect(failData(result).persons).toEqual([
|
||||
{ id: 'p1', displayName: 'Person p1' },
|
||||
{ id: 'p2', displayName: 'Person p2' }
|
||||
]);
|
||||
expect((failData(result).documents as { id: string }[])[0].id).toBe('d1');
|
||||
});
|
||||
|
||||
it('surfaces both title and date errors when both blank (REQ-011)', async () => {
|
||||
vi.mocked(createApiClient).mockReturnValue({ POST: vi.fn() } as never);
|
||||
const result = await actions.save(saveEvent({ title: '', type: 'PERSONAL', eventDate: '' }));
|
||||
expect(failData(result).titleError).toBeTruthy();
|
||||
expect(failData(result).dateError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('redirects to /persons/{id} when originPersonId is a valid UUID', async () => {
|
||||
const post = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
|
||||
|
||||
await expect(
|
||||
actions.save(
|
||||
saveEvent({
|
||||
title: 'Umzug',
|
||||
type: 'PERSONAL',
|
||||
eventDate: '1925-04-01',
|
||||
originPersonId: validUuid
|
||||
})
|
||||
)
|
||||
).rejects.toMatchObject({ status: 303, location: `/persons/${validUuid}` });
|
||||
});
|
||||
|
||||
it('defaults to /zeitstrahl when originPersonId is not a valid UUID (REQ-015)', async () => {
|
||||
const post = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
|
||||
|
||||
await expect(
|
||||
actions.save(
|
||||
saveEvent({
|
||||
title: 'Umzug',
|
||||
type: 'PERSONAL',
|
||||
eventDate: '1925-04-01',
|
||||
originPersonId: '../evil'
|
||||
})
|
||||
)
|
||||
).rejects.toMatchObject({ status: 303, location: '/zeitstrahl' });
|
||||
});
|
||||
|
||||
it('maps the API error and does not redirect on a non-ok save (incl. 409)', async () => {
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
response: { ok: false, status: 409 },
|
||||
error: { code: 'CONFLICT' }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ POST: post } as never);
|
||||
|
||||
const result = await actions.save(
|
||||
saveEvent({ title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01' })
|
||||
);
|
||||
expect(result).toMatchObject({ status: 409 });
|
||||
expect(failData(result).error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user