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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' } : {})
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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<HTMLButtonElement>('button[aria-label*="bearbeiten"]')!;
|
||||
editBtn.click();
|
||||
expect(onEdit).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -19,6 +19,8 @@ function makeRel(
|
||||
personDisplayName: 'Alice',
|
||||
relatedPersonDisplayName: 'Bob',
|
||||
relationType,
|
||||
fromDatePrecision: 'UNKNOWN',
|
||||
toDatePrecision: 'UNKNOWN',
|
||||
...override
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
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'];
|
||||
@@ -35,18 +36,37 @@ function otherId(rel: RelationshipDTO): string {
|
||||
{#if relationships.length > 0}
|
||||
<ul class="mb-4 space-y-2">
|
||||
{#each relationships as rel (rel.id)}
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||
>
|
||||
{chipLabel(rel, personId)}
|
||||
</span>
|
||||
<a
|
||||
href="/persons/{otherId(rel)}"
|
||||
class="min-w-0 flex-1 truncate font-serif text-sm text-ink hover:underline"
|
||||
>
|
||||
{otherName(rel, personId)}
|
||||
</a>
|
||||
{@const dateRange = formatRelationshipDateRange(
|
||||
rel.fromDate,
|
||||
rel.fromDatePrecision,
|
||||
rel.toDate,
|
||||
rel.toDatePrecision
|
||||
)}
|
||||
<li class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||
>
|
||||
{chipLabel(rel, personId)}
|
||||
</span>
|
||||
<a
|
||||
href="/persons/{otherId(rel)}"
|
||||
class="min-w-0 flex-1 truncate font-serif text-sm text-ink hover:underline"
|
||||
>
|
||||
{otherName(rel, personId)}
|
||||
</a>
|
||||
{#if dateRange}
|
||||
<span
|
||||
class="shrink-0 font-sans text-xs text-ink-3"
|
||||
data-testid="relationship-date-range">{dateRange}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if rel.notes}
|
||||
<p class="pl-1 font-serif text-xs text-ink-2 italic" data-testid="relationship-notes">
|
||||
{rel.notes}
|
||||
</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -18,7 +18,9 @@ describe('PersonRelationshipsCard', () => {
|
||||
relatedPersonId: SPOUSE_ID,
|
||||
personDisplayName: 'Anna Müller',
|
||||
relatedPersonDisplayName: 'Bertha Müller',
|
||||
relationType: 'SPOUSE_OF'
|
||||
relationType: 'SPOUSE_OF',
|
||||
fromDatePrecision: 'UNKNOWN',
|
||||
toDatePrecision: 'UNKNOWN'
|
||||
}
|
||||
],
|
||||
inferredRelationships: [
|
||||
@@ -65,7 +67,9 @@ describe('PersonRelationshipsCard', () => {
|
||||
relatedPersonId: PARENT_ID,
|
||||
personDisplayName: 'Anna Müller',
|
||||
relatedPersonDisplayName: 'Kind Müller',
|
||||
relationType: 'PARENT_OF'
|
||||
relationType: 'PARENT_OF',
|
||||
fromDatePrecision: 'UNKNOWN',
|
||||
toDatePrecision: 'UNKNOWN'
|
||||
}
|
||||
],
|
||||
inferredRelationships: []
|
||||
@@ -84,7 +88,9 @@ describe('PersonRelationshipsCard', () => {
|
||||
relatedPersonId: SPOUSE_ID,
|
||||
personDisplayName: 'Anna',
|
||||
relatedPersonDisplayName: 'Bertha',
|
||||
relationType: 'SPOUSE_OF'
|
||||
relationType: 'SPOUSE_OF',
|
||||
fromDatePrecision: 'UNKNOWN',
|
||||
toDatePrecision: 'UNKNOWN'
|
||||
}
|
||||
],
|
||||
inferredRelationships: [
|
||||
@@ -113,7 +119,9 @@ describe('PersonRelationshipsCard', () => {
|
||||
relatedPersonId: PERSON_ID,
|
||||
personDisplayName: 'Eltern Müller',
|
||||
relatedPersonDisplayName: 'Anna Müller',
|
||||
relationType: 'PARENT_OF'
|
||||
relationType: 'PARENT_OF',
|
||||
fromDatePrecision: 'UNKNOWN',
|
||||
toDatePrecision: 'UNKNOWN'
|
||||
}
|
||||
],
|
||||
inferredRelationships: []
|
||||
@@ -121,4 +129,74 @@ describe('PersonRelationshipsCard', () => {
|
||||
|
||||
await expect.element(page.getByText('Kind von')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the date range at its stored precision', async () => {
|
||||
render(PersonRelationshipsCard, {
|
||||
personId: PERSON_ID,
|
||||
relationships: [
|
||||
{
|
||||
id: 'r1',
|
||||
personId: PERSON_ID,
|
||||
relatedPersonId: SPOUSE_ID,
|
||||
personDisplayName: 'Anna Müller',
|
||||
relatedPersonDisplayName: 'Bertha Müller',
|
||||
relationType: 'SPOUSE_OF',
|
||||
fromDate: '1923-05-12',
|
||||
fromDatePrecision: 'DAY',
|
||||
toDatePrecision: 'UNKNOWN'
|
||||
}
|
||||
],
|
||||
inferredRelationships: []
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByTestId('relationship-date-range'))
|
||||
.toHaveTextContent('12. Mai 1923');
|
||||
});
|
||||
|
||||
it('shows the notes line', async () => {
|
||||
render(PersonRelationshipsCard, {
|
||||
personId: PERSON_ID,
|
||||
relationships: [
|
||||
{
|
||||
id: 'r1',
|
||||
personId: PERSON_ID,
|
||||
relatedPersonId: SPOUSE_ID,
|
||||
personDisplayName: 'Anna Müller',
|
||||
relatedPersonDisplayName: 'Bertha Müller',
|
||||
relationType: 'SPOUSE_OF',
|
||||
fromDatePrecision: 'UNKNOWN',
|
||||
toDatePrecision: 'UNKNOWN',
|
||||
notes: 'Hochzeit in Berlin'
|
||||
}
|
||||
],
|
||||
inferredRelationships: []
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByTestId('relationship-notes'))
|
||||
.toHaveTextContent('Hochzeit in Berlin');
|
||||
});
|
||||
|
||||
it('renders no date line when the relationship has no dates', async () => {
|
||||
render(PersonRelationshipsCard, {
|
||||
personId: PERSON_ID,
|
||||
relationships: [
|
||||
{
|
||||
id: 'r1',
|
||||
personId: PERSON_ID,
|
||||
relatedPersonId: SPOUSE_ID,
|
||||
personDisplayName: 'Anna Müller',
|
||||
relatedPersonDisplayName: 'Bertha Müller',
|
||||
relationType: 'SPOUSE_OF',
|
||||
fromDatePrecision: 'UNKNOWN',
|
||||
toDatePrecision: 'UNKNOWN'
|
||||
}
|
||||
],
|
||||
inferredRelationships: []
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Bertha Müller')).toBeInTheDocument();
|
||||
expect(document.querySelector('[data-testid="relationship-date-range"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,40 @@ import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import {
|
||||
normalizePersonType,
|
||||
validatePersonFields,
|
||||
resolveValidationMessage
|
||||
} from '$lib/person/person-validation';
|
||||
|
||||
type RelationType = NonNullable<components['schemas']['RelationshipUpsertRequest']['relationType']>;
|
||||
|
||||
// Parses the shared relationship create/update form into a RelationshipUpsertRequest
|
||||
// body. An empty date omits date AND precision so the backend normalises the pair to
|
||||
// null/UNKNOWN — a lone precision would fail the coherence check (INVALID_DATE_PRECISION).
|
||||
function parseRelationshipForm(formData: FormData) {
|
||||
const relatedPersonId = formData.get('relatedPersonId')?.toString();
|
||||
const relationType = formData.get('relationType')?.toString();
|
||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||
const fromDate = formData.get('fromDate')?.toString().trim() || undefined;
|
||||
const fromDatePrecision = fromDate
|
||||
? (formData.get('fromDatePrecision')?.toString() as DatePrecision)
|
||||
: undefined;
|
||||
const toDate = formData.get('toDate')?.toString().trim() || undefined;
|
||||
const toDatePrecision = toDate
|
||||
? (formData.get('toDatePrecision')?.toString() as DatePrecision)
|
||||
: undefined;
|
||||
const body = {
|
||||
relatedPersonId: relatedPersonId ?? '',
|
||||
relationType: (relationType ?? 'OTHER') as RelationType,
|
||||
...(fromDate ? { fromDate, fromDatePrecision } : {}),
|
||||
...(toDate ? { toDate, toDatePrecision } : {}),
|
||||
...(notes ? { notes } : {})
|
||||
};
|
||||
return { relatedPersonId, relationType, body };
|
||||
}
|
||||
|
||||
export async function load({ params, fetch, locals }) {
|
||||
const canWrite =
|
||||
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
|
||||
@@ -193,40 +221,45 @@ export const actions = {
|
||||
|
||||
addRelationship: async ({ request, params, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const relatedPersonId = formData.get('relatedPersonId')?.toString();
|
||||
const relationType = formData.get('relationType')?.toString();
|
||||
const fromYearRaw = formData.get('fromYear')?.toString().trim();
|
||||
const toYearRaw = formData.get('toYear')?.toString().trim();
|
||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||
const fields = parseRelationshipForm(formData);
|
||||
|
||||
if (!relatedPersonId || !relationType) {
|
||||
if (!fields.relatedPersonId || !fields.relationType) {
|
||||
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
||||
}
|
||||
if (relatedPersonId === params.id) {
|
||||
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
||||
}
|
||||
const fromYear = fromYearRaw ? parseInt(fromYearRaw, 10) : undefined;
|
||||
const toYear = toYearRaw ? parseInt(toYearRaw, 10) : undefined;
|
||||
if (
|
||||
fromYear !== undefined &&
|
||||
toYear !== undefined &&
|
||||
!Number.isNaN(fromYear) &&
|
||||
!Number.isNaN(toYear) &&
|
||||
toYear < fromYear
|
||||
) {
|
||||
if (fields.relatedPersonId === params.id) {
|
||||
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
||||
}
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.POST('/api/persons/{id}/relationships', {
|
||||
params: { path: { id: params.id } },
|
||||
body: {
|
||||
relatedPersonId,
|
||||
relationType,
|
||||
...(fromYear !== undefined && !Number.isNaN(fromYear) ? { fromYear } : {}),
|
||||
...(toYear !== undefined && !Number.isNaN(toYear) ? { toYear } : {}),
|
||||
...(notes ? { notes } : {})
|
||||
}
|
||||
body: fields.body
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
relationshipError: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
}
|
||||
return { relationshipSuccess: true };
|
||||
},
|
||||
|
||||
updateRelationship: async ({ request, params, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const relId = formData.get('relId')?.toString();
|
||||
const fields = parseRelationshipForm(formData);
|
||||
|
||||
if (!relId || !fields.relatedPersonId || !fields.relationType) {
|
||||
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
||||
}
|
||||
if (fields.relatedPersonId === params.id) {
|
||||
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
||||
}
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.PUT('/api/persons/{id}/relationships/{relId}', {
|
||||
params: { path: { id: params.id, relId } },
|
||||
body: fields.body
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
|
||||
@@ -97,3 +97,98 @@ describe('persons/[id]/edit update action — generation (#689)', () => {
|
||||
expect(body).toHaveProperty('generation', 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persons/[id]/edit relationship actions (#837)', () => {
|
||||
function relForm(overrides: Record<string, string | null> = {}): Request {
|
||||
const fd = new FormData();
|
||||
fd.set('relatedPersonId', 'p2');
|
||||
fd.set('relationType', 'SPOUSE_OF');
|
||||
for (const [k, v] of Object.entries(overrides)) {
|
||||
if (v == null) fd.delete(k);
|
||||
else fd.set(k, v);
|
||||
}
|
||||
return new Request('http://localhost/persons/p1/edit', { method: 'POST', body: fd });
|
||||
}
|
||||
|
||||
it('addRelationship posts date + precision + notes', async () => {
|
||||
const post = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
|
||||
vi.mocked(createApiClient).mockReturnValue({ POST: post } as unknown as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
const request = relForm({
|
||||
fromDate: '1923-05-12',
|
||||
fromDatePrecision: 'DAY',
|
||||
notes: 'Hochzeit'
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await actions.addRelationship({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
|
||||
|
||||
const [path, opts] = post.mock.calls[0];
|
||||
expect(path).toBe('/api/persons/{id}/relationships');
|
||||
expect(opts.body).toMatchObject({
|
||||
relatedPersonId: 'p2',
|
||||
relationType: 'SPOUSE_OF',
|
||||
fromDate: '1923-05-12',
|
||||
fromDatePrecision: 'DAY',
|
||||
notes: 'Hochzeit'
|
||||
});
|
||||
});
|
||||
|
||||
it('addRelationship omits precision when the date is empty (coherence)', async () => {
|
||||
const post = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
|
||||
vi.mocked(createApiClient).mockReturnValue({ POST: post } as unknown as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
const request = relForm({ fromDatePrecision: 'DAY' }); // precision but no date
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await actions.addRelationship({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
|
||||
|
||||
const body = post.mock.calls[0][1].body;
|
||||
expect(body).not.toHaveProperty('fromDate');
|
||||
expect(body).not.toHaveProperty('fromDatePrecision');
|
||||
});
|
||||
|
||||
it('updateRelationship PUTs to the relId path with the new body', async () => {
|
||||
const put = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: {} });
|
||||
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
const request = relForm({ relId: 'rel-9', fromDate: '1923-05-12', fromDatePrecision: 'DAY' });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await actions.updateRelationship({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
|
||||
|
||||
const [path, opts] = put.mock.calls[0];
|
||||
expect(path).toBe('/api/persons/{id}/relationships/{relId}');
|
||||
expect(opts.params.path).toMatchObject({ id: 'p1', relId: 'rel-9' });
|
||||
expect(opts.body).toMatchObject({
|
||||
relatedPersonId: 'p2',
|
||||
relationType: 'SPOUSE_OF',
|
||||
fromDate: '1923-05-12',
|
||||
fromDatePrecision: 'DAY'
|
||||
});
|
||||
});
|
||||
|
||||
it('updateRelationship surfaces a backend error as a fail', async () => {
|
||||
const put = vi.fn().mockResolvedValue({
|
||||
response: { ok: false, status: 400 },
|
||||
error: { code: 'INVALID_RELATIONSHIP_DATES' }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
const request = relForm({ relId: 'rel-9' });
|
||||
|
||||
const result = (await actions.updateRelationship({
|
||||
request,
|
||||
params: { id: 'p1' },
|
||||
fetch: mockFetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)) as { status: number; data: { relationshipError: string } };
|
||||
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.data.relationshipError).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user