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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-14 19:24:24 +02:00
parent 4d9b165a2d
commit 491d1a015a
27 changed files with 960 additions and 353 deletions

View File

@@ -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;

View File

@@ -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, {

View File

@@ -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<RelationshipDTO['relationType']>;
const sortedDirect = $derived([...relationships].sort(byTypeThenYear));
const sortedDirect = $derived([...relationships].sort(byTypeThenDate));
const topDerived = $derived(inferredRelationships.slice(0, 5));
let editingRelId = $state<string | null>(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
);
}
</script>
@@ -132,10 +135,20 @@ function yearRange(rel: RelationshipDTO): string {
<RelationshipChip
chipLabel={chipLabel(rel, personId)}
otherName={otherName(rel, personId)}
yearRange={yearRange(rel)}
dateRange={dateRangeOf(rel)}
canWrite={canWrite}
relId={rel.id}
onEdit={canWrite ? () => (editingRelId = rel.id) : undefined}
/>
{#if editingRelId === rel.id}
<li>
<AddRelationshipForm
personId={personId}
relationship={rel}
onClose={() => (editingRelId = null)}
/>
</li>
{/if}
{/each}
</ul>
{/if}

View File

@@ -111,17 +111,21 @@ describe('StammbaumCard', () => {
expect(items.length).toBeGreaterThanOrEqual(2);
});
it('renders the year range "fromto" 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 () => {

View File

@@ -250,7 +250,7 @@ const parentLinks = $derived.by<ParentLinks>(() => {
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}
/>
<circle
cx={(aCenter.x + bCenter.x) / 2}

View File

@@ -18,7 +18,9 @@ function parentEdge(parentId: string, childId: string): RelationshipDTO {
relatedPersonId: childId,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'PARENT_OF'
relationType: 'PARENT_OF',
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN'
};
}
@@ -30,7 +32,9 @@ function endedSpouseEdge(a: string, b: string): RelationshipDTO {
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF',
toYear: 1950
fromDatePrecision: 'UNKNOWN',
toDate: '1950-01-01',
toDatePrecision: 'YEAR'
};
}

View File

@@ -54,12 +54,19 @@ async function loadFor(id: string) {
}
async function handleAddRelationship(data: RelFormData) {
const body: Record<string, string | number> = {
const body: Record<string, string> = {
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' },

View File

@@ -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<HTMLButtonElement>('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();
}
});

View File

@@ -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'
}
];

View File

@@ -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,

View File

@@ -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' } : {})
};
}

View File

@@ -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
);
}
}

View File

@@ -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'
};
}

View File

