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_from_year": "Von Jahr",
|
||||||
"relation_form_field_to_year": "Bis Jahr",
|
"relation_form_field_to_year": "Bis Jahr",
|
||||||
"relation_form_year_placeholder": "z.B. 1920",
|
"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_heading": "Beziehungen",
|
||||||
"person_relationships_empty": "Noch keine Beziehungen bekannt.",
|
"person_relationships_empty": "Noch keine Beziehungen bekannt.",
|
||||||
"timeline_aria_label": "Zeitachse Dokumentdichte",
|
"timeline_aria_label": "Zeitachse Dokumentdichte",
|
||||||
|
|||||||
@@ -1221,6 +1221,16 @@
|
|||||||
"relation_form_field_from_year": "From year",
|
"relation_form_field_from_year": "From year",
|
||||||
"relation_form_field_to_year": "To year",
|
"relation_form_field_to_year": "To year",
|
||||||
"relation_form_year_placeholder": "e.g. 1920",
|
"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_heading": "Relationships",
|
||||||
"person_relationships_empty": "No relationships known yet.",
|
"person_relationships_empty": "No relationships known yet.",
|
||||||
"timeline_aria_label": "Document density timeline",
|
"timeline_aria_label": "Document density timeline",
|
||||||
|
|||||||
@@ -1221,6 +1221,16 @@
|
|||||||
"relation_form_field_from_year": "Desde año",
|
"relation_form_field_from_year": "Desde año",
|
||||||
"relation_form_field_to_year": "Hasta año",
|
"relation_form_field_to_year": "Hasta año",
|
||||||
"relation_form_year_placeholder": "ej. 1920",
|
"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_heading": "Relaciones",
|
||||||
"person_relationships_empty": "Aún no se conocen relaciones.",
|
"person_relationships_empty": "Aún no se conocen relaciones.",
|
||||||
"timeline_aria_label": "Cronología de densidad de documentos",
|
"timeline_aria_label": "Cronología de densidad de documentos",
|
||||||
|
|||||||
@@ -100,6 +100,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/geschichten/{id}/items/reorder": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1640,22 +1656,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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}": {
|
"/api/persons/{id}/aliases/{aliasId}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1853,6 +1853,50 @@ export interface components {
|
|||||||
provisional: boolean;
|
provisional: boolean;
|
||||||
readonly displayName: string;
|
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: {
|
JourneyReorderDTO: {
|
||||||
itemIds?: string[];
|
itemIds?: string[];
|
||||||
};
|
};
|
||||||
@@ -2008,42 +2052,6 @@ export interface components {
|
|||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
targetId: string;
|
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: {
|
PersonNameAliasDTO: {
|
||||||
lastName: string;
|
lastName: string;
|
||||||
firstName?: 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: {
|
reorderItems: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3659,7 +3715,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["CreateRelationshipRequest"];
|
"application/json": components["schemas"]["RelationshipUpsertRequest"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
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: {
|
removeAlias: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -193,7 +193,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
relatedPersonId: 'p-spouse',
|
relatedPersonId: 'p-spouse',
|
||||||
personDisplayName: 'Auguste',
|
personDisplayName: 'Auguste',
|
||||||
relatedPersonDisplayName: 'Otto Raddatz',
|
relatedPersonDisplayName: 'Otto Raddatz',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'r2',
|
id: 'r2',
|
||||||
@@ -201,7 +203,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
relatedPersonId: 'p-friend',
|
relatedPersonId: 'p-friend',
|
||||||
personDisplayName: 'Auguste',
|
personDisplayName: 'Auguste',
|
||||||
relatedPersonDisplayName: 'Karl Friend',
|
relatedPersonDisplayName: 'Karl Friend',
|
||||||
relationType: 'FRIEND'
|
relationType: 'FRIEND',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'r3',
|
id: 'r3',
|
||||||
@@ -209,7 +213,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
relatedPersonId: 'p-sibling',
|
relatedPersonId: 'p-sibling',
|
||||||
personDisplayName: 'Auguste',
|
personDisplayName: 'Auguste',
|
||||||
relatedPersonDisplayName: 'Marie Sister',
|
relatedPersonDisplayName: 'Marie Sister',
|
||||||
relationType: 'SIBLING_OF'
|
relationType: 'SIBLING_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
render(PersonHoverCard, {
|
render(PersonHoverCard, {
|
||||||
@@ -235,7 +241,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
relatedPersonId: 'p-aug',
|
relatedPersonId: 'p-aug',
|
||||||
personDisplayName: 'Heinrich Raddatz',
|
personDisplayName: 'Heinrich Raddatz',
|
||||||
relatedPersonDisplayName: 'Auguste Raddatz',
|
relatedPersonDisplayName: 'Auguste Raddatz',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
render(PersonHoverCard, {
|
render(PersonHoverCard, {
|
||||||
@@ -258,7 +266,9 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
relatedPersonId: 'p-friend',
|
relatedPersonId: 'p-friend',
|
||||||
personDisplayName: 'Auguste',
|
personDisplayName: 'Auguste',
|
||||||
relatedPersonDisplayName: 'Karl Friend',
|
relatedPersonDisplayName: 'Karl Friend',
|
||||||
relationType: 'FRIEND'
|
relationType: 'FRIEND',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
render(PersonHoverCard, {
|
render(PersonHoverCard, {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
import RelationshipChip from '$lib/person/relationship/RelationshipChip.svelte';
|
import RelationshipChip from '$lib/person/relationship/RelationshipChip.svelte';
|
||||||
import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte';
|
import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte';
|
||||||
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
|
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
|
||||||
|
import { formatRelationshipDateRange } from '$lib/person/relationshipDates';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
@@ -29,13 +30,15 @@ let {
|
|||||||
|
|
||||||
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
||||||
|
|
||||||
const sortedDirect = $derived([...relationships].sort(byTypeThenYear));
|
const sortedDirect = $derived([...relationships].sort(byTypeThenDate));
|
||||||
const topDerived = $derived(inferredRelationships.slice(0, 5));
|
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);
|
const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType);
|
||||||
if (order !== 0) return order;
|
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 {
|
function relationTypeOrder(t: RelationType | undefined): number {
|
||||||
@@ -53,13 +56,13 @@ function relationTypeOrder(t: RelationType | undefined): number {
|
|||||||
return order[t ?? 'OTHER'] ?? 99;
|
return order[t ?? 'OTHER'] ?? 99;
|
||||||
}
|
}
|
||||||
|
|
||||||
function yearRange(rel: RelationshipDTO): string {
|
function dateRangeOf(rel: RelationshipDTO): string {
|
||||||
const from = rel.fromYear;
|
return formatRelationshipDateRange(
|
||||||
const to = rel.toYear;
|
rel.fromDate,
|
||||||
if (from && to) return `${from}–${to}`;
|
rel.fromDatePrecision,
|
||||||
if (from) return m.relation_year_from({ year: from });
|
rel.toDate,
|
||||||
if (to) return m.relation_year_to({ year: to });
|
rel.toDatePrecision
|
||||||
return '';
|
);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -132,10 +135,20 @@ function yearRange(rel: RelationshipDTO): string {
|
|||||||
<RelationshipChip
|
<RelationshipChip
|
||||||
chipLabel={chipLabel(rel, personId)}
|
chipLabel={chipLabel(rel, personId)}
|
||||||
otherName={otherName(rel, personId)}
|
otherName={otherName(rel, personId)}
|
||||||
yearRange={yearRange(rel)}
|
dateRange={dateRangeOf(rel)}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
relId={rel.id}
|
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}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -111,17 +111,21 @@ describe('StammbaumCard', () => {
|
|||||||
expect(items.length).toBeGreaterThanOrEqual(2);
|
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, {
|
render(StammbaumCard, {
|
||||||
props: baseProps({
|
props: baseProps({
|
||||||
relationships: [
|
relationships: [
|
||||||
{
|
{
|
||||||
id: 'r-1',
|
id: 'r-1',
|
||||||
|
personId: 'p-1',
|
||||||
|
relatedPersonId: 'p-x',
|
||||||
|
personDisplayName: 'Anna',
|
||||||
|
relatedPersonDisplayName: 'Xavier',
|
||||||
relationType: 'COLLEAGUE',
|
relationType: 'COLLEAGUE',
|
||||||
fromYear: 1940,
|
fromDate: '1940-01-01',
|
||||||
toYear: 1945,
|
fromDatePrecision: 'YEAR',
|
||||||
personA: { id: 'p-1', displayName: 'Anna' },
|
toDate: '1945-01-01',
|
||||||
personB: { id: 'p-x', displayName: 'Xavier' }
|
toDatePrecision: 'YEAR'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -131,23 +135,27 @@ describe('StammbaumCard', () => {
|
|||||||
expect(document.body.textContent).toContain('1945');
|
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, {
|
render(StammbaumCard, {
|
||||||
props: baseProps({
|
props: baseProps({
|
||||||
relationships: [
|
relationships: [
|
||||||
{
|
{
|
||||||
id: 'r-2',
|
id: 'r-2',
|
||||||
|
personId: 'p-1',
|
||||||
|
relatedPersonId: 'p-y',
|
||||||
|
personDisplayName: 'Anna',
|
||||||
|
relatedPersonDisplayName: 'Yvonne',
|
||||||
relationType: 'NEIGHBOR',
|
relationType: 'NEIGHBOR',
|
||||||
fromYear: 1935,
|
fromDate: '1935-01-01',
|
||||||
personA: { id: 'p-1', displayName: 'Anna' },
|
fromDatePrecision: 'YEAR',
|
||||||
personB: { id: 'p-y', displayName: 'Yvonne' }
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(document.body.textContent).toContain('1935');
|
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 () => {
|
it('renders the inferred-relationships disclosure when topDerived has items', async () => {
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
|||||||
y2={bCenter.y}
|
y2={bCenter.y}
|
||||||
stroke="var(--c-primary)"
|
stroke="var(--c-primary)"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
stroke-dasharray={e.toYear ? '4 4' : undefined}
|
stroke-dasharray={e.toDate ? '4 4' : undefined}
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle
|
||||||
cx={(aCenter.x + bCenter.x) / 2}
|
cx={(aCenter.x + bCenter.x) / 2}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ function parentEdge(parentId: string, childId: string): RelationshipDTO {
|
|||||||
relatedPersonId: childId,
|
relatedPersonId: childId,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +32,9 @@ function endedSpouseEdge(a: string, b: string): RelationshipDTO {
|
|||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'SPOUSE_OF',
|
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) {
|
async function handleAddRelationship(data: RelFormData) {
|
||||||
const body: Record<string, string | number> = {
|
const body: Record<string, string> = {
|
||||||
relatedPersonId: data.relatedPersonId,
|
relatedPersonId: data.relatedPersonId,
|
||||||
relationType: data.relationType
|
relationType: data.relationType
|
||||||
};
|
};
|
||||||
if (data.fromYear !== undefined) body.fromYear = data.fromYear;
|
if (data.fromDate) {
|
||||||
if (data.toYear !== undefined) body.toYear = data.toYear;
|
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`, {
|
const res = await csrfFetch(`/api/persons/${node.id}/relationships`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -72,20 +72,20 @@ describe('StammbaumSidePanel', () => {
|
|||||||
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
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 });
|
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: true });
|
||||||
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
||||||
const addBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) =>
|
const addBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) =>
|
||||||
/Beziehung hinzufügen/i.test(b.textContent ?? '')
|
/Beziehung hinzufügen/i.test(b.textContent ?? '')
|
||||||
);
|
);
|
||||||
addBtn!.click();
|
addBtn!.click();
|
||||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
|
||||||
const yearInputs = [...document.querySelectorAll('input')].filter(
|
const dateInputs = [...document.querySelectorAll('input')].filter(
|
||||||
(i) => i.inputMode === 'numeric'
|
(i) => i.inputMode === 'numeric'
|
||||||
);
|
);
|
||||||
expect(yearInputs.length).toBeGreaterThan(0);
|
expect(dateInputs.length).toBeGreaterThan(0);
|
||||||
for (const input of yearInputs) {
|
for (const input of dateInputs) {
|
||||||
expect(input.closest('label')).not.toBeNull();
|
expect(input.getAttribute('aria-label')).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { render } from 'vitest-browser-svelte';
|
|||||||
import StammbaumTree from './StammbaumTree.svelte';
|
import StammbaumTree from './StammbaumTree.svelte';
|
||||||
import type { PanZoomState } from './panZoom';
|
import type { PanZoomState } from './panZoom';
|
||||||
import { DIMMED_OPACITY } from './layout/highlightLineage';
|
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_A = '00000000-0000-0000-0000-000000000001';
|
||||||
const ID_B = '00000000-0000-0000-0000-000000000002';
|
const ID_B = '00000000-0000-0000-0000-000000000002';
|
||||||
@@ -105,7 +108,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: PARENT_B,
|
relatedPersonId: PARENT_B,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Eugenie',
|
relatedPersonDisplayName: 'Eugenie',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p1a',
|
id: 'p1a',
|
||||||
@@ -113,7 +118,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD_1,
|
relatedPersonId: CHILD_1,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p1b',
|
id: 'p1b',
|
||||||
@@ -121,7 +128,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD_1,
|
relatedPersonId: CHILD_1,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2a',
|
id: 'p2a',
|
||||||
@@ -129,7 +138,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD_2,
|
relatedPersonId: CHILD_2,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2b',
|
id: 'p2b',
|
||||||
@@ -137,7 +148,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD_2,
|
relatedPersonId: CHILD_2,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -181,7 +194,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: PARENT_B,
|
relatedPersonId: PARENT_B,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Eugenie',
|
relatedPersonDisplayName: 'Eugenie',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
@@ -189,7 +204,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD,
|
relatedPersonId: CHILD,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2',
|
id: 'p2',
|
||||||
@@ -197,7 +214,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CHILD,
|
relatedPersonId: CHILD,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -244,7 +263,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: EUGENIE,
|
relatedPersonId: EUGENIE,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Eugenie',
|
relatedPersonDisplayName: 'Eugenie',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
@@ -252,7 +273,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: HANS,
|
relatedPersonId: HANS,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2',
|
id: 'p2',
|
||||||
@@ -260,7 +283,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: HANS,
|
relatedPersonId: HANS,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p3',
|
id: 'p3',
|
||||||
@@ -268,7 +293,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CLARA,
|
relatedPersonId: CLARA,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p4',
|
id: 'p4',
|
||||||
@@ -276,7 +303,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: CLARA,
|
relatedPersonId: CLARA,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 's2',
|
id: 's2',
|
||||||
@@ -284,7 +313,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: HILDE,
|
relatedPersonId: HILDE,
|
||||||
personDisplayName: 'Hans',
|
personDisplayName: 'Hans',
|
||||||
relatedPersonDisplayName: 'Hilde',
|
relatedPersonDisplayName: 'Hilde',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p5',
|
id: 'p5',
|
||||||
@@ -292,7 +323,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: LILI,
|
relatedPersonId: LILI,
|
||||||
personDisplayName: 'Hans',
|
personDisplayName: 'Hans',
|
||||||
relatedPersonDisplayName: 'Lili',
|
relatedPersonDisplayName: 'Lili',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p6',
|
id: 'p6',
|
||||||
@@ -300,7 +333,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: LILI,
|
relatedPersonId: LILI,
|
||||||
personDisplayName: 'Hilde',
|
personDisplayName: 'Hilde',
|
||||||
relatedPersonDisplayName: 'Lili',
|
relatedPersonDisplayName: 'Lili',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -358,7 +393,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: ID_B,
|
relatedPersonId: ID_B,
|
||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -391,7 +428,9 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
relatedPersonId: ID_B,
|
relatedPersonId: ID_B,
|
||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -599,7 +638,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
|||||||
relatedPersonId: ID_B,
|
relatedPersonId: ID_B,
|
||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -668,7 +709,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
|||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF',
|
relationType: 'SPOUSE_OF',
|
||||||
toYear: 1925
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDate: '1925-01-01',
|
||||||
|
toDatePrecision: 'YEAR'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -695,7 +738,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
|||||||
relatedPersonId: ID_B,
|
relatedPersonId: ID_B,
|
||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -723,7 +768,9 @@ describe('StammbaumTree keyboard pan/zoom (#692)', () => {
|
|||||||
relatedPersonId: CHILD,
|
relatedPersonId: CHILD,
|
||||||
personDisplayName: 'Parent',
|
personDisplayName: 'Parent',
|
||||||
relatedPersonDisplayName: 'Child',
|
relatedPersonDisplayName: 'Child',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -908,6 +955,8 @@ describe('StammbaumTree lineage highlight (#703)', () => {
|
|||||||
personDisplayName: string;
|
personDisplayName: string;
|
||||||
relatedPersonDisplayName: string;
|
relatedPersonDisplayName: string;
|
||||||
relationType: 'PARENT_OF' | 'SPOUSE_OF';
|
relationType: 'PARENT_OF' | 'SPOUSE_OF';
|
||||||
|
fromDatePrecision: 'UNKNOWN';
|
||||||
|
toDatePrecision: 'UNKNOWN';
|
||||||
};
|
};
|
||||||
const edge = (
|
const edge = (
|
||||||
personId: string,
|
personId: string,
|
||||||
@@ -919,7 +968,9 @@ describe('StammbaumTree lineage highlight (#703)', () => {
|
|||||||
relatedPersonId,
|
relatedPersonId,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType
|
relationType,
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
});
|
});
|
||||||
|
|
||||||
const NODES = [
|
const NODES = [
|
||||||
@@ -1104,14 +1155,16 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
|
|||||||
// year, then a deterministic id tie-break), not alphabetically — with no birth
|
// 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
|
// 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.
|
// deterministic visual order is Walter, Eugenie (top row) then Clara, Hans.
|
||||||
const FAMILY_EDGES = [
|
const FAMILY_EDGES: RelationshipDTO[] = [
|
||||||
{
|
{
|
||||||
id: 'sp',
|
id: 'sp',
|
||||||
personId: WALTER,
|
personId: WALTER,
|
||||||
relatedPersonId: EUGENIE,
|
relatedPersonId: EUGENIE,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Eugenie',
|
relatedPersonDisplayName: 'Eugenie',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
@@ -1119,7 +1172,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
|
|||||||
relatedPersonId: CLARA,
|
relatedPersonId: CLARA,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p2',
|
id: 'p2',
|
||||||
@@ -1127,7 +1182,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
|
|||||||
relatedPersonId: CLARA,
|
relatedPersonId: CLARA,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Clara',
|
relatedPersonDisplayName: 'Clara',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p3',
|
id: 'p3',
|
||||||
@@ -1135,7 +1192,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
|
|||||||
relatedPersonId: HANS,
|
relatedPersonId: HANS,
|
||||||
personDisplayName: 'Walter',
|
personDisplayName: 'Walter',
|
||||||
relatedPersonDisplayName: 'Hans',
|
relatedPersonDisplayName: 'Hans',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'p4',
|
id: 'p4',
|
||||||
@@ -1143,7 +1202,9 @@ describe('StammbaumTree keyboard tab order (#718)', () => {
|
|||||||
relatedPersonId: HANS,
|
relatedPersonId: HANS,
|
||||||
personDisplayName: 'Eugenie',
|
personDisplayName: 'Eugenie',
|
||||||
relatedPersonDisplayName: 'Hans',
|
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,
|
relatedPersonId: childId,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
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,
|
relatedPersonId: b,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +224,10 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
|
|||||||
fromYear: number | undefined,
|
fromYear: number | undefined,
|
||||||
id = a + b
|
id = a + b
|
||||||
): RelationshipDTO {
|
): 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', () => {
|
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
|
// fail fast instead so the maintainer either updates the test or
|
||||||
// splits into a year-branch / name-branch pair.
|
// splits into a year-branch / name-branch pair.
|
||||||
const spouseEdgesWithYear = fixtureEdges.filter(
|
const spouseEdgesWithYear = fixtureEdges.filter(
|
||||||
(e) => e.relationType === 'SPOUSE_OF' && e.fromYear != null
|
(e) => e.relationType === 'SPOUSE_OF' && e.fromDate != null
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
spouseEdgesWithYear,
|
spouseEdgesWithYear,
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ function parent(p: string, c: string): RelationshipDTO {
|
|||||||
relatedPersonId: c,
|
relatedPersonId: c,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
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: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'SPOUSE_OF',
|
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') {
|
} else if (e.relationType === 'SPOUSE_OF') {
|
||||||
addToSet(spouses, e.personId, e.relatedPersonId);
|
addToSet(spouses, e.personId, e.relatedPersonId);
|
||||||
addToSet(spouses, e.relatedPersonId, e.personId);
|
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,
|
relatedPersonId: childId,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
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,
|
relatedPersonId: b,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
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,
|
relatedPersonId: b,
|
||||||
personDisplayName: '',
|
personDisplayName: '',
|
||||||
relatedPersonDisplayName: '',
|
relatedPersonDisplayName: '',
|
||||||
relationType: 'SIBLING_OF'
|
relationType: 'SIBLING_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
||||||
|
import RelationshipDateField from '$lib/person/relationship/RelationshipDateField.svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
|
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
||||||
@@ -10,71 +13,96 @@ type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
|||||||
export type RelFormData = {
|
export type RelFormData = {
|
||||||
relatedPersonId: string;
|
relatedPersonId: string;
|
||||||
relationType: RelationType;
|
relationType: RelationType;
|
||||||
fromYear?: number;
|
fromDate?: string;
|
||||||
toYear?: number;
|
fromDatePrecision?: DatePrecision;
|
||||||
|
toDate?: string;
|
||||||
|
toDatePrecision?: DatePrecision;
|
||||||
|
notes?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
personId: string;
|
personId: string;
|
||||||
|
// When present the form is an EDIT: pre-filled and posting to ?/updateRelationship.
|
||||||
|
relationship?: RelationshipDTO;
|
||||||
onSubmit?: (data: RelFormData) => Promise<void>;
|
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 open = $state(false);
|
||||||
let addType = $state<RelationType>('PARENT_OF');
|
let addType = $state<RelationType>('PARENT_OF');
|
||||||
let addRelatedPersonId = $state('');
|
let addRelatedPersonId = $state('');
|
||||||
let addRelatedPersonName = $state('');
|
let addRelatedPersonName = $state('');
|
||||||
let addFromYear = $state('');
|
let notes = $state('');
|
||||||
let addToYear = $state('');
|
|
||||||
let callbackError = $state<string | null>(null);
|
let callbackError = $state<string | null>(null);
|
||||||
|
let submitting = $state(false);
|
||||||
|
|
||||||
const yearError = $derived.by(() => {
|
// Seed once at mount (reading props in a closure avoids state_referenced_locally).
|
||||||
const from = addFromYear.trim();
|
// The parent re-creates this form per edited row, so the relationship never
|
||||||
const to = addToYear.trim();
|
// changes under a live instance.
|
||||||
if (!from || !to) return null;
|
onMount(() => {
|
||||||
const fromInt = parseInt(from, 10);
|
if (!relationship) return;
|
||||||
const toInt = parseInt(to, 10);
|
open = true;
|
||||||
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null;
|
addType = relationship.relationType ?? 'PARENT_OF';
|
||||||
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null;
|
const viewpointIsSubject = relationship.personId === personId;
|
||||||
|
addRelatedPersonId =
|
||||||
|
(viewpointIsSubject ? relationship.relatedPersonId : relationship.personId) ?? '';
|
||||||
|
addRelatedPersonName =
|
||||||
|
(viewpointIsSubject ? relationship.relatedPersonDisplayName : relationship.personDisplayName) ??
|
||||||
|
'';
|
||||||
|
notes = relationship.notes ?? '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const selfError = $derived(
|
const selfError = $derived(
|
||||||
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
|
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitDisabled = $derived(
|
const submitDisabled = $derived(selfError !== null || addRelatedPersonId === '');
|
||||||
yearError !== null || selfError !== null || addRelatedPersonId === ''
|
|
||||||
);
|
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
addType = 'PARENT_OF';
|
addType = 'PARENT_OF';
|
||||||
addRelatedPersonId = '';
|
addRelatedPersonId = '';
|
||||||
addRelatedPersonName = '';
|
addRelatedPersonName = '';
|
||||||
addFromYear = '';
|
notes = '';
|
||||||
addToYear = '';
|
|
||||||
callbackError = null;
|
callbackError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
|
if (isEdit) {
|
||||||
|
onClose?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
open = false;
|
open = false;
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCallbackSubmit(event: Event) {
|
async function handleCallbackSubmit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (submitDisabled || !onSubmit) return;
|
if (submitDisabled || !onSubmit) return;
|
||||||
const data: RelFormData = { relatedPersonId: addRelatedPersonId, relationType: addType };
|
const fd = new FormData(event.currentTarget as HTMLFormElement);
|
||||||
const from = parseInt(addFromYear.trim(), 10);
|
const fromDate = (fd.get('fromDate') as string) || undefined;
|
||||||
if (!Number.isNaN(from)) data.fromYear = from;
|
const toDate = (fd.get('toDate') as string) || undefined;
|
||||||
const to = parseInt(addToYear.trim(), 10);
|
const data: RelFormData = {
|
||||||
if (!Number.isNaN(to)) data.toYear = to;
|
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 {
|
try {
|
||||||
await onSubmit(data);
|
await onSubmit(data);
|
||||||
open = false;
|
open = false;
|
||||||
reset();
|
reset();
|
||||||
} catch {
|
} catch {
|
||||||
callbackError = m.error_internal_error();
|
callbackError = m.error_internal_error();
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -113,39 +141,32 @@ async function handleCallbackSubmit(event: Event) {
|
|||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
<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}
|
{#if selfError}
|
||||||
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
|
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -162,10 +183,18 @@ async function handleCallbackSubmit(event: Event) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitDisabled}
|
disabled={submitDisabled || submitting}
|
||||||
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -185,18 +214,27 @@ async function handleCallbackSubmit(event: Event) {
|
|||||||
{:else}
|
{:else}
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/addRelationship"
|
action={isEdit ? '?/updateRelationship' : '?/addRelationship'}
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
|
submitting = true;
|
||||||
return async ({ result, update }) => {
|
return async ({ result, update }) => {
|
||||||
await update();
|
await update();
|
||||||
|
submitting = false;
|
||||||
if (result.type === 'success') {
|
if (result.type === 'success') {
|
||||||
open = false;
|
if (isEdit) {
|
||||||
reset();
|
onClose?.();
|
||||||
|
} else {
|
||||||
|
open = false;
|
||||||
|
reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
class="mt-3 rounded-sm border border-line bg-muted/40 p-3"
|
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()}
|
{@render formFields()}
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -8,58 +8,116 @@ vi.mock('$lib/person/PersonTypeahead.svelte', () => ({ default: () => null }));
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe('AddRelationshipForm', () => {
|
const PID = 'person-1';
|
||||||
it('shows add-relationship button initially and no form', async () => {
|
const OTHER = 'person-2';
|
||||||
render(AddRelationshipForm, { personId: 'person-1' });
|
|
||||||
|
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('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 () => {
|
it('shows the relationType select when the add toggle is clicked', async () => {
|
||||||
render(AddRelationshipForm, { personId: 'person-1' });
|
render(AddRelationshipForm, { personId: PID });
|
||||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
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 () => {
|
it('hides the form and shows the toggle again on cancel', async () => {
|
||||||
render(AddRelationshipForm, { personId: 'person-1' });
|
render(AddRelationshipForm, { personId: PID });
|
||||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
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(
|
const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||||
(b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '')
|
(b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '')
|
||||||
);
|
);
|
||||||
cancelBtn!.click();
|
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 () => {
|
it('disables submit when no person is selected', async () => {
|
||||||
render(AddRelationshipForm, { personId: 'person-1' });
|
render(AddRelationshipForm, { personId: PID });
|
||||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled();
|
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);
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
render(AddRelationshipForm, { personId: 'person-1', onSubmit });
|
render(AddRelationshipForm, { personId: PID, onSubmit });
|
||||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
|
||||||
const form = document.querySelector('form');
|
expect(document.querySelector('form')?.hasAttribute('action')).toBe(false);
|
||||||
expect(form?.hasAttribute('action')).toBe(false);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows year-range error when toYear is before fromYear', async () => {
|
describe('AddRelationshipForm — edit mode', () => {
|
||||||
render(AddRelationshipForm, { personId: 'person-1' });
|
it('opens pre-filled and labels the submit "Speichern"', async () => {
|
||||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
|
||||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
await expect.element(page.getByRole('button', { name: /^Speichern$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
const fromInput = document.querySelector<HTMLInputElement>('input[name="fromYear"]')!;
|
|
||||||
fromInput.value = '1935';
|
it('pre-fills the from-date as dd.mm.yyyy', async () => {
|
||||||
fromInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
render(AddRelationshipForm, { personId: PID, relationship: editRel() });
|
||||||
|
await expect.element(page.getByRole('combobox', { name: 'Typ' })).toBeInTheDocument();
|
||||||
const toInput = document.querySelector<HTMLInputElement>('input[name="toYear"]')!;
|
const fromInput = document.querySelector<HTMLInputElement>('#fromDate')!;
|
||||||
toInput.value = '1920';
|
await vi.waitFor(() => expect(fromInput.value).toBe('12.05.1923'));
|
||||||
toInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
});
|
||||||
|
|
||||||
await expect.element(page.getByRole('alert')).toBeVisible();
|
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');
|
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 () => {
|
it('cancel button closes the form', async () => {
|
||||||
render(AddRelationshipForm, { props: { personId: 'p-1' } });
|
render(AddRelationshipForm, { props: { personId: 'p-1' } });
|
||||||
|
|
||||||
@@ -136,25 +106,4 @@ describe('AddRelationshipForm', () => {
|
|||||||
expect(submitBtn!.disabled).toBe(true);
|
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 {
|
interface Props {
|
||||||
chipLabel: string;
|
chipLabel: string;
|
||||||
otherName: string;
|
otherName: string;
|
||||||
yearRange?: string;
|
dateRange?: string;
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
relId: string;
|
relId: string;
|
||||||
|
onEdit?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props();
|
let { chipLabel, otherName, dateRange = '', canWrite, relId, onEdit }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<li class="flex items-center gap-2 py-2">
|
<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">
|
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
|
||||||
{otherName}
|
{otherName}
|
||||||
</span>
|
</span>
|
||||||
{#if yearRange}
|
{#if dateRange}
|
||||||
<span class="shrink-0 font-sans text-xs text-ink-3" data-testid="year-range">{yearRange}</span>
|
<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}
|
||||||
{#if canWrite}
|
{#if canWrite}
|
||||||
<form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0">
|
<form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ afterEach(cleanup);
|
|||||||
const baseProps = {
|
const baseProps = {
|
||||||
chipLabel: 'Elternteil',
|
chipLabel: 'Elternteil',
|
||||||
otherName: 'Anna Schmidt',
|
otherName: 'Anna Schmidt',
|
||||||
yearRange: '',
|
dateRange: '',
|
||||||
canWrite: false,
|
canWrite: false,
|
||||||
relId: 'rel-1'
|
relId: 'rel-1'
|
||||||
};
|
};
|
||||||
@@ -26,30 +26,55 @@ describe('RelationshipChip', () => {
|
|||||||
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows year range when provided', async () => {
|
it('shows the date range when provided', async () => {
|
||||||
render(RelationshipChip, { ...baseProps, yearRange: '1920–1980' });
|
render(RelationshipChip, { ...baseProps, dateRange: '12. Mai 1923 – 1958' });
|
||||||
await expect.element(page.getByText('1920–1980')).toBeInTheDocument();
|
await expect.element(page.getByText('12. Mai 1923 – 1958')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show year range span when empty', async () => {
|
it('does not render a date-range span when empty', async () => {
|
||||||
render(RelationshipChip, { ...baseProps, yearRange: '' });
|
render(RelationshipChip, { ...baseProps, dateRange: '' });
|
||||||
expect(document.querySelector('[data-testid="year-range"]')).toBeNull();
|
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 });
|
render(RelationshipChip, { ...baseProps, canWrite: true });
|
||||||
await expect.element(page.getByRole('button')).toBeInTheDocument();
|
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 });
|
render(RelationshipChip, { ...baseProps, canWrite: false });
|
||||||
expect(document.querySelector('button')).toBeNull();
|
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 });
|
render(RelationshipChip, { ...baseProps, canWrite: true });
|
||||||
const btn = document.querySelector('button')!;
|
const btn = document.querySelector('button')!;
|
||||||
expect(btn.className).toContain('h-11');
|
expect(btn.className).toContain('h-11');
|
||||||
expect(btn.className).toContain('w-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',
|
personDisplayName: 'Alice',
|
||||||
relatedPersonDisplayName: 'Bob',
|
relatedPersonDisplayName: 'Bob',
|
||||||
relationType,
|
relationType,
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN',
|
||||||
...override
|
...override
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
|
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/relationshipLabels';
|
||||||
|
import { formatRelationshipDateRange } from '$lib/person/relationshipDates';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
@@ -35,18 +36,37 @@ function otherId(rel: RelationshipDTO): string {
|
|||||||
{#if relationships.length > 0}
|
{#if relationships.length > 0}
|
||||||
<ul class="mb-4 space-y-2">
|
<ul class="mb-4 space-y-2">
|
||||||
{#each relationships as rel (rel.id)}
|
{#each relationships as rel (rel.id)}
|
||||||
<li class="flex items-center gap-2">
|
{@const dateRange = formatRelationshipDateRange(
|
||||||
<span
|
rel.fromDate,
|
||||||
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"
|
rel.fromDatePrecision,
|
||||||
>
|
rel.toDate,
|
||||||
{chipLabel(rel, personId)}
|
rel.toDatePrecision
|
||||||
</span>
|
)}
|
||||||
<a
|
<li class="flex flex-col gap-0.5">
|
||||||
href="/persons/{otherId(rel)}"
|
<div class="flex items-center gap-2">
|
||||||
class="min-w-0 flex-1 truncate font-serif text-sm text-ink hover:underline"
|
<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"
|
||||||
{otherName(rel, personId)}
|
>
|
||||||
</a>
|
{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>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ describe('PersonRelationshipsCard', () => {
|
|||||||
relatedPersonId: SPOUSE_ID,
|
relatedPersonId: SPOUSE_ID,
|
||||||
personDisplayName: 'Anna Müller',
|
personDisplayName: 'Anna Müller',
|
||||||
relatedPersonDisplayName: 'Bertha Müller',
|
relatedPersonDisplayName: 'Bertha Müller',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
inferredRelationships: [
|
inferredRelationships: [
|
||||||
@@ -65,7 +67,9 @@ describe('PersonRelationshipsCard', () => {
|
|||||||
relatedPersonId: PARENT_ID,
|
relatedPersonId: PARENT_ID,
|
||||||
personDisplayName: 'Anna Müller',
|
personDisplayName: 'Anna Müller',
|
||||||
relatedPersonDisplayName: 'Kind Müller',
|
relatedPersonDisplayName: 'Kind Müller',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
inferredRelationships: []
|
inferredRelationships: []
|
||||||
@@ -84,7 +88,9 @@ describe('PersonRelationshipsCard', () => {
|
|||||||
relatedPersonId: SPOUSE_ID,
|
relatedPersonId: SPOUSE_ID,
|
||||||
personDisplayName: 'Anna',
|
personDisplayName: 'Anna',
|
||||||
relatedPersonDisplayName: 'Bertha',
|
relatedPersonDisplayName: 'Bertha',
|
||||||
relationType: 'SPOUSE_OF'
|
relationType: 'SPOUSE_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
inferredRelationships: [
|
inferredRelationships: [
|
||||||
@@ -113,7 +119,9 @@ describe('PersonRelationshipsCard', () => {
|
|||||||
relatedPersonId: PERSON_ID,
|
relatedPersonId: PERSON_ID,
|
||||||
personDisplayName: 'Eltern Müller',
|
personDisplayName: 'Eltern Müller',
|
||||||
relatedPersonDisplayName: 'Anna Müller',
|
relatedPersonDisplayName: 'Anna Müller',
|
||||||
relationType: 'PARENT_OF'
|
relationType: 'PARENT_OF',
|
||||||
|
fromDatePrecision: 'UNKNOWN',
|
||||||
|
toDatePrecision: 'UNKNOWN'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
inferredRelationships: []
|
inferredRelationships: []
|
||||||
@@ -121,4 +129,74 @@ describe('PersonRelationshipsCard', () => {
|
|||||||
|
|
||||||
await expect.element(page.getByText('Kind von')).toBeInTheDocument();
|
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 { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
import type { DatePrecision } from '$lib/shared/utils/documentDate';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
import {
|
import {
|
||||||
normalizePersonType,
|
normalizePersonType,
|
||||||
validatePersonFields,
|
validatePersonFields,
|
||||||
resolveValidationMessage
|
resolveValidationMessage
|
||||||
} from '$lib/person/person-validation';
|
} 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 }) {
|
export async function load({ params, fetch, locals }) {
|
||||||
const canWrite =
|
const canWrite =
|
||||||
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
|
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
|
||||||
@@ -193,40 +221,45 @@ export const actions = {
|
|||||||
|
|
||||||
addRelationship: async ({ request, params, fetch }) => {
|
addRelationship: async ({ request, params, fetch }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const relatedPersonId = formData.get('relatedPersonId')?.toString();
|
const fields = parseRelationshipForm(formData);
|
||||||
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;
|
|
||||||
|
|
||||||
if (!relatedPersonId || !relationType) {
|
if (!fields.relatedPersonId || !fields.relationType) {
|
||||||
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
||||||
}
|
}
|
||||||
if (relatedPersonId === params.id) {
|
if (fields.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
|
|
||||||
) {
|
|
||||||
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
const result = await api.POST('/api/persons/{id}/relationships', {
|
const result = await api.POST('/api/persons/{id}/relationships', {
|
||||||
params: { path: { id: params.id } },
|
params: { path: { id: params.id } },
|
||||||
body: {
|
body: fields.body
|
||||||
relatedPersonId,
|
});
|
||||||
relationType,
|
|
||||||
...(fromYear !== undefined && !Number.isNaN(fromYear) ? { fromYear } : {}),
|
if (!result.response.ok) {
|
||||||
...(toYear !== undefined && !Number.isNaN(toYear) ? { toYear } : {}),
|
return fail(result.response.status, {
|
||||||
...(notes ? { notes } : {})
|
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) {
|
if (!result.response.ok) {
|
||||||
|
|||||||
@@ -97,3 +97,98 @@ describe('persons/[id]/edit update action — generation (#689)', () => {
|
|||||||
expect(body).toHaveProperty('generation', 3);
|
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