From 491d1a015a81fd081f72d1fb7e31ae7e1387205b Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 14 Jun 2026 19:24:24 +0200 Subject: [PATCH] feat(relationship): date+precision edit UI, notes, and read-view display Regenerate api.ts for the LocalDate+DatePrecision RelationshipDTO / RelationshipUpsertRequest and the new PUT, then migrate every caller: - RelationshipDateField (mirrors PersonLifeDateField: DAY/MONTH/YEAR, 44px targets, labelled, semantic dark-mode tokens, relation_* i18n keys). - AddRelationshipForm is now upsert-capable: an optional `relationship` prop pre-fills type, person, both dates+precision and notes; posts to ?/updateRelationship (else ?/addRelationship); the submit control disables and shows a progress spinner while a request is in flight (REQ-019); notes textarea (<=2000). - RelationshipChip gains an accessible Edit affordance (canWrite + onEdit); StammbaumCard wires it, formats the date range via formatRelationshipDateRange, and sorts by fromDate. PersonRelationshipsCard (read view) shows the date range and notes; no dates -> no date line. - persons/[id]/edit/+page.server.ts: updateRelationship action (PUT) + the addRelationship action reshaped to date+precision+notes (empty date omits precision for coherence). - Genealogy callers fixed for the dropped year fields: familyForest spouse-order and StammbaumConnectors ended-edge dashing now key off fromDate/toDate. - i18n relation_* form keys in de/en/es. REQ-004, REQ-014, REQ-015, REQ-016, REQ-019 Refs #837 Co-Authored-By: Claude Opus 4.8 --- frontend/messages/de.json | 10 + frontend/messages/en.json | 10 + frontend/messages/es.json | 10 + frontend/src/lib/generated/api.ts | 183 +++++++++++------- .../lib/person/PersonHoverCard.svelte.spec.ts | 20 +- .../lib/person/genealogy/StammbaumCard.svelte | 35 ++-- .../genealogy/StammbaumCard.svelte.test.ts | 28 ++- .../genealogy/StammbaumConnectors.svelte | 2 +- .../StammbaumConnectors.svelte.test.ts | 8 +- .../genealogy/StammbaumSidePanel.svelte | 13 +- .../StammbaumSidePanel.svelte.spec.ts | 12 +- .../genealogy/StammbaumTree.svelte.test.ts | 119 +++++++++--- .../genealogy/layout/buildLayout.test.ts | 15 +- .../genealogy/layout/familyForest.test.ts | 8 +- .../person/genealogy/layout/familyForest.ts | 5 +- .../genealogy/layout/highlightLineage.test.ts | 12 +- .../relationship/AddRelationshipForm.svelte | 162 ++++++++++------ .../AddRelationshipForm.svelte.spec.ts | 126 ++++++++---- .../AddRelationshipForm.svelte.test.ts | 51 ----- .../relationship/RelationshipChip.svelte | 32 ++- .../RelationshipChip.svelte.spec.ts | 45 ++++- .../relationship/RelationshipDateField.svelte | 97 ++++++++++ .../src/lib/person/relationshipLabels.test.ts | 2 + .../[id]/PersonRelationshipsCard.svelte | 44 +++-- .../PersonRelationshipsCard.svelte.test.ts | 86 +++++++- .../routes/persons/[id]/edit/+page.server.ts | 83 +++++--- .../persons/[id]/edit/page.server.spec.ts | 95 +++++++++ 27 files changed, 960 insertions(+), 353 deletions(-) create mode 100644 frontend/src/lib/person/relationship/RelationshipDateField.svelte diff --git a/frontend/messages/de.json b/frontend/messages/de.json index e09aa9ac..492c3ac0 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1221,6 +1221,16 @@ "relation_form_field_from_year": "Von Jahr", "relation_form_field_to_year": "Bis Jahr", "relation_form_year_placeholder": "z.B. 1920", + "relation_label_from_date": "Beginn (Datum)", + "relation_label_to_date": "Ende (Datum)", + "relation_label_date_precision": "Genauigkeit", + "relation_precision_day": "Genaues Datum (Tag)", + "relation_precision_month": "Monat bekannt", + "relation_precision_year": "Nur Jahreszahl", + "relation_label_notes": "Notizen", + "relation_notes_placeholder": "Optionaler Hinweis zu dieser Beziehung", + "relation_date_placeholder_hint": "Leer lassen, wenn unbekannt", + "relation_edit": "Beziehung bearbeiten", "person_relationships_heading": "Beziehungen", "person_relationships_empty": "Noch keine Beziehungen bekannt.", "timeline_aria_label": "Zeitachse Dokumentdichte", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 3f8a4132..f1b04bf2 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1221,6 +1221,16 @@ "relation_form_field_from_year": "From year", "relation_form_field_to_year": "To year", "relation_form_year_placeholder": "e.g. 1920", + "relation_label_from_date": "Start date", + "relation_label_to_date": "End date", + "relation_label_date_precision": "Precision", + "relation_precision_day": "Exact date (day)", + "relation_precision_month": "Month known", + "relation_precision_year": "Year only", + "relation_label_notes": "Notes", + "relation_notes_placeholder": "Optional note about this relationship", + "relation_date_placeholder_hint": "Leave empty if unknown", + "relation_edit": "Edit relationship", "person_relationships_heading": "Relationships", "person_relationships_empty": "No relationships known yet.", "timeline_aria_label": "Document density timeline", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 24df3c02..d495201e 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1221,6 +1221,16 @@ "relation_form_field_from_year": "Desde año", "relation_form_field_to_year": "Hasta año", "relation_form_year_placeholder": "ej. 1920", + "relation_label_from_date": "Fecha de inicio", + "relation_label_to_date": "Fecha de fin", + "relation_label_date_precision": "Precisión", + "relation_precision_day": "Fecha exacta (día)", + "relation_precision_month": "Mes conocido", + "relation_precision_year": "Solo año", + "relation_label_notes": "Notas", + "relation_notes_placeholder": "Nota opcional sobre esta relación", + "relation_date_placeholder_hint": "Dejar vacío si es desconocido", + "relation_edit": "Editar relación", "person_relationships_heading": "Relaciones", "person_relationships_empty": "Aún no se conocen relaciones.", "timeline_aria_label": "Cronología de densidad de documentos", diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index f2fc43f5..7241b00c 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -100,6 +100,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/persons/{id}/relationships/{relId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateRelationship"]; + post?: never; + delete: operations["deleteRelationship"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/geschichten/{id}/items/reorder": { parameters: { query?: never; @@ -1640,22 +1656,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/persons/{id}/relationships/{relId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete: operations["deleteRelationship"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/persons/{id}/aliases/{aliasId}": { parameters: { query?: never; @@ -1853,6 +1853,50 @@ export interface components { provisional: boolean; readonly displayName: string; }; + RelationshipUpsertRequest: { + /** Format: uuid */ + relatedPersonId: string; + /** @enum {string} */ + relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER"; + /** Format: date */ + fromDate?: string; + /** @enum {string} */ + fromDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + /** Format: date */ + toDate?: string; + /** @enum {string} */ + toDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + notes?: string; + }; + RelationshipDTO: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + personId: string; + /** Format: uuid */ + relatedPersonId: string; + personDisplayName: string; + /** Format: int32 */ + personBirthYear?: number; + /** Format: int32 */ + personDeathYear?: number; + relatedPersonDisplayName: string; + /** Format: int32 */ + relatedPersonBirthYear?: number; + /** Format: int32 */ + relatedPersonDeathYear?: number; + /** @enum {string} */ + relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER"; + /** Format: date */ + fromDate?: string; + /** @enum {string} */ + fromDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + /** Format: date */ + toDate?: string; + /** @enum {string} */ + toDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + notes?: string; + }; JourneyReorderDTO: { itemIds?: string[]; }; @@ -2008,42 +2052,6 @@ export interface components { /** Format: uuid */ targetId: string; }; - CreateRelationshipRequest: { - /** Format: uuid */ - relatedPersonId: string; - /** @enum {string} */ - relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER"; - /** Format: int32 */ - fromYear?: number; - /** Format: int32 */ - toYear?: number; - notes?: string; - }; - RelationshipDTO: { - /** Format: uuid */ - id: string; - /** Format: uuid */ - personId: string; - /** Format: uuid */ - relatedPersonId: string; - personDisplayName: string; - /** Format: int32 */ - personBirthYear?: number; - /** Format: int32 */ - personDeathYear?: number; - relatedPersonDisplayName: string; - /** Format: int32 */ - relatedPersonBirthYear?: number; - /** Format: int32 */ - relatedPersonDeathYear?: number; - /** @enum {string} */ - relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER"; - /** Format: int32 */ - fromYear?: number; - /** Format: int32 */ - toYear?: number; - notes?: string; - }; PersonNameAliasDTO: { lastName: string; firstName?: string; @@ -3196,6 +3204,54 @@ export interface operations { }; }; }; + updateRelationship: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + relId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RelationshipUpsertRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["RelationshipDTO"]; + }; + }; + }; + }; + deleteRelationship: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + relId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; reorderItems: { parameters: { query?: never; @@ -3659,7 +3715,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreateRelationshipRequest"]; + "application/json": components["schemas"]["RelationshipUpsertRequest"]; }; }; responses: { @@ -5905,27 +5961,6 @@ export interface operations { }; }; }; - deleteRelationship: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - relId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; removeAlias: { parameters: { query?: never; diff --git a/frontend/src/lib/person/PersonHoverCard.svelte.spec.ts b/frontend/src/lib/person/PersonHoverCard.svelte.spec.ts index 2ee2fb6e..b6a295ba 100644 --- a/frontend/src/lib/person/PersonHoverCard.svelte.spec.ts +++ b/frontend/src/lib/person/PersonHoverCard.svelte.spec.ts @@ -193,7 +193,9 @@ describe('PersonHoverCard — loaded state', () => { relatedPersonId: 'p-spouse', personDisplayName: 'Auguste', relatedPersonDisplayName: 'Otto Raddatz', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'r2', @@ -201,7 +203,9 @@ describe('PersonHoverCard — loaded state', () => { relatedPersonId: 'p-friend', personDisplayName: 'Auguste', relatedPersonDisplayName: 'Karl Friend', - relationType: 'FRIEND' + relationType: 'FRIEND', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'r3', @@ -209,7 +213,9 @@ describe('PersonHoverCard — loaded state', () => { relatedPersonId: 'p-sibling', personDisplayName: 'Auguste', relatedPersonDisplayName: 'Marie Sister', - relationType: 'SIBLING_OF' + relationType: 'SIBLING_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ]; render(PersonHoverCard, { @@ -235,7 +241,9 @@ describe('PersonHoverCard — loaded state', () => { relatedPersonId: 'p-aug', personDisplayName: 'Heinrich Raddatz', relatedPersonDisplayName: 'Auguste Raddatz', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ]; render(PersonHoverCard, { @@ -258,7 +266,9 @@ describe('PersonHoverCard — loaded state', () => { relatedPersonId: 'p-friend', personDisplayName: 'Auguste', relatedPersonDisplayName: 'Karl Friend', - relationType: 'FRIEND' + relationType: 'FRIEND', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ]; render(PersonHoverCard, { diff --git a/frontend/src/lib/person/genealogy/StammbaumCard.svelte b/frontend/src/lib/person/genealogy/StammbaumCard.svelte index 02542f39..07e350f8 100644 --- a/frontend/src/lib/person/genealogy/StammbaumCard.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumCard.svelte @@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js'; import RelationshipChip from '$lib/person/relationship/RelationshipChip.svelte'; import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte'; import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels'; +import { formatRelationshipDateRange } from '$lib/person/relationshipDates'; import type { components } from '$lib/generated/api'; type RelationshipDTO = components['schemas']['RelationshipDTO']; @@ -29,13 +30,15 @@ let { type RelationType = NonNullable; -const sortedDirect = $derived([...relationships].sort(byTypeThenYear)); +const sortedDirect = $derived([...relationships].sort(byTypeThenDate)); const topDerived = $derived(inferredRelationships.slice(0, 5)); +let editingRelId = $state(null); -function byTypeThenYear(a: RelationshipDTO, b: RelationshipDTO): number { +function byTypeThenDate(a: RelationshipDTO, b: RelationshipDTO): number { const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType); if (order !== 0) return order; - return (a.fromYear ?? 0) - (b.fromYear ?? 0); + // ISO dates sort lexicographically == chronologically; a missing date sorts first. + return (a.fromDate ?? '').localeCompare(b.fromDate ?? ''); } function relationTypeOrder(t: RelationType | undefined): number { @@ -53,13 +56,13 @@ function relationTypeOrder(t: RelationType | undefined): number { return order[t ?? 'OTHER'] ?? 99; } -function yearRange(rel: RelationshipDTO): string { - const from = rel.fromYear; - const to = rel.toYear; - if (from && to) return `${from}–${to}`; - if (from) return m.relation_year_from({ year: from }); - if (to) return m.relation_year_to({ year: to }); - return ''; +function dateRangeOf(rel: RelationshipDTO): string { + return formatRelationshipDateRange( + rel.fromDate, + rel.fromDatePrecision, + rel.toDate, + rel.toDatePrecision + ); } @@ -132,10 +135,20 @@ function yearRange(rel: RelationshipDTO): string { (editingRelId = rel.id) : undefined} /> + {#if editingRelId === rel.id} +
  • + (editingRelId = null)} + /> +
  • + {/if} {/each} {/if} diff --git a/frontend/src/lib/person/genealogy/StammbaumCard.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumCard.svelte.test.ts index 2769ae25..54e5e2d8 100644 --- a/frontend/src/lib/person/genealogy/StammbaumCard.svelte.test.ts +++ b/frontend/src/lib/person/genealogy/StammbaumCard.svelte.test.ts @@ -111,17 +111,21 @@ describe('StammbaumCard', () => { expect(items.length).toBeGreaterThanOrEqual(2); }); - it('renders the year range "from–to" for a relationship with both years', async () => { + it('renders the date range "from – to" for a relationship with both dates', async () => { render(StammbaumCard, { props: baseProps({ relationships: [ { id: 'r-1', + personId: 'p-1', + relatedPersonId: 'p-x', + personDisplayName: 'Anna', + relatedPersonDisplayName: 'Xavier', relationType: 'COLLEAGUE', - fromYear: 1940, - toYear: 1945, - personA: { id: 'p-1', displayName: 'Anna' }, - personB: { id: 'p-x', displayName: 'Xavier' } + fromDate: '1940-01-01', + fromDatePrecision: 'YEAR', + toDate: '1945-01-01', + toDatePrecision: 'YEAR' } ] }) @@ -131,23 +135,27 @@ describe('StammbaumCard', () => { expect(document.body.textContent).toContain('1945'); }); - it('renders only "fromYear" for a relationship with no end year', async () => { + it('renders only the start date for a relationship with no end date', async () => { render(StammbaumCard, { props: baseProps({ relationships: [ { id: 'r-2', + personId: 'p-1', + relatedPersonId: 'p-y', + personDisplayName: 'Anna', + relatedPersonDisplayName: 'Yvonne', relationType: 'NEIGHBOR', - fromYear: 1935, - personA: { id: 'p-1', displayName: 'Anna' }, - personB: { id: 'p-y', displayName: 'Yvonne' } + fromDate: '1935-01-01', + fromDatePrecision: 'YEAR', + toDatePrecision: 'UNKNOWN' } ] }) }); expect(document.body.textContent).toContain('1935'); - expect(document.body.textContent).not.toContain('1935–'); + expect(document.body.textContent).not.toContain('1935 –'); }); it('renders the inferred-relationships disclosure when topDerived has items', async () => { diff --git a/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte b/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte index d60c85fd..9e37dbe1 100644 --- a/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte @@ -250,7 +250,7 @@ const parentLinks = $derived.by(() => { y2={bCenter.y} stroke="var(--c-primary)" stroke-width="1.5" - stroke-dasharray={e.toYear ? '4 4' : undefined} + stroke-dasharray={e.toDate ? '4 4' : undefined} /> = { + const body: Record = { relatedPersonId: data.relatedPersonId, relationType: data.relationType }; - if (data.fromYear !== undefined) body.fromYear = data.fromYear; - if (data.toYear !== undefined) body.toYear = data.toYear; + if (data.fromDate) { + body.fromDate = data.fromDate; + if (data.fromDatePrecision) body.fromDatePrecision = data.fromDatePrecision; + } + if (data.toDate) { + body.toDate = data.toDate; + if (data.toDatePrecision) body.toDatePrecision = data.toDatePrecision; + } + if (data.notes) body.notes = data.notes; const res = await csrfFetch(`/api/persons/${node.id}/relationships`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts index 142ccce2..fa991b91 100644 --- a/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts +++ b/frontend/src/lib/person/genealogy/StammbaumSidePanel.svelte.spec.ts @@ -72,20 +72,20 @@ describe('StammbaumSidePanel', () => { await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument(); }); - it('year inputs inside the add form have label elements (canWrite=true)', async () => { + it('date inputs inside the add form have accessible labels (canWrite=true)', async () => { render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: true }); await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument(); const addBtn = [...document.querySelectorAll('button')].find((b) => /Beziehung hinzufügen/i.test(b.textContent ?? '') ); addBtn!.click(); - await expect.element(page.getByRole('combobox')).toBeInTheDocument(); - const yearInputs = [...document.querySelectorAll('input')].filter( + await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument(); + const dateInputs = [...document.querySelectorAll('input')].filter( (i) => i.inputMode === 'numeric' ); - expect(yearInputs.length).toBeGreaterThan(0); - for (const input of yearInputs) { - expect(input.closest('label')).not.toBeNull(); + expect(dateInputs.length).toBeGreaterThan(0); + for (const input of dateInputs) { + expect(input.getAttribute('aria-label')).toBeTruthy(); } }); diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts index 409459ea..1df0305a 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts @@ -3,6 +3,9 @@ import { render } from 'vitest-browser-svelte'; import StammbaumTree from './StammbaumTree.svelte'; import type { PanZoomState } from './panZoom'; import { DIMMED_OPACITY } from './layout/highlightLineage'; +import type { components } from '$lib/generated/api'; + +type RelationshipDTO = components['schemas']['RelationshipDTO']; const ID_A = '00000000-0000-0000-0000-000000000001'; const ID_B = '00000000-0000-0000-0000-000000000002'; @@ -105,7 +108,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: PARENT_B, personDisplayName: 'Walter', relatedPersonDisplayName: 'Eugenie', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p1a', @@ -113,7 +118,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: CHILD_1, personDisplayName: 'Walter', relatedPersonDisplayName: 'Clara', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p1b', @@ -121,7 +128,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: CHILD_1, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Clara', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p2a', @@ -129,7 +138,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: CHILD_2, personDisplayName: 'Walter', relatedPersonDisplayName: 'Hans', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p2b', @@ -137,7 +148,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: CHILD_2, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Hans', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ], selectedId: null, @@ -181,7 +194,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: PARENT_B, personDisplayName: 'Walter', relatedPersonDisplayName: 'Eugenie', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p1', @@ -189,7 +204,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: CHILD, personDisplayName: 'Walter', relatedPersonDisplayName: 'Hans', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p2', @@ -197,7 +214,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: CHILD, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Hans', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ], selectedId: null, @@ -244,7 +263,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: EUGENIE, personDisplayName: 'Walter', relatedPersonDisplayName: 'Eugenie', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p1', @@ -252,7 +273,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: HANS, personDisplayName: 'Walter', relatedPersonDisplayName: 'Hans', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p2', @@ -260,7 +283,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: HANS, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Hans', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p3', @@ -268,7 +293,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: CLARA, personDisplayName: 'Walter', relatedPersonDisplayName: 'Clara', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p4', @@ -276,7 +303,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: CLARA, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Clara', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 's2', @@ -284,7 +313,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: HILDE, personDisplayName: 'Hans', relatedPersonDisplayName: 'Hilde', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p5', @@ -292,7 +323,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: LILI, personDisplayName: 'Hans', relatedPersonDisplayName: 'Lili', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p6', @@ -300,7 +333,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: LILI, personDisplayName: 'Hilde', relatedPersonDisplayName: 'Lili', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ], selectedId: null, @@ -358,7 +393,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: ID_B, personDisplayName: 'Anna', relatedPersonDisplayName: 'Bertha', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ], selectedId: null, @@ -391,7 +428,9 @@ describe('StammbaumTree viewBox', () => { relatedPersonId: ID_B, personDisplayName: 'Anna', relatedPersonDisplayName: 'Bertha', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ], selectedId: null, @@ -599,7 +638,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => { relatedPersonId: ID_B, personDisplayName: 'Anna', relatedPersonDisplayName: 'Bertha', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ], selectedId: null, @@ -668,7 +709,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => { personDisplayName: 'Anna', relatedPersonDisplayName: 'Bertha', relationType: 'SPOUSE_OF', - toYear: 1925 + fromDatePrecision: 'UNKNOWN', + toDate: '1925-01-01', + toDatePrecision: 'YEAR' } ], selectedId: null, @@ -695,7 +738,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => { relatedPersonId: ID_B, personDisplayName: 'Anna', relatedPersonDisplayName: 'Bertha', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ], selectedId: null, @@ -723,7 +768,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => { relatedPersonId: CHILD, personDisplayName: 'Parent', relatedPersonDisplayName: 'Child', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ], selectedId: null, @@ -908,6 +955,8 @@ describe('StammbaumTree lineage highlight (#703)', () => { personDisplayName: string; relatedPersonDisplayName: string; relationType: 'PARENT_OF' | 'SPOUSE_OF'; + fromDatePrecision: 'UNKNOWN'; + toDatePrecision: 'UNKNOWN'; }; const edge = ( personId: string, @@ -919,7 +968,9 @@ describe('StammbaumTree lineage highlight (#703)', () => { relatedPersonId, personDisplayName: '', relatedPersonDisplayName: '', - relationType + relationType, + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }); const NODES = [ @@ -1104,14 +1155,16 @@ describe('StammbaumTree keyboard tab order (#718)', () => { // year, then a deterministic id tie-break), not alphabetically — with no birth // years here Walter (id …a1) owns the run and Eugenie sits to his right. So the // deterministic visual order is Walter, Eugenie (top row) then Clara, Hans. - const FAMILY_EDGES = [ + const FAMILY_EDGES: RelationshipDTO[] = [ { id: 'sp', personId: WALTER, relatedPersonId: EUGENIE, personDisplayName: 'Walter', relatedPersonDisplayName: 'Eugenie', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p1', @@ -1119,7 +1172,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => { relatedPersonId: CLARA, personDisplayName: 'Walter', relatedPersonDisplayName: 'Clara', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p2', @@ -1127,7 +1182,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => { relatedPersonId: CLARA, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Clara', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p3', @@ -1135,7 +1192,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => { relatedPersonId: HANS, personDisplayName: 'Walter', relatedPersonDisplayName: 'Hans', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }, { id: 'p4', @@ -1143,7 +1202,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => { relatedPersonId: HANS, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Hans', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' } ]; diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts index d03da269..ac5d9185 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts @@ -42,7 +42,9 @@ function parentEdge(parentId: string, childId: string, id = parentId + childId): relatedPersonId: childId, personDisplayName: '', relatedPersonDisplayName: '', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }; } @@ -53,7 +55,9 @@ function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO { relatedPersonId: b, personDisplayName: '', relatedPersonDisplayName: '', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }; } @@ -220,7 +224,10 @@ describe('buildLayout — multi-spouse ordering (#361)', () => { fromYear: number | undefined, id = a + b ): RelationshipDTO { - return { ...spouseEdge(a, b, id), fromYear }; + return { + ...spouseEdge(a, b, id), + ...(fromYear != null ? { fromDate: `${fromYear}-01-01`, fromDatePrecision: 'YEAR' } : {}) + }; } it('multi_spouses_ordered_by_fromYear_then_displayName', () => { @@ -329,7 +336,7 @@ describe('buildLayout — multi-spouse ordering (#361)', () => { // fail fast instead so the maintainer either updates the test or // splits into a year-branch / name-branch pair. const spouseEdgesWithYear = fixtureEdges.filter( - (e) => e.relationType === 'SPOUSE_OF' && e.fromYear != null + (e) => e.relationType === 'SPOUSE_OF' && e.fromDate != null ); expect( spouseEdgesWithYear, diff --git a/frontend/src/lib/person/genealogy/layout/familyForest.test.ts b/frontend/src/lib/person/genealogy/layout/familyForest.test.ts index 2a1ace57..5aa04dfb 100644 --- a/frontend/src/lib/person/genealogy/layout/familyForest.test.ts +++ b/frontend/src/lib/person/genealogy/layout/familyForest.test.ts @@ -21,7 +21,9 @@ function parent(p: string, c: string): RelationshipDTO { relatedPersonId: c, personDisplayName: '', relatedPersonDisplayName: '', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }; } @@ -33,7 +35,9 @@ function spouse(a: string, b: string, fromYear?: number): RelationshipDTO { personDisplayName: '', relatedPersonDisplayName: '', relationType: 'SPOUSE_OF', - ...(fromYear != null ? { fromYear } : {}) + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN', + ...(fromYear != null ? { fromDate: `${fromYear}-01-01`, fromDatePrecision: 'YEAR' } : {}) }; } diff --git a/frontend/src/lib/person/genealogy/layout/familyForest.ts b/frontend/src/lib/person/genealogy/layout/familyForest.ts index 0663ebd1..6d1f94e2 100644 --- a/frontend/src/lib/person/genealogy/layout/familyForest.ts +++ b/frontend/src/lib/person/genealogy/layout/familyForest.ts @@ -82,7 +82,10 @@ export function buildFamilyForest(nodes: PersonNodeDTO[], edges: RelationshipDTO } else if (e.relationType === 'SPOUSE_OF') { addToSet(spouses, e.personId, e.relatedPersonId); addToSet(spouses, e.relatedPersonId, e.personId); - spouseYear.set(pairKey(e.personId, e.relatedPersonId), e.fromYear ?? undefined); + spouseYear.set( + pairKey(e.personId, e.relatedPersonId), + e.fromDate ? Number(e.fromDate.slice(0, 4)) : undefined + ); } } diff --git a/frontend/src/lib/person/genealogy/layout/highlightLineage.test.ts b/frontend/src/lib/person/genealogy/layout/highlightLineage.test.ts index 403be550..f9c5301f 100644 --- a/frontend/src/lib/person/genealogy/layout/highlightLineage.test.ts +++ b/frontend/src/lib/person/genealogy/layout/highlightLineage.test.ts @@ -13,7 +13,9 @@ function parentEdge(parentId: string, childId: string, id = parentId + childId): relatedPersonId: childId, personDisplayName: '', relatedPersonDisplayName: '', - relationType: 'PARENT_OF' + relationType: 'PARENT_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }; } @@ -24,7 +26,9 @@ function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO { relatedPersonId: b, personDisplayName: '', relatedPersonDisplayName: '', - relationType: 'SPOUSE_OF' + relationType: 'SPOUSE_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }; } @@ -35,7 +39,9 @@ function siblingEdge(a: string, b: string, id = a + b): RelationshipDTO { relatedPersonId: b, personDisplayName: '', relatedPersonDisplayName: '', - relationType: 'SIBLING_OF' + relationType: 'SIBLING_OF', + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN' }; } diff --git a/frontend/src/lib/person/relationship/AddRelationshipForm.svelte b/frontend/src/lib/person/relationship/AddRelationshipForm.svelte index 07fe0e47..78c9451c 100644 --- a/frontend/src/lib/person/relationship/AddRelationshipForm.svelte +++ b/frontend/src/lib/person/relationship/AddRelationshipForm.svelte @@ -1,8 +1,11 @@ @@ -113,39 +141,32 @@ async function handleCallbackSubmit(event: Event) { compact /> - - +
    + + +
    + {#if selfError} {/if} @@ -162,10 +183,18 @@ async function handleCallbackSubmit(event: Event) { {/snippet} @@ -185,18 +214,27 @@ async function handleCallbackSubmit(event: Event) { {:else}
    { + submitting = true; return async ({ result, update }) => { await update(); + submitting = false; if (result.type === 'success') { - open = false; - reset(); + if (isEdit) { + onClose?.(); + } else { + open = false; + reset(); + } } }; }} class="mt-3 rounded-sm border border-line bg-muted/40 p-3" > + {#if relationship} + + {/if} {@render formFields()}
    {/if} diff --git a/frontend/src/lib/person/relationship/AddRelationshipForm.svelte.spec.ts b/frontend/src/lib/person/relationship/AddRelationshipForm.svelte.spec.ts index da9be13d..8e2e9feb 100644 --- a/frontend/src/lib/person/relationship/AddRelationshipForm.svelte.spec.ts +++ b/frontend/src/lib/person/relationship/AddRelationshipForm.svelte.spec.ts @@ -8,58 +8,116 @@ vi.mock('$lib/person/PersonTypeahead.svelte', () => ({ default: () => null })); afterEach(cleanup); -describe('AddRelationshipForm', () => { - it('shows add-relationship button initially and no form', async () => { - render(AddRelationshipForm, { personId: 'person-1' }); +const PID = 'person-1'; +const OTHER = 'person-2'; + +const editRel = () => ({ + id: 'rel-9', + personId: PID, + relatedPersonId: OTHER, + personDisplayName: 'Anna Müller', + relatedPersonDisplayName: 'Hans Müller', + relationType: 'SPOUSE_OF' as const, + fromDate: '1923-05-12', + fromDatePrecision: 'DAY' as const, + toDatePrecision: 'UNKNOWN' as const, + notes: 'Hochzeit in Berlin' +}); + +describe('AddRelationshipForm — create mode', () => { + it('shows the add-relationship toggle initially and no form', async () => { + render(AddRelationshipForm, { personId: PID }); await expect.element(page.getByRole('button')).toBeInTheDocument(); - await expect.element(page.getByRole('combobox')).not.toBeInTheDocument(); + expect(document.querySelector('select[name="relationType"]')).toBeNull(); }); - it('shows relationType select when add button is clicked', async () => { - render(AddRelationshipForm, { personId: 'person-1' }); + it('shows the relationType select when the add toggle is clicked', async () => { + render(AddRelationshipForm, { personId: PID }); document.querySelector('button')!.click(); - await expect.element(page.getByRole('combobox')).toBeInTheDocument(); + await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument(); }); - it('hides form and shows button when cancel is clicked', async () => { - render(AddRelationshipForm, { personId: 'person-1' }); + it('hides the form and shows the toggle again on cancel', async () => { + render(AddRelationshipForm, { personId: PID }); document.querySelector('button')!.click(); - await expect.element(page.getByRole('combobox')).toBeInTheDocument(); + await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument(); const cancelBtn = [...document.querySelectorAll('button')].find( (b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '') ); cancelBtn!.click(); - await expect.element(page.getByRole('combobox')).not.toBeInTheDocument(); + await vi.waitFor(() => + expect(document.querySelector('select[name="relationType"]')).toBeNull() + ); }); - it('submit is disabled when no person is selected', async () => { - render(AddRelationshipForm, { personId: 'person-1' }); + it('disables submit when no person is selected', async () => { + render(AddRelationshipForm, { personId: PID }); document.querySelector('button')!.click(); await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled(); }); - it('form has no server action when onSubmit prop is provided', async () => { + it('has no server action when an onSubmit prop is provided', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); - render(AddRelationshipForm, { personId: 'person-1', onSubmit }); + render(AddRelationshipForm, { personId: PID, onSubmit }); document.querySelector('button')!.click(); - await expect.element(page.getByRole('combobox')).toBeInTheDocument(); - const form = document.querySelector('form'); - expect(form?.hasAttribute('action')).toBe(false); - }); - - it('shows year-range error when toYear is before fromYear', async () => { - render(AddRelationshipForm, { personId: 'person-1' }); - document.querySelector('button')!.click(); - await expect.element(page.getByRole('combobox')).toBeInTheDocument(); - - const fromInput = document.querySelector('input[name="fromYear"]')!; - fromInput.value = '1935'; - fromInput.dispatchEvent(new InputEvent('input', { bubbles: true })); - - const toInput = document.querySelector('input[name="toYear"]')!; - toInput.value = '1920'; - toInput.dispatchEvent(new InputEvent('input', { bubbles: true })); - - await expect.element(page.getByRole('alert')).toBeVisible(); + await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument(); + expect(document.querySelector('form')?.hasAttribute('action')).toBe(false); + }); +}); + +describe('AddRelationshipForm — edit mode', () => { + it('opens pre-filled and labels the submit "Speichern"', async () => { + render(AddRelationshipForm, { personId: PID, relationship: editRel() }); + await expect.element(page.getByRole('button', { name: /^Speichern$/i })).toBeInTheDocument(); + }); + + it('pre-fills the from-date as dd.mm.yyyy', async () => { + render(AddRelationshipForm, { personId: PID, relationship: editRel() }); + await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument(); + const fromInput = document.querySelector('#fromDate')!; + await vi.waitFor(() => expect(fromInput.value).toBe('12.05.1923')); + }); + + it('round-trips the notes into the textarea', async () => { + render(AddRelationshipForm, { personId: PID, relationship: editRel() }); + await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument(); + const notes = document.querySelector('textarea[name="notes"]')!; + await vi.waitFor(() => expect(notes.value).toBe('Hochzeit in Berlin')); + }); + + it('offers only DAY/MONTH/YEAR in each precision select', async () => { + render(AddRelationshipForm, { personId: PID, relationship: editRel() }); + await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument(); + const options = [ + ...document.querySelectorAll('#fromDatePrecision option') + ].map((o) => o.value); + expect(options).toEqual(['DAY', 'MONTH', 'YEAR']); + }); + + it('gives each date input an associated label (accessible name)', async () => { + render(AddRelationshipForm, { personId: PID, relationship: editRel() }); + await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument(); + expect(document.querySelector('#fromDate')?.getAttribute('aria-label')).toBe('Beginn (Datum)'); + expect(document.querySelector('#toDate')?.getAttribute('aria-label')).toBe('Ende (Datum)'); + }); + + it('disables the submit and shows a progress spinner while a submit is in flight', async () => { + let resolve: () => void = () => {}; + const onSubmit = vi.fn(() => new Promise((r) => (resolve = r))); + render(AddRelationshipForm, { personId: PID, relationship: editRel(), onSubmit }); + + const submit = await vi.waitFor(() => { + const b = [...document.querySelectorAll('button')].find( + (x) => x.type === 'submit' + ); + if (!b) throw new Error('submit not ready'); + return b; + }); + submit.click(); + + await expect.element(page.getByTestId('submit-spinner')).toBeInTheDocument(); + await vi.waitFor(() => expect(submit.disabled).toBe(true)); + expect(onSubmit).toHaveBeenCalledOnce(); + resolve(); }); }); diff --git a/frontend/src/lib/person/relationship/AddRelationshipForm.svelte.test.ts b/frontend/src/lib/person/relationship/AddRelationshipForm.svelte.test.ts index 4e530518..974909a0 100644 --- a/frontend/src/lib/person/relationship/AddRelationshipForm.svelte.test.ts +++ b/frontend/src/lib/person/relationship/AddRelationshipForm.svelte.test.ts @@ -33,36 +33,6 @@ describe('AddRelationshipForm', () => { expect(optionValues).toContain('OTHER'); }); - it('shows the year-error alert when toYear is before fromYear', async () => { - render(AddRelationshipForm, { props: { personId: 'p-1' } }); - - await page.getByRole('button', { name: /hinzufügen/i }).click(); - - const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement; - const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement; - fromInput.value = '1923'; - fromInput.dispatchEvent(new Event('input', { bubbles: true })); - toInput.value = '1920'; - toInput.dispatchEvent(new Event('input', { bubbles: true })); - - await expect.element(page.getByText(/bis-jahr darf nicht vor von-jahr/i)).toBeVisible(); - }); - - it('does not show the year-error when toYear equals fromYear', async () => { - render(AddRelationshipForm, { props: { personId: 'p-1' } }); - - await page.getByRole('button', { name: /hinzufügen/i }).click(); - - const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement; - const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement; - fromInput.value = '1923'; - fromInput.dispatchEvent(new Event('input', { bubbles: true })); - toInput.value = '1923'; - toInput.dispatchEvent(new Event('input', { bubbles: true })); - - await expect.element(page.getByText(/bis-jahr darf nicht/i)).not.toBeInTheDocument(); - }); - it('cancel button closes the form', async () => { render(AddRelationshipForm, { props: { personId: 'p-1' } }); @@ -136,25 +106,4 @@ describe('AddRelationshipForm', () => { expect(submitBtn!.disabled).toBe(true); }); }); - - it('keeps submit disabled when there is a yearError', async () => { - render(AddRelationshipForm, { props: { personId: 'p-1' } }); - - await page.getByRole('button', { name: /hinzufügen/i }).click(); - - const fromInput = document.querySelector('input[name="fromYear"]') as HTMLInputElement; - const toInput = document.querySelector('input[name="toYear"]') as HTMLInputElement; - const relInput = document.querySelector('input[name="relatedPersonId"]') as HTMLInputElement; - fromInput.value = '1923'; - fromInput.dispatchEvent(new Event('input', { bubbles: true })); - toInput.value = '1920'; - toInput.dispatchEvent(new Event('input', { bubbles: true })); - relInput.value = 'p-other'; - relInput.dispatchEvent(new Event('input', { bubbles: true })); - - await vi.waitFor(() => { - const submitBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement; - expect(submitBtn.disabled).toBe(true); - }); - }); }); diff --git a/frontend/src/lib/person/relationship/RelationshipChip.svelte b/frontend/src/lib/person/relationship/RelationshipChip.svelte index 852b0aaa..0bc05c0d 100644 --- a/frontend/src/lib/person/relationship/RelationshipChip.svelte +++ b/frontend/src/lib/person/relationship/RelationshipChip.svelte @@ -5,12 +5,13 @@ import { m } from '$lib/paraglide/messages.js'; interface Props { chipLabel: string; otherName: string; - yearRange?: string; + dateRange?: string; canWrite: boolean; relId: string; + onEdit?: () => void; } -let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props(); +let { chipLabel, otherName, dateRange = '', canWrite, relId, onEdit }: Props = $props();
  • @@ -22,8 +23,31 @@ let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props(); {otherName} - {#if yearRange} - {yearRange} + {#if dateRange} + {dateRange} + {/if} + {#if canWrite && onEdit} + {/if} {#if canWrite}
    diff --git a/frontend/src/lib/person/relationship/RelationshipChip.svelte.spec.ts b/frontend/src/lib/person/relationship/RelationshipChip.svelte.spec.ts index 9c85a6a6..a82e4a27 100644 --- a/frontend/src/lib/person/relationship/RelationshipChip.svelte.spec.ts +++ b/frontend/src/lib/person/relationship/RelationshipChip.svelte.spec.ts @@ -10,7 +10,7 @@ afterEach(cleanup); const baseProps = { chipLabel: 'Elternteil', otherName: 'Anna Schmidt', - yearRange: '', + dateRange: '', canWrite: false, relId: 'rel-1' }; @@ -26,30 +26,55 @@ describe('RelationshipChip', () => { await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument(); }); - it('shows year range when provided', async () => { - render(RelationshipChip, { ...baseProps, yearRange: '1920–1980' }); - await expect.element(page.getByText('1920–1980')).toBeInTheDocument(); + it('shows the date range when provided', async () => { + render(RelationshipChip, { ...baseProps, dateRange: '12. Mai 1923 – 1958' }); + await expect.element(page.getByText('12. Mai 1923 – 1958')).toBeInTheDocument(); }); - it('does not show year range span when empty', async () => { - render(RelationshipChip, { ...baseProps, yearRange: '' }); - expect(document.querySelector('[data-testid="year-range"]')).toBeNull(); + it('does not render a date-range span when empty', async () => { + render(RelationshipChip, { ...baseProps, dateRange: '' }); + expect(document.querySelector('[data-testid="date-range"]')).toBeNull(); }); - it('shows delete button when canWrite is true', async () => { + it('shows the delete button when canWrite is true', async () => { render(RelationshipChip, { ...baseProps, canWrite: true }); await expect.element(page.getByRole('button')).toBeInTheDocument(); }); - it('hides delete button when canWrite is false', async () => { + it('hides the delete button when canWrite is false', async () => { render(RelationshipChip, { ...baseProps, canWrite: false }); expect(document.querySelector('button')).toBeNull(); }); - it('delete button has h-11 w-11 (44px) WCAG touch target class', async () => { + it('gives the delete button an h-11 w-11 (44px) WCAG touch target', async () => { render(RelationshipChip, { ...baseProps, canWrite: true }); const btn = document.querySelector('button')!; expect(btn.className).toContain('h-11'); expect(btn.className).toContain('w-11'); }); + + it('shows an Edit affordance with an accessible name when canWrite and onEdit', async () => { + render(RelationshipChip, { ...baseProps, canWrite: true, onEdit: () => {} }); + await expect + .element(page.getByRole('button', { name: /Beziehung bearbeiten/i })) + .toBeInTheDocument(); + }); + + it('does not show the Edit affordance without onEdit', async () => { + render(RelationshipChip, { ...baseProps, canWrite: true }); + expect(document.querySelector('button[aria-label*="bearbeiten"]')).toBeNull(); + }); + + it('does not show the Edit affordance when canWrite is false', async () => { + render(RelationshipChip, { ...baseProps, canWrite: false, onEdit: () => {} }); + expect(document.querySelector('button[aria-label*="bearbeiten"]')).toBeNull(); + }); + + it('calls onEdit when the Edit affordance is clicked', async () => { + const onEdit = vi.fn(); + render(RelationshipChip, { ...baseProps, canWrite: true, onEdit }); + const editBtn = document.querySelector('button[aria-label*="bearbeiten"]')!; + editBtn.click(); + expect(onEdit).toHaveBeenCalledOnce(); + }); }); diff --git a/frontend/src/lib/person/relationship/RelationshipDateField.svelte b/frontend/src/lib/person/relationship/RelationshipDateField.svelte new file mode 100644 index 00000000..56c65e91 --- /dev/null +++ b/frontend/src/lib/person/relationship/RelationshipDateField.svelte @@ -0,0 +1,97 @@ + + +
    + + {legend} + +
    +
    + + {#if errorMessage} +

    {errorMessage}

    + {/if} +
    +
    + + +
    +
    +

    {m.relation_date_placeholder_hint()}

    +
    diff --git a/frontend/src/lib/person/relationshipLabels.test.ts b/frontend/src/lib/person/relationshipLabels.test.ts index a62033a7..8dc24923 100644 --- a/frontend/src/lib/person/relationshipLabels.test.ts +++ b/frontend/src/lib/person/relationshipLabels.test.ts @@ -19,6 +19,8 @@ function makeRel( personDisplayName: 'Alice', relatedPersonDisplayName: 'Bob', relationType, + fromDatePrecision: 'UNKNOWN', + toDatePrecision: 'UNKNOWN', ...override }; } diff --git a/frontend/src/routes/persons/[id]/PersonRelationshipsCard.svelte b/frontend/src/routes/persons/[id]/PersonRelationshipsCard.svelte index 389cd894..a7d6549f 100644 --- a/frontend/src/routes/persons/[id]/PersonRelationshipsCard.svelte +++ b/frontend/src/routes/persons/[id]/PersonRelationshipsCard.svelte @@ -1,6 +1,7 @@