@@ -1,8 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import RelationshipDateField from '$lib/person/relationship/RelationshipDateField.svelte';
import type { components } from '$lib/generated/api';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type RelationType = NonNullable<RelationshipDTO['relationType']>;
@@ -10,71 +13,96 @@ type RelationType = NonNullable<RelationshipDTO['relationType']>;
export type RelFormData = {
relatedPersonId: string;
relationType: RelationType;
fromYear?: number;
toYear?: number;
fromDate?: string;
fromDatePrecision?: DatePrecision;
toDate?: string;
toDatePrecision?: DatePrecision;
notes?: string;
};
interface Props {
personId: string;
// When present the form is an EDIT: pre-filled and posting to ?/updateRelationship.
relationship?: RelationshipDTO;
onSubmit?: (data: RelFormData) => Promise<void>;
onClose?: () => void;
}
let { personId, onSubmit }: Props = $props();
let { personId, relationship, onSubmit, onClose }: Props = $props();
const isEdit = $derived(relationship != null);
let open = $state(false);
let addType = $state<RelationType>('PARENT_OF');
let addRelatedPersonId = $state('');
let addRelatedPersonName = $state('');
let addFromYear = $state('');
let addToYear = $state('');
let notes = $state('');
let callbackError = $state<string | null>(null);
let submitting = $state(false);
const yearError = $derived.by(() => {
const from = addFromYear.trim();
const to = addToYear.trim();
if (!from || !to) return null;
const fromInt = parseInt(from, 10);
const toInt = parseInt(to, 10);
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null;
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null;
// Seed once at mount (reading props in a closure avoids state_referenced_locally).
// The parent re-creates this form per edited row, so the relationship never
// changes under a live instance.
onMount(() => {
if (!relationship) return;
open = true;
addType = relationship.relationType ?? 'PARENT_OF';
const viewpointIsSubject = relationship.personId === personId;
addRelatedPersonId =
(viewpointIsSubject ? relationship.relatedPersonId : relationship.personId) ?? '';
addRelatedPersonName =
(viewpointIsSubject ? relationship.relatedPersonDisplayName : relationship.personDisplayName) ??
'';
notes = relationship.notes ?? '';
});
const selfError = $derived(
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
);
const submitDisabled = $derived(
yearError !== null || selfError !== null || addRelatedPersonId === ''
);
const submitDisabled = $derived(selfError !== null || addRelatedPersonId === '');
function reset() {
addType = 'PARENT_OF';
addRelatedPersonId = '';
addRelatedPersonName = '';
addFromYear = '';
addToYear = '';
notes = '';
callbackError = null;
}
function cancel() {
if (isEdit) {
onClose?.();
return;
}
open = false;
reset();
}
async function handleCallbackSubmit(event: Event) {
async function handleCallbackSubmit(event: SubmitEvent) {
event.preventDefault();
if (submitDisabled || !onSubmit) return;
const data: RelFormData = { relatedPersonId: addRelatedPersonId, relationType: addType };
const from = parseInt(addFromYear.trim(), 10);
if (!Number.isNaN(from)) data.fromYear = from;
const to = parseInt(addToYear.trim(), 10);
if (!Number.isNaN(to)) data.toYear = to;
const fd = new FormData(event.currentTarget as HTMLFormElement);
const fromDate = (fd.get('fromDate') as string) || undefined;
const toDate = (fd.get('toDate') as string) || undefined;
const data: RelFormData = {
relatedPersonId: addRelatedPersonId,
relationType: addType,
fromDate,
fromDatePrecision: fromDate ? (fd.get('fromDatePrecision') as DatePrecision) : undefined,
toDate,
toDatePrecision: toDate ? (fd.get('toDatePrecision') as DatePrecision) : undefined,
notes: (fd.get('notes') as string)?.trim() || undefined
};
submitting = true;
try {
await onSubmit(data);
open = false;
reset();
} catch {
callbackError = m.error_internal_error();
} finally {
submitting = false;
}
}
</script>
@@ -113,39 +141,32 @@ async function handleCallbackSubmit(event: Event) {
compact
/>
</div>
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2"
>{m.relation_form_field_from_year()}</span
>
<input
type="text"
name="fromYear"
inputmode="numeric"
pattern="[0-9]*"
bind:value={addFromYear}
placeholder={m.relation_form_year_placeholder()}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
/>
</label>
<label class="block">
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_to_year()}</span
>
<input
type="text"
name="toYear"
inputmode="numeric"
pattern="[0-9]*"
bind:value={addToYear}
aria-describedby={yearError ? 'add-rel-year-error' : undefined}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
/>
{#if yearError}
<p id="add-rel-year-error" class="mt-1 text-xs text-red-700" role="alert">
{yearError}
</p>
{/if}
</label>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<RelationshipDateField
name="fromDate"
legend={m.relation_label_from_date()}
initialIso={relationship?.fromDate ?? ''}
initialPrecision={relationship?.fromDatePrecision ?? null}
/>
<RelationshipDateField
name="toDate"
legend={m.relation_label_to_date()}
initialIso={relationship?.toDate ?? ''}
initialPrecision={relationship?.toDatePrecision ?? null}
/>
</div>
<label class="mt-3 block">
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_label_notes()}</span>
<textarea
name="notes"
maxlength="2000"
rows="2"
bind:value={notes}
placeholder={m.relation_notes_placeholder()}
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 font-serif text-sm text-ink-3 focus:border-primary focus:outline-none"
></textarea>
</label>
{#if selfError}
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
{/if}
@@ -162,10 +183,18 @@ async function handleCallbackSubmit(event: Event) {
</button>
<button
type="submit"
disabled={submitDisabled}
class="rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
disabled={submitDisabled || submitting}
aria-busy={submitting}
class="inline-flex items-center gap-1.5 rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
>
{m.relation_btn_add()}
{#if submitting}
<span
class="h-3 w-3 animate-spin rounded-full border-2 border-primary-fg/40 border-t-primary-fg"
data-testid="submit-spinner"
aria-hidden="true"
></span>
{/if}
{isEdit ? m.relation_btn_save() : m.relation_btn_add()}
</button>
</div>
{/snippet}
@@ -185,18 +214,27 @@ async function handleCallbackSubmit(event: Event) {
{:else}
<form
method="POST"
action="?/addRelationship"
action={isEdit ? '?/updateRelationship' : '?/addRelationship'}
use:enhance={() => {
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}
<input type="hidden" name="relId" value={relationship.id} />
{/if}
{@render formFields()}
</form>
{/if}

View File

@@ -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<HTMLButtonElement>('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<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('button')!.click();
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
const fromInput = document.querySelector<HTMLInputElement>('input[name="fromYear"]')!;
fromInput.value = '1935';
fromInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
const toInput = document.querySelector<HTMLInputElement>('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<HTMLInputElement>('#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<HTMLTextAreaElement>('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<HTMLOptionElement>('#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<void>((r) => (resolve = r)));
render(AddRelationshipForm, { personId: PID, relationship: editRel(), onSubmit });
const submit = await vi.waitFor(() => {
const b = [...document.querySelectorAll<HTMLButtonElement>('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();
});
});

View File

@@ -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);
});
});
});

View File

@@ -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();
</script>
<li class="flex items-center gap-2 py-2">
@@ -22,8 +23,31 @@ let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props();
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
{otherName}
</span>
{#if yearRange}
<span class="shrink-0 font-sans text-xs text-ink-3" data-testid="year-range">{yearRange}</span>
{#if dateRange}
<span class="shrink-0 font-sans text-xs text-ink-3" data-testid="date-range">{dateRange}</span>
{/if}
{#if canWrite && onEdit}
<button
type="button"
onclick={onEdit}
aria-label="{m.relation_edit()} {otherName}"
class="inline-flex h-11 w-11 items-center justify-center text-ink-3 transition-colors hover:text-primary"
>
<svg
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z"
/>
</svg>
</button>
{/if}
{#if canWrite}
<form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0">

View File

@@ -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: '19201980' });
await expect.element(page.getByText('19201980')).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<HTMLButtonElement>('button[aria-label*="bearbeiten"]')!;
editBtn.click();
expect(onEdit).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import DateInput from '$lib/shared/primitives/DateInput.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
// Only DAY / MONTH / YEAR are offered (same as the person life-date field and the
// 60+ author audience). Storage still accepts all seven precisions; SEASON/RANGE/
// APPROX render correctly elsewhere but make no sense to enter for a relationship.
const RELATIONSHIP_DATE_PRECISIONS: { value: DatePrecision; label: () => string }[] = [
{ value: 'DAY', label: m.relation_precision_day },
{ value: 'MONTH', label: m.relation_precision_month },
{ value: 'YEAR', label: m.relation_precision_year }
];
let {
name,
legend,
initialIso = '',
initialPrecision = null
}: {
name: string;
legend: string;
initialIso?: string | null;
initialPrecision?: string | null;
} = $props();
let iso = $state('');
let errorMessage = $state<string | null>(null);
let inputEl = $state<HTMLInputElement | undefined>();
let precision = $state<DatePrecision>('DAY');
// Seed once at mount so a later load() rerun does not stomp an in-progress edit.
onMount(() => {
if (initialIso) {
iso = initialIso;
}
const offered = RELATIONSHIP_DATE_PRECISIONS.some((p) => p.value === initialPrecision);
if (offered) {
precision = initialPrecision as DatePrecision;
} else if (initialIso) {
// A stored non-offered precision (SEASON/RANGE/APPROX) seeds as YEAR so an
// untouched save does not silently claim DAY precision for the stored date.
precision = 'YEAR';
}
});
// A partial date leaves the hidden ISO empty — block native submission until the
// date is completed or fully emptied, so a save can never silently clear a date.
$effect(() => {
inputEl?.setCustomValidity(errorMessage ?? '');
});
const controlCls =
'block min-h-[44px] w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
</script>
<fieldset>
<legend class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{legend}
</legend>
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex-1">
<DateInput
bind:value={iso}
bind:errorMessage={errorMessage}
bind:inputEl={inputEl}
name={name}
id={name}
placeholder="TT.MM.JJJJ"
ariaLabel={legend}
ariaDescribedby={errorMessage ? `${name}-error` : undefined}
class="{controlCls} bg-surface"
/>
{#if errorMessage}
<p id="{name}-error" class="mt-1 font-sans text-xs text-red-600">{errorMessage}</p>
{/if}
</div>
<div class="flex-1">
<label class="sr-only" for="{name}Precision"
>{legend}: {m.relation_label_date_precision()}</label
>
<select
id="{name}Precision"
name="{name}Precision"
aria-label="{legend}: {m.relation_label_date_precision()}"
bind:value={precision}
class="{controlCls} bg-surface text-ink-3"
>
{#each RELATIONSHIP_DATE_PRECISIONS as p (p.value)}
<option value={p.value}>{p.label()}</option>
{/each}
</select>
</div>
</div>
<p class="mt-1 font-sans text-xs text-ink-3">{m.relation_date_placeholder_hint()}</p>
</fieldset>

View File

@@ -19,6 +19,8 @@ function makeRel(
personDisplayName: 'Alice',
relatedPersonDisplayName: 'Bob',
relationType,
fromDatePrecision: 'UNKNOWN',
toDatePrecision: 'UNKNOWN',
...override
};
}