Compare commits
187 Commits
feat/issue
...
8c7f3b2e4e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c7f3b2e4e | ||
|
|
52178c2f5b | ||
|
|
54b4b96411 | ||
|
|
c905b81fd3 | ||
|
|
db66d0cc61 | ||
|
|
7dc5dc6f71 | ||
|
|
d974d39d17 | ||
|
|
5e4e487d5f | ||
|
|
b3fe9b1171 | ||
|
|
3c7c7a9aa4 | ||
|
|
9908f7afdc | ||
|
|
96d9ff5db1 | ||
|
|
0113367d05 | ||
|
|
fb6bffd7ee | ||
|
|
b087de84c4 | ||
|
|
3e07f6798c | ||
|
|
bc0824b934 | ||
|
|
7ccd541d40 | ||
|
|
835dc77382 | ||
|
|
37edac4da6 | ||
|
|
49443ad16a | ||
|
|
e6844c403c | ||
|
|
f1932fd5f6 | ||
|
|
ba88febc77 | ||
|
|
fa7b97acdc | ||
|
|
6ef888a128 | ||
|
|
94d0733412 | ||
|
|
4ac94b2feb | ||
|
|
392af640c4 | ||
|
|
7a25feb04e | ||
|
|
d87ad36278 | ||
|
|
39ddf90725 | ||
|
|
e5634c301e | ||
|
|
68cb6e9b76 | ||
|
|
5591f95871 | ||
|
|
41a57c0dc8 | ||
|
|
2d19ca7244 | ||
|
|
bc58d77f2c | ||
|
|
515fa03088 | ||
|
|
060a1149e0 | ||
|
|
558e1e6b22 | ||
|
|
6dd60571e3 | ||
|
|
3365f5845e | ||
|
|
3faac13533 | ||
|
|
5890bb3abd | ||
|
|
060db69108 | ||
|
|
1842e23c81 | ||
|
|
26519d029a | ||
|
|
488d4384a1 | ||
|
|
6a6967d841 | ||
|
|
ae868f4110 | ||
|
|
1fd38830fe | ||
|
|
c9c395eb59 | ||
|
|
c247e1e971 | ||
|
|
eb6e21f032 | ||
|
|
b4b46a0a79 | ||
|
|
ba73387d50 | ||
|
|
d9c7abf2ab | ||
|
|
7fc56022ae | ||
|
|
e8ba840560 | ||
|
|
09f71a2dce | ||
|
|
86ad5ca9b3 | ||
|
|
780c682136 | ||
|
|
a8a3b7f574 | ||
|
|
f0bb1c3163 | ||
|
|
cacbd57752 | ||
|
|
43aacd9f60 | ||
|
|
362a84dde9 | ||
|
|
49db82e1bd | ||
|
|
fd3a44d10c | ||
|
|
cb51e8e432 | ||
|
|
bbde9e8497 | ||
|
|
793496440c | ||
|
|
e3175f493c | ||
|
|
64a61f705c | ||
|
|
e50aab2578 | ||
|
|
02d3e2ab61 | ||
|
|
c4ee2c666b | ||
|
|
bf8fb00dd2 | ||
|
|
b3ce15f0dd | ||
|
|
c7013f4902 | ||
|
|
091f6c7592 | ||
|
|
3a6f90441e | ||
|
|
13e0801b30 | ||
|
|
4c3aa159c5 | ||
|
|
eb51155b4e | ||
|
|
43f474fc5b | ||
|
|
8ca3f37817 | ||
|
|
1dc812bd47 | ||
|
|
7a647b5633 | ||
|
|
5f76d4a1ac | ||
|
|
c7958681f5 | ||
|
|
1f3f879f9c | ||
|
|
7906373053 | ||
|
|
2d48821f95 | ||
|
|
0def9e9b9d | ||
|
|
acffcc8516 | ||
|
|
48492330a7 | ||
|
|
d924d9059c | ||
|
|
99aee777de | ||
|
|
8b498665df | ||
|
|
5ebe1f1a5a | ||
|
|
221a6af838 | ||
|
|
404d874b4e | ||
|
|
4bc4267e5a | ||
|
|
bd17532118 | ||
|
|
e021261300 | ||
|
|
e94ffde075 | ||
|
|
29a1df5d9c | ||
|
|
4d288589fa | ||
|
|
a2c633c5de | ||
|
|
28112e1d7b | ||
|
|
08e7987033 | ||
|
|
1db0f38f62 | ||
|
|
4e8df66a79 | ||
|
|
80ddfb47ac | ||
|
|
7805da52e6 | ||
|
|
0f3e000379 | ||
|
|
b435fd69f7 | ||
|
|
a6c8db226d | ||
|
|
e833d1f71a | ||
|
|
5d82a3e471 | ||
|
|
cb93f55396 | ||
|
|
3cfaae06da | ||
|
|
a81323a7a1 | ||
|
|
10b1bab57b | ||
|
|
000333d540 | ||
|
|
5817a79151 | ||
|
|
3b430828b7 | ||
|
|
f8aa8c6574 | ||
|
|
ce005622f2 | ||
|
|
0e9fa157e5 | ||
|
|
fa1dfbc99d | ||
|
|
eb91639a5e | ||
|
|
43fb51305e | ||
|
|
6babcc7f17 | ||
|
|
1754b96b18 | ||
|
|
d230156651 | ||
|
|
93f4a00032 | ||
|
|
ea97bdd869 | ||
|
|
cbaff016d0 | ||
|
|
0b3455dbb2 | ||
|
|
499d0a3ca8 | ||
|
|
bd3feda182 | ||
|
|
f2127e2814 | ||
|
|
13bb3b451e | ||
|
|
6074ac396f | ||
|
|
b6253cb023 | ||
|
|
e94e9a3573 | ||
|
|
06ecad5e74 | ||
|
|
fcfae8fb78 | ||
|
|
83de7ff673 | ||
|
|
48649e67f9 | ||
|
|
1d14c32c23 | ||
|
|
d27fed3c35 | ||
|
|
22752ac1ae | ||
|
|
7a3d919c2d | ||
|
|
b969bcd877 | ||
|
|
cd26057ea5 | ||
|
|
ccbcbca0e8 | ||
|
|
c40cc05f68 | ||
|
|
a021355072 | ||
|
|
8971fee75e | ||
|
|
48a704f002 | ||
|
|
a7b1dcb5e1 | ||
|
|
f382bd9974 | ||
|
|
d7f4f6f163 | ||
|
|
242e10179d | ||
|
|
aaf885cafd | ||
|
|
b658a13247 | ||
|
|
6bed617959 | ||
|
|
51db976348 | ||
|
|
fc46704144 | ||
|
|
050f2bc929 | ||
|
|
f29f4d3f5b | ||
|
|
790c6f5b02 | ||
|
|
acea4a60f2 | ||
|
|
25f62ce93b | ||
|
|
df6175ed2c | ||
| f6cf2e0e42 | |||
|
|
33ca2df45b | ||
|
|
0979302205 | ||
|
|
9fb2c025cf | ||
|
|
ee2de8135b | ||
|
|
fe13df574a | ||
|
|
a9080e9dab | ||
|
|
e8a1cc82ff |
@@ -6,6 +6,7 @@ import jakarta.validation.ConstraintViolationException;
|
|||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
@@ -47,6 +48,12 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleMessageNotReadable(HttpMessageNotReadableException ex) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "Invalid request body"));
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(ResponseStatusException.class)
|
@ExceptionHandler(ResponseStatusException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
||||||
return ResponseEntity.status(ex.getStatusCode())
|
return ResponseEntity.status(ex.getStatusCode())
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ public class PersonController {
|
|||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
|
@RequirePermission(Permission.READ_ALL)
|
||||||
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
|
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
|
||||||
return ResponseEntity.ok(personService.findAll(q));
|
return ResponseEntity.ok(personService.findAll(q));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
|
@RequirePermission(Permission.READ_ALL)
|
||||||
public Person getPerson(@PathVariable UUID id) {
|
public Person getPerson(@PathVariable UUID id) {
|
||||||
return personService.getById(id);
|
return personService.getById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
|
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
|
||||||
@@ -45,7 +46,7 @@ public class TranscriptionBlockController {
|
|||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public TranscriptionBlock createBlock(
|
public TranscriptionBlock createBlock(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@RequestBody CreateTranscriptionBlockDTO dto,
|
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
UUID userId = requireUserId(authentication);
|
UUID userId = requireUserId(authentication);
|
||||||
return transcriptionService.createBlock(documentId, dto, userId);
|
return transcriptionService.createBlock(documentId, dto, userId);
|
||||||
@@ -56,7 +57,7 @@ public class TranscriptionBlockController {
|
|||||||
public TranscriptionBlock updateBlock(
|
public TranscriptionBlock updateBlock(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID blockId,
|
@PathVariable UUID blockId,
|
||||||
@RequestBody UpdateTranscriptionBlockDTO dto,
|
@Valid @RequestBody UpdateTranscriptionBlockDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
UUID userId = requireUserId(authentication);
|
UUID userId = requireUserId(authentication);
|
||||||
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
|
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import jakarta.validation.constraints.Min;
|
import jakarta.validation.constraints.Min;
|
||||||
import jakarta.validation.constraints.Positive;
|
import jakarta.validation.constraints.Positive;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.model.PersonMention;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
public class CreateTranscriptionBlockDTO {
|
public class CreateTranscriptionBlockDTO {
|
||||||
@Min(0)
|
@Min(0)
|
||||||
private int pageNumber;
|
private int pageNumber;
|
||||||
@@ -22,4 +29,8 @@ public class CreateTranscriptionBlockDTO {
|
|||||||
private double height;
|
private double height;
|
||||||
private String text;
|
private String text;
|
||||||
private String label;
|
private String label;
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
@Builder.Default
|
||||||
|
private List<PersonMention> mentionedPersons = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public interface PersonSummaryDTO {
|
|||||||
Integer getBirthYear();
|
Integer getBirthYear();
|
||||||
Integer getDeathYear();
|
Integer getDeathYear();
|
||||||
String getNotes();
|
String getNotes();
|
||||||
|
boolean isFamilyMember();
|
||||||
long getDocumentCount();
|
long getDocumentCount();
|
||||||
|
|
||||||
default String getDisplayName() {
|
default String getDisplayName() {
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.model.PersonMention;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
public class UpdateTranscriptionBlockDTO {
|
public class UpdateTranscriptionBlockDTO {
|
||||||
private String text;
|
private String text;
|
||||||
private String label;
|
private String label;
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
@Builder.Default
|
||||||
|
private List<PersonMention> mentionedPersons = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ public enum ErrorCode {
|
|||||||
ALIAS_NOT_FOUND,
|
ALIAS_NOT_FOUND,
|
||||||
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
||||||
INVALID_PERSON_TYPE,
|
INVALID_PERSON_TYPE,
|
||||||
|
|
||||||
// --- Documents ---
|
// --- Documents ---
|
||||||
/** A document with the given ID does not exist. 404 */
|
/** A document with the given ID does not exist. 404 */
|
||||||
DOCUMENT_NOT_FOUND,
|
DOCUMENT_NOT_FOUND,
|
||||||
@@ -96,6 +95,14 @@ public enum ErrorCode {
|
|||||||
/** Internal inconsistency: expected training run row was not found after creation. 500 */
|
/** Internal inconsistency: expected training run row was not found after creation. 500 */
|
||||||
OCR_TRAINING_CONFLICT,
|
OCR_TRAINING_CONFLICT,
|
||||||
|
|
||||||
|
// --- Relationships (Stammbaum) ---
|
||||||
|
/** A relationship row with the given ID does not exist. 404 */
|
||||||
|
RELATIONSHIP_NOT_FOUND,
|
||||||
|
/** Adding this relationship would create a cycle (e.g. reverse PARENT_OF already exists). 409 */
|
||||||
|
CIRCULAR_RELATIONSHIP,
|
||||||
|
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
|
||||||
|
DUPLICATE_RELATIONSHIP,
|
||||||
|
|
||||||
// --- Tags ---
|
// --- Tags ---
|
||||||
/** A tag with the given ID does not exist. 404 */
|
/** A tag with the given ID does not exist. 404 */
|
||||||
TAG_NOT_FOUND,
|
TAG_NOT_FOUND,
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ public class Person {
|
|||||||
private Integer birthYear;
|
private Integer birthYear;
|
||||||
private Integer deathYear;
|
private Integer deathYear;
|
||||||
|
|
||||||
|
@Column(name = "family_member", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private boolean familyMember = false;
|
||||||
|
|
||||||
// Entity-graph navigation for JPA JOIN queries (e.g. DocumentSpecifications.hasText).
|
// Entity-graph navigation for JPA JOIN queries (e.g. DocumentSpecifications.hasText).
|
||||||
// Uses entity relationship rather than cross-domain repository access, avoiding a
|
// Uses entity relationship rather than cross-domain repository access, avoiding a
|
||||||
// separate DB roundtrip while respecting domain boundaries.
|
// separate DB roundtrip while respecting domain boundaries.
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Embeddable;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Embeddable
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class PersonMention {
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "person_id", nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private UUID personId;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Size(max = 200)
|
||||||
|
@Column(name = "display_name", nullable = false, length = 200)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
// Archival: the text the transcriber typed after @. Never updated on person rename.
|
||||||
|
private String displayName;
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import org.hibernate.annotations.CreationTimestamp;
|
|||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -33,6 +35,16 @@ public class TranscriptionBlock {
|
|||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String text;
|
private String text;
|
||||||
|
|
||||||
|
// EAGER: mention set is bounded by block text length (typically < 20 entries).
|
||||||
|
// Switching back to LAZY requires callers to be inside an open Hibernate session.
|
||||||
|
@ElementCollection(fetch = FetchType.EAGER)
|
||||||
|
@CollectionTable(
|
||||||
|
name = "transcription_block_mentioned_persons",
|
||||||
|
joinColumns = @JoinColumn(name = "block_id"))
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
@Builder.Default
|
||||||
|
private List<PersonMention> mentionedPersons = new ArrayList<>();
|
||||||
|
|
||||||
@Column(length = 200)
|
@Column(length = 200)
|
||||||
private String label;
|
private String label;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "person_relationships")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@ToString(exclude = "notes")
|
||||||
|
public class PersonRelationship {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "person_id", nullable = false)
|
||||||
|
@JsonIgnore
|
||||||
|
private Person person;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "related_person_id", nullable = false)
|
||||||
|
@JsonIgnore
|
||||||
|
private Person relatedPerson;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "relation_type", nullable = false, length = 30)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private RelationType relationType;
|
||||||
|
|
||||||
|
@Column(name = "from_year")
|
||||||
|
private Integer fromYear;
|
||||||
|
|
||||||
|
@Column(name = "to_year")
|
||||||
|
private Integer toYear;
|
||||||
|
|
||||||
|
@Column(length = 2000)
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", updatable = false, nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private Instant createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface PersonRelationshipRepository extends JpaRepository<PersonRelationship, UUID> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk fetch for the network endpoint — pulls only edges of the given types.
|
||||||
|
* The service filters by family_member afterwards.
|
||||||
|
*/
|
||||||
|
@Query("SELECT r FROM PersonRelationship r " +
|
||||||
|
"JOIN FETCH r.person " +
|
||||||
|
"JOIN FETCH r.relatedPerson " +
|
||||||
|
"WHERE r.relationType IN :types")
|
||||||
|
List<PersonRelationship> findAllByRelationTypeIn(@Param("types") Collection<RelationType> types);
|
||||||
|
|
||||||
|
/** Used for the circular-PARENT_OF check in {@code addRelationship}. */
|
||||||
|
boolean existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
|
UUID personId, UUID relatedPersonId, RelationType relationType);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All edges incident on {@code personId} (either side) restricted to the given types.
|
||||||
|
* Used by the inference service to load a person's local subgraph for BFS.
|
||||||
|
*/
|
||||||
|
@Query("SELECT r FROM PersonRelationship r " +
|
||||||
|
"WHERE (r.person.id = :personId OR r.relatedPerson.id = :personId) " +
|
||||||
|
"AND r.relationType IN :types")
|
||||||
|
List<PersonRelationship> findAllByPersonIdOrRelatedPersonIdAndRelationTypeIn(
|
||||||
|
@Param("personId") UUID personId,
|
||||||
|
@Param("types") Collection<RelationType> types);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All edges incident on {@code personId} (either side), all types.
|
||||||
|
* Used by the "direct relationships" listings (person edit, side panel).
|
||||||
|
*/
|
||||||
|
@Query("SELECT r FROM PersonRelationship r " +
|
||||||
|
"JOIN FETCH r.person " +
|
||||||
|
"JOIN FETCH r.relatedPerson " +
|
||||||
|
"WHERE r.person.id = :personId OR r.relatedPerson.id = :personId")
|
||||||
|
List<PersonRelationship> findAllByPersonIdOrRelatedPersonId(@Param("personId") UUID personId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract direction tokens emitted by the BFS in {@link RelationshipInferenceService}.
|
||||||
|
* A path is a list of these tokens — e.g. niece-of-me is {@code [SIBLING, DOWN]}.
|
||||||
|
*
|
||||||
|
* <p>Reversing a path swaps {@link #UP} ↔ {@link #DOWN} and leaves the symmetric
|
||||||
|
* tokens ({@link #SPOUSE}, {@link #SIBLING}) untouched.
|
||||||
|
*/
|
||||||
|
public enum RelationToken {
|
||||||
|
UP,
|
||||||
|
DOWN,
|
||||||
|
SPOUSE,
|
||||||
|
SIBLING;
|
||||||
|
|
||||||
|
public RelationToken reverse() {
|
||||||
|
return switch (this) {
|
||||||
|
case UP -> DOWN;
|
||||||
|
case DOWN -> UP;
|
||||||
|
case SPOUSE -> SPOUSE;
|
||||||
|
case SIBLING -> SIBLING;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Family-network relationship taxonomy.
|
||||||
|
*
|
||||||
|
* <p>Symmetric types ({@link #SPOUSE_OF}, {@link #SIBLING_OF}) are stored once;
|
||||||
|
* the inference service walks them in both directions. {@link #PARENT_OF} is
|
||||||
|
* directional: A PARENT_OF B means A is the parent.
|
||||||
|
*/
|
||||||
|
public enum RelationType {
|
||||||
|
PARENT_OF,
|
||||||
|
SPOUSE_OF,
|
||||||
|
SIBLING_OF,
|
||||||
|
FRIEND,
|
||||||
|
COLLEAGUE,
|
||||||
|
EMPLOYER,
|
||||||
|
DOCTOR,
|
||||||
|
NEIGHBOR,
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.FamilyMemberPatchDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stammbaum API. Endpoints split across two roots:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code /api/network} — the family graph</li>
|
||||||
|
* <li>{@code /api/persons/{id}/...} — per-person relationship operations
|
||||||
|
* (PersonController is intentionally left untouched)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RelationshipController {
|
||||||
|
|
||||||
|
private final RelationshipService relationshipService;
|
||||||
|
|
||||||
|
// READ endpoints carry no @RequirePermission: all authenticated users may read the family graph.
|
||||||
|
// Unauthenticated requests are rejected by Spring Security's anyRequest().authenticated() rule.
|
||||||
|
|
||||||
|
@GetMapping("/api/network")
|
||||||
|
public NetworkDTO getNetwork() {
|
||||||
|
return relationshipService.getFamilyNetwork();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/persons/{id}/relationships")
|
||||||
|
public List<RelationshipDTO> getRelationships(@PathVariable UUID id) {
|
||||||
|
return relationshipService.getRelationships(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/persons/{id}/inferred-relationships")
|
||||||
|
public List<InferredRelationshipWithPersonDTO> getInferredRelationships(@PathVariable UUID id) {
|
||||||
|
return relationshipService.getInferredRelationships(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/persons/{aId}/relationship-to/{bId}")
|
||||||
|
public InferredRelationshipDTO getRelationshipBetween(@PathVariable UUID aId, @PathVariable UUID bId) {
|
||||||
|
return relationshipService.getRelationshipBetween(aId, bId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
|
ErrorCode.RELATIONSHIP_NOT_FOUND, "No relationship path between " + aId + " and " + bId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/persons/{id}/relationships")
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public ResponseEntity<RelationshipDTO> addRelationship(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
@Valid @RequestBody CreateRelationshipRequest dto) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
|
.body(relationshipService.addRelationship(id, dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/api/persons/{id}/relationships/{relId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public void deleteRelationship(@PathVariable UUID id, @PathVariable UUID relId) {
|
||||||
|
relationshipService.deleteRelationship(id, relId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/api/persons/{id}/family-member")
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public Person patchFamilyMember(@PathVariable UUID id, @RequestBody FamilyMemberPatchDTO dto) {
|
||||||
|
return relationshipService.setFamilyMember(id, dto.familyMember());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
|
||||||
|
import org.raddatz.familienarchiv.service.PersonService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives indirect family relationships by BFS over the family-graph subset
|
||||||
|
* (PARENT_OF, SPOUSE_OF, SIBLING_OF). Time-ignorant: from_year / to_year are
|
||||||
|
* not consulted. Siblings are also derived from shared parents — no SIBLING_OF
|
||||||
|
* row is required.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RelationshipInferenceService {
|
||||||
|
|
||||||
|
// 8 hops covers great-grandparents ↔ great-great-grandchildren and second cousins —
|
||||||
|
// the practical horizon for a 1899–1950 family archive. Paths longer than this are
|
||||||
|
// classified as LABEL_DISTANT and rarely carry meaningful relationship labels.
|
||||||
|
static final int MAX_DEPTH = 8;
|
||||||
|
|
||||||
|
/** "distant" is the catch-all label for paths that do not match the LABEL_MAP. */
|
||||||
|
static final String LABEL_DISTANT = "distant";
|
||||||
|
|
||||||
|
private static final Map<List<RelationToken>, String> LABEL_MAP = buildLabelMap();
|
||||||
|
|
||||||
|
private final PersonRelationshipRepository relationshipRepository;
|
||||||
|
private final PersonService personService;
|
||||||
|
|
||||||
|
private static Map<List<RelationToken>, String> buildLabelMap() {
|
||||||
|
Map<List<RelationToken>, String> m = new HashMap<>();
|
||||||
|
m.put(List.of(RelationToken.UP), "parent");
|
||||||
|
m.put(List.of(RelationToken.DOWN), "child");
|
||||||
|
m.put(List.of(RelationToken.SPOUSE), "spouse");
|
||||||
|
m.put(List.of(RelationToken.SIBLING), "sibling");
|
||||||
|
m.put(List.of(RelationToken.UP, RelationToken.UP), "grandparent");
|
||||||
|
m.put(List.of(RelationToken.DOWN, RelationToken.DOWN), "grandchild");
|
||||||
|
m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.UP), "great_grandparent");
|
||||||
|
m.put(List.of(RelationToken.DOWN, RelationToken.DOWN, RelationToken.DOWN), "great_grandchild");
|
||||||
|
m.put(List.of(RelationToken.UP, RelationToken.SIBLING), "uncle_aunt");
|
||||||
|
m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN), "niece_nephew");
|
||||||
|
m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.SIBLING), "great_uncle_aunt");
|
||||||
|
m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN, RelationToken.DOWN), "great_niece_nephew");
|
||||||
|
m.put(List.of(RelationToken.SPOUSE, RelationToken.UP), "inlaw_parent");
|
||||||
|
m.put(List.of(RelationToken.DOWN, RelationToken.SPOUSE), "inlaw_child");
|
||||||
|
m.put(List.of(RelationToken.SPOUSE, RelationToken.SIBLING), "sibling_inlaw");
|
||||||
|
m.put(List.of(RelationToken.SIBLING, RelationToken.SPOUSE), "sibling_inlaw");
|
||||||
|
m.put(List.of(RelationToken.UP, RelationToken.SIBLING, RelationToken.DOWN), "cousin_1");
|
||||||
|
return Collections.unmodifiableMap(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortest token path from {@code from} to {@code to}, or empty if unreachable
|
||||||
|
* within {@link #MAX_DEPTH} hops. Package-private to permit direct path
|
||||||
|
* assertions in unit tests.
|
||||||
|
*/
|
||||||
|
Optional<List<RelationToken>> findShortestPath(UUID from, UUID to) {
|
||||||
|
if (from.equals(to)) return Optional.empty();
|
||||||
|
Map<UUID, List<Edge>> adj = buildAdjacency();
|
||||||
|
return bfs(adj, from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two-sided label between A and B. {@code labelFromA} reads "B is my <labelFromA>". */
|
||||||
|
public Optional<InferredRelationshipDTO> infer(UUID a, UUID b) {
|
||||||
|
Optional<List<RelationToken>> aToB = findShortestPath(a, b);
|
||||||
|
if (aToB.isEmpty()) return Optional.empty();
|
||||||
|
List<RelationToken> path = aToB.get();
|
||||||
|
return Optional.of(new InferredRelationshipDTO(
|
||||||
|
labelFor(path),
|
||||||
|
labelFor(reversePath(path)),
|
||||||
|
path.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All persons reachable from {@code personId} within MAX_DEPTH, with their labels. */
|
||||||
|
public List<InferredRelationshipWithPersonDTO> findAllFor(UUID personId) {
|
||||||
|
Map<UUID, List<Edge>> adj = buildAdjacency();
|
||||||
|
Map<UUID, List<RelationToken>> shortestPaths = bfsAll(adj, personId);
|
||||||
|
shortestPaths.remove(personId);
|
||||||
|
if (shortestPaths.isEmpty()) return List.of();
|
||||||
|
|
||||||
|
List<UUID> ids = new ArrayList<>(shortestPaths.keySet());
|
||||||
|
Map<UUID, Person> byId = new HashMap<>();
|
||||||
|
for (Person p : personService.getAllById(ids)) {
|
||||||
|
byId.put(p.getId(), p);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<InferredRelationshipWithPersonDTO> out = new ArrayList<>();
|
||||||
|
for (UUID id : ids) {
|
||||||
|
Person p = byId.get(id);
|
||||||
|
if (p == null) continue;
|
||||||
|
List<RelationToken> path = shortestPaths.get(id);
|
||||||
|
PersonNodeDTO node = new PersonNodeDTO(
|
||||||
|
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), p.isFamilyMember());
|
||||||
|
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
|
||||||
|
}
|
||||||
|
out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops)
|
||||||
|
.thenComparing(d -> d.person().displayName()));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String labelFor(List<RelationToken> path) {
|
||||||
|
String specific = LABEL_MAP.get(path);
|
||||||
|
return specific != null ? specific : LABEL_DISTANT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<RelationToken> reversePath(List<RelationToken> path) {
|
||||||
|
List<RelationToken> reversed = new ArrayList<>(path.size());
|
||||||
|
for (int i = path.size() - 1; i >= 0; i--) {
|
||||||
|
reversed.add(path.get(i).reverse());
|
||||||
|
}
|
||||||
|
return List.copyOf(reversed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<UUID, List<Edge>> buildAdjacency() {
|
||||||
|
List<PersonRelationship> edges = relationshipRepository.findAllByRelationTypeIn(
|
||||||
|
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
|
||||||
|
Map<UUID, List<Edge>> adj = new HashMap<>();
|
||||||
|
Map<UUID, List<UUID>> parentToChildren = new HashMap<>();
|
||||||
|
|
||||||
|
for (PersonRelationship e : edges) {
|
||||||
|
UUID a = e.getPerson().getId();
|
||||||
|
UUID b = e.getRelatedPerson().getId();
|
||||||
|
switch (e.getRelationType()) {
|
||||||
|
case PARENT_OF -> {
|
||||||
|
addEdge(adj, a, b, RelationToken.DOWN);
|
||||||
|
addEdge(adj, b, a, RelationToken.UP);
|
||||||
|
parentToChildren.computeIfAbsent(a, k -> new ArrayList<>()).add(b);
|
||||||
|
}
|
||||||
|
case SPOUSE_OF -> {
|
||||||
|
addEdge(adj, a, b, RelationToken.SPOUSE);
|
||||||
|
addEdge(adj, b, a, RelationToken.SPOUSE);
|
||||||
|
}
|
||||||
|
case SIBLING_OF -> {
|
||||||
|
addEdge(adj, a, b, RelationToken.SIBLING);
|
||||||
|
addEdge(adj, b, a, RelationToken.SIBLING);
|
||||||
|
}
|
||||||
|
default -> { /* family graph excludes other types */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (List<UUID> children : parentToChildren.values()) {
|
||||||
|
for (int i = 0; i < children.size(); i++) {
|
||||||
|
for (int j = i + 1; j < children.size(); j++) {
|
||||||
|
UUID c1 = children.get(i);
|
||||||
|
UUID c2 = children.get(j);
|
||||||
|
addEdge(adj, c1, c2, RelationToken.SIBLING);
|
||||||
|
addEdge(adj, c2, c1, RelationToken.SIBLING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return adj;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addEdge(Map<UUID, List<Edge>> adj, UUID from, UUID to, RelationToken token) {
|
||||||
|
adj.computeIfAbsent(from, k -> new ArrayList<>()).add(new Edge(to, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<List<RelationToken>> bfs(Map<UUID, List<Edge>> adj, UUID from, UUID to) {
|
||||||
|
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
|
||||||
|
shortest.put(from, List.of());
|
||||||
|
Deque<UUID> queue = new ArrayDeque<>();
|
||||||
|
queue.add(from);
|
||||||
|
while (!queue.isEmpty()) {
|
||||||
|
UUID curr = queue.poll();
|
||||||
|
List<RelationToken> currPath = shortest.get(curr);
|
||||||
|
if (currPath.size() >= MAX_DEPTH) continue;
|
||||||
|
for (Edge e : adj.getOrDefault(curr, List.of())) {
|
||||||
|
if (shortest.containsKey(e.target())) continue;
|
||||||
|
List<RelationToken> nextPath = append(currPath, e.token());
|
||||||
|
shortest.put(e.target(), nextPath);
|
||||||
|
if (e.target().equals(to)) return Optional.of(nextPath);
|
||||||
|
queue.add(e.target());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<UUID, List<RelationToken>> bfsAll(Map<UUID, List<Edge>> adj, UUID from) {
|
||||||
|
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
|
||||||
|
shortest.put(from, List.of());
|
||||||
|
Deque<UUID> queue = new ArrayDeque<>();
|
||||||
|
queue.add(from);
|
||||||
|
while (!queue.isEmpty()) {
|
||||||
|
UUID curr = queue.poll();
|
||||||
|
List<RelationToken> currPath = shortest.get(curr);
|
||||||
|
if (currPath.size() >= MAX_DEPTH) continue;
|
||||||
|
for (Edge e : adj.getOrDefault(curr, List.of())) {
|
||||||
|
if (shortest.containsKey(e.target())) continue;
|
||||||
|
List<RelationToken> nextPath = append(currPath, e.token());
|
||||||
|
shortest.put(e.target(), nextPath);
|
||||||
|
queue.add(e.target());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shortest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<RelationToken> append(List<RelationToken> prefix, RelationToken next) {
|
||||||
|
List<RelationToken> out = new ArrayList<>(prefix.size() + 1);
|
||||||
|
out.addAll(prefix);
|
||||||
|
out.add(next);
|
||||||
|
return List.copyOf(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Edge(UUID target, RelationToken token) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
|
||||||
|
import org.raddatz.familienarchiv.service.PersonService;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the {@code person_relationships} table and the family_member flag.
|
||||||
|
* Always orchestrates {@link PersonService} for cross-domain access — never
|
||||||
|
* touches {@link org.raddatz.familienarchiv.repository.PersonRepository}.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RelationshipService {
|
||||||
|
|
||||||
|
private final PersonRelationshipRepository relationshipRepository;
|
||||||
|
private final PersonService personService;
|
||||||
|
private final RelationshipInferenceService inferenceService;
|
||||||
|
|
||||||
|
public List<RelationshipDTO> getRelationships(UUID personId) {
|
||||||
|
personService.getById(personId);
|
||||||
|
List<PersonRelationship> rels = relationshipRepository.findAllByPersonIdOrRelatedPersonId(personId);
|
||||||
|
return rels.stream().map(RelationshipService::toDTO).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<InferredRelationshipWithPersonDTO> getInferredRelationships(UUID personId) {
|
||||||
|
personService.getById(personId);
|
||||||
|
return inferenceService.findAllFor(personId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<InferredRelationshipDTO> getRelationshipBetween(UUID a, UUID b) {
|
||||||
|
personService.getById(a);
|
||||||
|
personService.getById(b);
|
||||||
|
return inferenceService.infer(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public NetworkDTO getFamilyNetwork() {
|
||||||
|
// Two queries: 1 for nodes (family members), 1 for edges (family-graph types).
|
||||||
|
List<Person> familyMembers = personService.findAllFamilyMembers();
|
||||||
|
Set<UUID> familyIds = new HashSet<>(familyMembers.size());
|
||||||
|
List<PersonNodeDTO> nodes = new ArrayList<>(familyMembers.size());
|
||||||
|
for (Person p : familyMembers) {
|
||||||
|
familyIds.add(p.getId());
|
||||||
|
nodes.add(new PersonNodeDTO(
|
||||||
|
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PersonRelationship> familyEdges = relationshipRepository.findAllByRelationTypeIn(
|
||||||
|
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
|
||||||
|
|
||||||
|
List<RelationshipDTO> edges = new ArrayList<>();
|
||||||
|
for (PersonRelationship r : familyEdges) {
|
||||||
|
UUID p = r.getPerson().getId();
|
||||||
|
UUID rp = r.getRelatedPerson().getId();
|
||||||
|
if (familyIds.contains(p) && familyIds.contains(rp)) {
|
||||||
|
edges.add(toDTO(r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new NetworkDTO(nodes, edges);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
|
||||||
|
if (personId.equals(dto.relatedPersonId())) {
|
||||||
|
throw DomainException.badRequest(
|
||||||
|
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
|
||||||
|
}
|
||||||
|
Person person = personService.getById(personId);
|
||||||
|
Person relatedPerson = personService.getById(dto.relatedPersonId());
|
||||||
|
|
||||||
|
validateYears(dto.fromYear(), dto.toYear());
|
||||||
|
|
||||||
|
if (dto.relationType() == RelationType.PARENT_OF
|
||||||
|
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
|
relatedPerson.getId(), personId, RelationType.PARENT_OF)) {
|
||||||
|
throw DomainException.conflict(
|
||||||
|
ErrorCode.CIRCULAR_RELATIONSHIP,
|
||||||
|
"Reverse PARENT_OF already exists between " + personId + " and " + relatedPerson.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
PersonRelationship rel = PersonRelationship.builder()
|
||||||
|
.person(person)
|
||||||
|
.relatedPerson(relatedPerson)
|
||||||
|
.relationType(dto.relationType())
|
||||||
|
.fromYear(dto.fromYear())
|
||||||
|
.toYear(dto.toYear())
|
||||||
|
.notes(blankToNull(dto.notes()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// saveAndFlush so the unique_rel constraint violates synchronously and is
|
||||||
|
// caught here, not at commit time outside the @Transactional boundary.
|
||||||
|
return toDTO(relationshipRepository.saveAndFlush(rel));
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
throw DomainException.conflict(
|
||||||
|
ErrorCode.DUPLICATE_RELATIONSHIP,
|
||||||
|
"Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + dto.relationType() + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteRelationship(UUID personId, UUID relId) {
|
||||||
|
PersonRelationship rel = relationshipRepository.findById(relId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
|
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
|
||||||
|
|
||||||
|
UUID storageSubject = rel.getPerson().getId();
|
||||||
|
UUID storageObject = rel.getRelatedPerson().getId();
|
||||||
|
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
|
||||||
|
throw DomainException.forbidden(
|
||||||
|
"Relationship " + relId + " does not belong to person " + personId);
|
||||||
|
}
|
||||||
|
relationshipRepository.delete(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Person setFamilyMember(UUID personId, boolean familyMember) {
|
||||||
|
return personService.setFamilyMember(personId, familyMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String s) {
|
||||||
|
return (s == null || s.isBlank()) ? null : s.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateYears(Integer fromYear, Integer toYear) {
|
||||||
|
if (fromYear != null && toYear != null && toYear < fromYear) {
|
||||||
|
throw DomainException.badRequest(
|
||||||
|
ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RelationshipDTO toDTO(PersonRelationship r) {
|
||||||
|
Person p = r.getPerson();
|
||||||
|
Person rp = r.getRelatedPerson();
|
||||||
|
return new RelationshipDTO(
|
||||||
|
r.getId(),
|
||||||
|
p.getId(),
|
||||||
|
rp.getId(),
|
||||||
|
p.getDisplayName(),
|
||||||
|
p.getBirthYear(),
|
||||||
|
p.getDeathYear(),
|
||||||
|
rp.getDisplayName(),
|
||||||
|
rp.getBirthYear(),
|
||||||
|
rp.getDeathYear(),
|
||||||
|
r.getRelationType(),
|
||||||
|
r.getFromYear(),
|
||||||
|
r.getToYear(),
|
||||||
|
r.getNotes());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.raddatz.familienarchiv.relationship.RelationType;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record CreateRelationshipRequest(
|
||||||
|
@NotNull UUID relatedPersonId,
|
||||||
|
@NotNull RelationType relationType,
|
||||||
|
Integer fromYear,
|
||||||
|
Integer toYear,
|
||||||
|
@Size(max = 2000) String notes
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship.dto;
|
||||||
|
|
||||||
|
/** Body for {@code PATCH /api/persons/{id}/family-member}. */
|
||||||
|
public record FamilyMemberPatchDTO(boolean familyMember) {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pairwise inferred relationship for the document badge.
|
||||||
|
* {@code labelFromA} reads "Person B, from A's point of view" and vice-versa
|
||||||
|
* (e.g. A=parent, B=child → labelFromA = "Sohn", labelFromB = "Vater").
|
||||||
|
*/
|
||||||
|
public record InferredRelationshipDTO(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String labelFromA,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String labelFromB,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int hops
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used by {@code GET /api/persons/{id}/inferred-relationships}: each entry
|
||||||
|
* is a derived relationship to another family member, labelled from the
|
||||||
|
* requesting person's perspective.
|
||||||
|
*/
|
||||||
|
public record InferredRelationshipWithPersonDTO(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) PersonNodeDTO person,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String label,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int hops
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Payload for {@code GET /api/network}. Nodes are family members; edges are family-graph relationships. */
|
||||||
|
public record NetworkDTO(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<PersonNodeDTO> nodes,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<RelationshipDTO> edges
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Lightweight node for the Stammbaum tree and inferred-relationship payloads. */
|
||||||
|
public record PersonNodeDTO(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
|
||||||
|
Integer birthYear,
|
||||||
|
Integer deathYear,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean familyMember
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.raddatz.familienarchiv.relationship.RelationType;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire shape for one stored relationship row. Both sides include name + years
|
||||||
|
* so the frontend can render the row from either perspective (e.g. on the
|
||||||
|
* subject's page the row reads "Elternteil von [related]"; on the object's
|
||||||
|
* page it reads "Kind von [person]").
|
||||||
|
*
|
||||||
|
* <p>Storage truth: {@code personId} is the {@code person_id} column,
|
||||||
|
* {@code relatedPersonId} is the {@code related_person_id} column. The
|
||||||
|
* frontend determines orientation by comparing against the viewpoint.
|
||||||
|
*/
|
||||||
|
public record RelationshipDTO(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID personId,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID relatedPersonId,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String personDisplayName,
|
||||||
|
Integer personBirthYear,
|
||||||
|
Integer personDeathYear,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String relatedPersonDisplayName,
|
||||||
|
Integer relatedPersonBirthYear,
|
||||||
|
Integer relatedPersonDeathYear,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
|
||||||
|
Integer fromYear,
|
||||||
|
Integer toYear,
|
||||||
|
String notes
|
||||||
|
) {}
|
||||||
@@ -26,6 +26,9 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
// Hilfsmethode: Alle sortiert laden (für den leeren Status)
|
// Hilfsmethode: Alle sortiert laden (für den leeren Status)
|
||||||
List<Person> findAllByOrderByLastNameAscFirstNameAsc();
|
List<Person> findAllByOrderByLastNameAscFirstNameAsc();
|
||||||
|
|
||||||
|
// Stammbaum-Knoten: alle Personen mit family_member = true.
|
||||||
|
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||||
|
|
||||||
// Lookup by full alias string, used during ODS mass import
|
// Lookup by full alias string, used during ODS mass import
|
||||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
Optional<Person> findByAliasIgnoreCase(String alias);
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||||
|
p.family_member AS familyMember,
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
FROM persons p
|
FROM persons p
|
||||||
@@ -50,6 +54,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||||
|
p.family_member AS familyMember,
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
FROM persons p
|
FROM persons p
|
||||||
@@ -58,7 +63,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes
|
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member
|
||||||
ORDER BY p.last_name ASC, p.first_name ASC
|
ORDER BY p.last_name ASC, p.first_name ASC
|
||||||
""",
|
""",
|
||||||
nativeQuery = true)
|
nativeQuery = true)
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
|
|||||||
|
|
||||||
Optional<TranscriptionBlock> findByAnnotationId(UUID annotationId);
|
Optional<TranscriptionBlock> findByAnnotationId(UUID annotationId);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT DISTINCT b FROM TranscriptionBlock b
|
||||||
|
JOIN FETCH b.mentionedPersons
|
||||||
|
WHERE b.id IN (
|
||||||
|
SELECT bb.id FROM TranscriptionBlock bb JOIN bb.mentionedPersons m WHERE m.personId = :personId
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
List<TranscriptionBlock> findByPersonIdWithMentionsFetched(@Param("personId") UUID personId);
|
||||||
|
|
||||||
void deleteByAnnotationId(UUID annotationId);
|
void deleteByAnnotationId(UUID annotationId);
|
||||||
|
|
||||||
int countByDocumentId(UUID documentId);
|
int countByDocumentId(UUID documentId);
|
||||||
@@ -51,21 +60,25 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
|
|||||||
""")
|
""")
|
||||||
List<TranscriptionBlock> findSegmentationBlocks();
|
List<TranscriptionBlock> findSegmentationBlocks();
|
||||||
|
|
||||||
|
// Uses 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels — aligned with findEligibleKurrentBlocks()
|
||||||
|
// which already used this form (changed from d.scriptType = 'KURRENT' in the original queries).
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT COUNT(b) FROM TranscriptionBlock b
|
SELECT COUNT(b) FROM TranscriptionBlock b
|
||||||
JOIN Document d ON d.id = b.documentId
|
JOIN Document d ON d.id = b.documentId
|
||||||
WHERE b.source = 'MANUAL'
|
WHERE b.source = 'MANUAL'
|
||||||
AND d.sender.id = :personId
|
AND d.sender.id = :personId
|
||||||
AND d.scriptType = 'HANDWRITING_KURRENT'
|
AND 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels
|
||||||
""")
|
""")
|
||||||
long countManualKurrentBlocksByPerson(@Param("personId") UUID personId);
|
long countManualKurrentBlocksByPerson(@Param("personId") UUID personId);
|
||||||
|
|
||||||
|
// Uses 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels — aligned with findEligibleKurrentBlocks()
|
||||||
|
// which already used this form (changed from d.scriptType = 'KURRENT' in the original queries).
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT b FROM TranscriptionBlock b
|
SELECT b FROM TranscriptionBlock b
|
||||||
JOIN Document d ON d.id = b.documentId
|
JOIN Document d ON d.id = b.documentId
|
||||||
WHERE b.source = 'MANUAL'
|
WHERE b.source = 'MANUAL'
|
||||||
AND d.sender.id = :personId
|
AND d.sender.id = :personId
|
||||||
AND d.scriptType = 'HANDWRITING_KURRENT'
|
AND 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels
|
||||||
""")
|
""")
|
||||||
List<TranscriptionBlock> findManualKurrentBlocksByPerson(@Param("personId") UUID personId);
|
List<TranscriptionBlock> findManualKurrentBlocksByPerson(@Param("personId") UUID personId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -58,6 +57,17 @@ public class PersonService {
|
|||||||
return personRepository.findAllById(ids);
|
return personRepository.findAllById(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Person> findAllFamilyMembers() {
|
||||||
|
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Person setFamilyMember(UUID personId, boolean familyMember) {
|
||||||
|
Person person = getById(personId);
|
||||||
|
person.setFamilyMember(familyMember);
|
||||||
|
return personRepository.save(person);
|
||||||
|
}
|
||||||
|
|
||||||
public Optional<Person> findByName(String firstName, String lastName) {
|
public Optional<Person> findByName(String firstName, String lastName) {
|
||||||
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ public class TranscriptionService {
|
|||||||
if (dto.getLabel() != null) {
|
if (dto.getLabel() != null) {
|
||||||
block.setLabel(dto.getLabel());
|
block.setLabel(dto.getLabel());
|
||||||
}
|
}
|
||||||
|
block.getMentionedPersons().clear();
|
||||||
|
block.getMentionedPersons().addAll(dto.getMentionedPersons());
|
||||||
block.setUpdatedBy(userId);
|
block.setUpdatedBy(userId);
|
||||||
|
|
||||||
TranscriptionBlock saved = blockRepository.save(block);
|
TranscriptionBlock saved = blockRepository.save(block);
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Family network: marks a Person as a tree node and stores typed relationships
|
||||||
|
-- between two persons. The tree page (/stammbaum) only shows persons with
|
||||||
|
-- family_member = TRUE. Symmetric types (SPOUSE_OF, SIBLING_OF) are stored once;
|
||||||
|
-- the partial unique index keeps SIBLING_OF pairs from being duplicated in the
|
||||||
|
-- reverse direction.
|
||||||
|
|
||||||
|
ALTER TABLE persons
|
||||||
|
ADD COLUMN family_member BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE TABLE person_relationships (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
|
||||||
|
related_person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
|
||||||
|
relation_type VARCHAR(30) NOT NULL,
|
||||||
|
from_year INTEGER,
|
||||||
|
to_year INTEGER,
|
||||||
|
notes VARCHAR(2000),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT no_self_rel CHECK (person_id <> related_person_id),
|
||||||
|
CONSTRAINT unique_rel UNIQUE (person_id, related_person_id, relation_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_person_rel_person_id ON person_relationships(person_id);
|
||||||
|
CREATE INDEX idx_person_rel_related_person_id ON person_relationships(related_person_id);
|
||||||
|
|
||||||
|
-- Symmetric SIBLING_OF: enforce only one row per unordered pair.
|
||||||
|
CREATE UNIQUE INDEX unique_sibling_pair ON person_relationships (
|
||||||
|
LEAST(person_id, related_person_id),
|
||||||
|
GREATEST(person_id, related_person_id)
|
||||||
|
) WHERE relation_type = 'SIBLING_OF';
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Symmetric SPOUSE_OF: enforce only one row per unordered pair, mirroring the
|
||||||
|
-- SIBLING_OF index added in V54.
|
||||||
|
CREATE UNIQUE INDEX unique_spouse_pair ON person_relationships (
|
||||||
|
LEAST(person_id, related_person_id),
|
||||||
|
GREATEST(person_id, related_person_id)
|
||||||
|
) WHERE relation_type = 'SPOUSE_OF';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Sidecar table for @-mentions inside transcription_blocks.text.
|
||||||
|
-- Each row is one (block_id, person_id, display_name) tuple emitted by the
|
||||||
|
-- typeahead in the transcription editor. block.text contains the literal
|
||||||
|
-- "@DisplayName" — the UUID lives only here so historical text stays clean.
|
||||||
|
--
|
||||||
|
-- Schema choice: child table via @ElementCollection (mirrors the established
|
||||||
|
-- UserGroup.permissions / group_permissions pattern), NOT JSONB. The "show
|
||||||
|
-- all blocks mentioning person X" query on the person detail page joins on
|
||||||
|
-- the indexed person_id column — equally fast as JSONB GIN containment, no
|
||||||
|
-- new dependency. document_comments.comment_mentions stays as a many-to-many
|
||||||
|
-- to AppUser; the divergence is intentional: Person mentions need lazy
|
||||||
|
-- degradation when a person is deleted (no FK), while user mentions don't.
|
||||||
|
--
|
||||||
|
-- No FK on person_id: when a Person is deleted we want @Auguste Raddatz to
|
||||||
|
-- remain visible as plain unlinked text inside the transcription rather than
|
||||||
|
-- vanishing or cascade-deleting the block.
|
||||||
|
|
||||||
|
CREATE TABLE transcription_block_mentioned_persons (
|
||||||
|
block_id UUID NOT NULL REFERENCES transcription_blocks(id) ON DELETE CASCADE,
|
||||||
|
person_id UUID NOT NULL,
|
||||||
|
display_name VARCHAR(200) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tbmp_person_id ON transcription_block_mentioned_persons(person_id);
|
||||||
|
CREATE INDEX idx_tbmp_block_id ON transcription_block_mentioned_persons(block_id);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Prevent duplicate sidecar rows for the same (block, person) pair.
|
||||||
|
-- @ElementCollection uses DELETE+INSERT per update so normal JPA writes can't
|
||||||
|
-- create duplicates, but a raw-SQL import or concurrent bypass of JPA could.
|
||||||
|
ALTER TABLE transcription_block_mentioned_persons
|
||||||
|
ADD CONSTRAINT uq_tbmp_block_person UNIQUE (block_id, person_id);
|
||||||
@@ -57,6 +57,13 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
|
void getPersons_returns403_whenMissingReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/persons"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getPersons_returns200_withEmptyList() throws Exception {
|
void getPersons_returns200_withEmptyList() throws Exception {
|
||||||
when(personService.findAll(null)).thenReturn(Collections.emptyList());
|
when(personService.findAll(null)).thenReturn(Collections.emptyList());
|
||||||
mockMvc.perform(get("/api/persons"))
|
mockMvc.perform(get("/api/persons"))
|
||||||
@@ -64,7 +71,7 @@ class PersonControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getPersons_delegatesQueryParam_toService() throws Exception {
|
void getPersons_delegatesQueryParam_toService() throws Exception {
|
||||||
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
|
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
|
||||||
when(personService.findAll("Hans")).thenReturn(List.of(dto));
|
when(personService.findAll("Hans")).thenReturn(List.of(dto));
|
||||||
@@ -85,6 +92,7 @@ class PersonControllerTest {
|
|||||||
public Integer getBirthYear() { return null; }
|
public Integer getBirthYear() { return null; }
|
||||||
public Integer getDeathYear() { return null; }
|
public Integer getDeathYear() { return null; }
|
||||||
public String getNotes() { return null; }
|
public String getNotes() { return null; }
|
||||||
|
public boolean isFamilyMember() { return false; }
|
||||||
public long getDocumentCount() { return 0; }
|
public long getDocumentCount() { return 0; }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -99,6 +107,13 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
|
void getPerson_returns403_whenMissingReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/persons/{id}", UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getPerson_returns200_whenFound() throws Exception {
|
void getPerson_returns200_whenFound() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build();
|
Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build();
|
||||||
|
|||||||
@@ -183,6 +183,36 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createBlock_returns400_whenMentionedPersonDisplayNameExceeds200Chars() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
|
String longName = "A".repeat(201);
|
||||||
|
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
||||||
|
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
|
||||||
|
+ "\",\"displayName\":\"" + longName + "\"}]}";
|
||||||
|
|
||||||
|
mockMvc.perform(post(URL_BASE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createBlock_returns400_whenMentionedPersonPersonIdIsNull() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
|
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
||||||
|
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||||
|
|
||||||
|
mockMvc.perform(post(URL_BASE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── PUT /api/documents/{id}/transcription-blocks/{blockId} ─────────────
|
// ─── PUT /api/documents/{id}/transcription-blocks/{blockId} ─────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -221,6 +251,34 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(jsonPath("$.label").value("Anrede"));
|
.andExpect(jsonPath("$.label").value("Anrede"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void updateBlock_returns400_whenMentionedPersonDisplayNameExceeds200Chars() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
|
String longName = "A".repeat(201);
|
||||||
|
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
|
||||||
|
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
|
||||||
|
|
||||||
|
mockMvc.perform(put(URL_BLOCK)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void updateBlock_returns400_whenMentionedPersonPersonIdIsNull() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
|
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||||
|
|
||||||
|
mockMvc.perform(put(URL_BLOCK)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updateBlock_returns404_whenBlockDoesNotExist() throws Exception {
|
void updateBlock_returns404_whenBlockDoesNotExist() throws Exception {
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
|
||||||
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doNothing;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
@WebMvcTest(RelationshipController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
|
class RelationshipControllerTest {
|
||||||
|
|
||||||
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean RelationshipService relationshipService;
|
||||||
|
@MockitoBean UserService userService;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
private static final UUID PERSON_ID = UUID.randomUUID();
|
||||||
|
private static final UUID OTHER_ID = UUID.randomUUID();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void getRelationshipBetween_returns404_with_RELATIONSHIP_NOT_FOUND_code_when_no_path() throws Exception {
|
||||||
|
when(relationshipService.getRelationshipBetween(any(), any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/persons/{aId}/relationship-to/{bId}", PERSON_ID, OTHER_ID))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value(ErrorCode.RELATIONSHIP_NOT_FOUND.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getRelationships_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/persons/{id}/relationships", PERSON_ID))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNetwork_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/network"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception {
|
||||||
|
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"familyMember\":true}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void getNetwork_returns200_with_NetworkDTO_for_authenticated_user() throws Exception {
|
||||||
|
PersonNodeDTO node = new PersonNodeDTO(PERSON_ID, "Alice Müller", 1900, 1980, true);
|
||||||
|
RelationshipDTO edge = new RelationshipDTO(
|
||||||
|
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||||
|
"Alice Müller", 1900, 1980,
|
||||||
|
"Bob Müller", 1930, null,
|
||||||
|
RelationType.PARENT_OF, null, null, null);
|
||||||
|
when(relationshipService.getFamilyNetwork())
|
||||||
|
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/network"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.nodes[0].displayName").value("Alice Müller"))
|
||||||
|
.andExpect(jsonPath("$.edges[0].relationType").value("PARENT_OF"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void getInferredRelationships_returns200_with_list_for_authenticated_user() throws Exception {
|
||||||
|
PersonNodeDTO relative = new PersonNodeDTO(OTHER_ID, "Bob Müller", 1930, null, true);
|
||||||
|
InferredRelationshipWithPersonDTO inferred =
|
||||||
|
new InferredRelationshipWithPersonDTO(relative, "Großvater", 2);
|
||||||
|
when(relationshipService.getInferredRelationships(PERSON_ID))
|
||||||
|
.thenReturn(List.of(inferred));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/persons/{id}/inferred-relationships", PERSON_ID))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].label").value("Großvater"))
|
||||||
|
.andExpect(jsonPath("$[0].hops").value(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||||
|
void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||||
|
void addRelationship_returns201_with_RelationshipDTO_for_WRITE_ALL_user() throws Exception {
|
||||||
|
RelationshipDTO created = new RelationshipDTO(
|
||||||
|
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||||
|
"Alice Müller", null, null,
|
||||||
|
"Bob Müller", null, null,
|
||||||
|
RelationType.PARENT_OF, null, null, null);
|
||||||
|
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.relationType").value("PARENT_OF"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||||
|
void deleteRelationship_returns204_for_WRITE_ALL_user() throws Exception {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
doNothing().when(relationshipService).deleteRelationship(any(), any());
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||||
|
import org.raddatz.familienarchiv.service.PersonService;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static java.util.Collections.emptyList;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.raddatz.familienarchiv.relationship.RelationToken.*;
|
||||||
|
import static org.raddatz.familienarchiv.relationship.RelationType.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Felix Brandt — TDD red phase for RelationshipInferenceService.
|
||||||
|
* <p>
|
||||||
|
* 18 unit tests, one per LABEL_MAP entry plus one no-path case. Each setup wires
|
||||||
|
* a small graph through the mocked repository and asserts the exact abstract
|
||||||
|
* token sequence emitted by BFS — except {@code distant_label_for_long_chain}
|
||||||
|
* which asserts the fallback label, and {@code returns_empty_when_no_path}
|
||||||
|
* which asserts no result.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class RelationshipInferenceServiceTest {
|
||||||
|
|
||||||
|
@Mock PersonRelationshipRepository relationshipRepository;
|
||||||
|
@Mock PersonService personService;
|
||||||
|
@InjectMocks RelationshipInferenceService service;
|
||||||
|
|
||||||
|
// --- 1: parent ---
|
||||||
|
@Test
|
||||||
|
void parent_path_emits_UP() {
|
||||||
|
Person parent = person();
|
||||||
|
Person child = person();
|
||||||
|
givenEdges(parentOf(parent, child));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(child.getId(), parent.getId()))
|
||||||
|
.hasValue(List.of(UP));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2: child ---
|
||||||
|
@Test
|
||||||
|
void child_path_emits_DOWN() {
|
||||||
|
Person parent = person();
|
||||||
|
Person child = person();
|
||||||
|
givenEdges(parentOf(parent, child));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(parent.getId(), child.getId()))
|
||||||
|
.hasValue(List.of(DOWN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3: spouse ---
|
||||||
|
@Test
|
||||||
|
void spouse_path_emits_SPOUSE() {
|
||||||
|
Person a = person();
|
||||||
|
Person b = person();
|
||||||
|
givenEdges(spouseOf(a, b));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(a.getId(), b.getId()))
|
||||||
|
.hasValue(List.of(SPOUSE));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4: sibling ---
|
||||||
|
@Test
|
||||||
|
void sibling_path_emits_SIBLING() {
|
||||||
|
Person a = person();
|
||||||
|
Person b = person();
|
||||||
|
givenEdges(siblingOf(a, b));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(a.getId(), b.getId()))
|
||||||
|
.hasValue(List.of(SIBLING));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5: grandparent (UP, UP) ---
|
||||||
|
@Test
|
||||||
|
void grandparent_path_emits_UP_UP() {
|
||||||
|
Person grandparent = person();
|
||||||
|
Person parent = person();
|
||||||
|
Person grandchild = person();
|
||||||
|
givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(grandchild.getId(), grandparent.getId()))
|
||||||
|
.hasValue(List.of(UP, UP));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 6: grandchild (DOWN, DOWN) ---
|
||||||
|
@Test
|
||||||
|
void grandchild_path_emits_DOWN_DOWN() {
|
||||||
|
Person grandparent = person();
|
||||||
|
Person parent = person();
|
||||||
|
Person grandchild = person();
|
||||||
|
givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(grandparent.getId(), grandchild.getId()))
|
||||||
|
.hasValue(List.of(DOWN, DOWN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 7: great-grandparent (UP, UP, UP) ---
|
||||||
|
@Test
|
||||||
|
void great_grandparent_path_emits_UP_UP_UP() {
|
||||||
|
Person g = person();
|
||||||
|
Person p = person();
|
||||||
|
Person c = person();
|
||||||
|
Person gc = person();
|
||||||
|
givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(gc.getId(), g.getId()))
|
||||||
|
.hasValue(List.of(UP, UP, UP));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 8: great-grandchild (DOWN, DOWN, DOWN) ---
|
||||||
|
@Test
|
||||||
|
void great_grandchild_path_emits_DOWN_DOWN_DOWN() {
|
||||||
|
Person g = person();
|
||||||
|
Person p = person();
|
||||||
|
Person c = person();
|
||||||
|
Person gc = person();
|
||||||
|
givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(g.getId(), gc.getId()))
|
||||||
|
.hasValue(List.of(DOWN, DOWN, DOWN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 9: uncle/aunt (UP, SIBLING) ---
|
||||||
|
@Test
|
||||||
|
void uncle_aunt_path_emits_UP_SIBLING() {
|
||||||
|
Person grandparent = person();
|
||||||
|
Person parent = person();
|
||||||
|
Person uncle = person();
|
||||||
|
Person me = person();
|
||||||
|
// grandparent has two children: parent and uncle. me is parent's child.
|
||||||
|
givenEdges(
|
||||||
|
parentOf(grandparent, parent),
|
||||||
|
parentOf(grandparent, uncle),
|
||||||
|
parentOf(parent, me));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(me.getId(), uncle.getId()))
|
||||||
|
.hasValue(List.of(UP, SIBLING));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 10: niece/nephew (SIBLING, DOWN) ---
|
||||||
|
@Test
|
||||||
|
void niece_nephew_path_emits_SIBLING_DOWN() {
|
||||||
|
Person grandparent = person();
|
||||||
|
Person uncle = person();
|
||||||
|
Person sibling = person();
|
||||||
|
Person niece = person();
|
||||||
|
// grandparent has uncle + sibling; sibling has niece.
|
||||||
|
givenEdges(
|
||||||
|
parentOf(grandparent, uncle),
|
||||||
|
parentOf(grandparent, sibling),
|
||||||
|
parentOf(sibling, niece));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(uncle.getId(), niece.getId()))
|
||||||
|
.hasValue(List.of(SIBLING, DOWN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 11: great uncle/aunt (UP, UP, SIBLING) ---
|
||||||
|
@Test
|
||||||
|
void great_uncle_aunt_path_emits_UP_UP_SIBLING() {
|
||||||
|
Person ggp = person();
|
||||||
|
Person grandparent = person();
|
||||||
|
Person greatUncle = person();
|
||||||
|
Person parent = person();
|
||||||
|
Person me = person();
|
||||||
|
givenEdges(
|
||||||
|
parentOf(ggp, grandparent),
|
||||||
|
parentOf(ggp, greatUncle),
|
||||||
|
parentOf(grandparent, parent),
|
||||||
|
parentOf(parent, me));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(me.getId(), greatUncle.getId()))
|
||||||
|
.hasValue(List.of(UP, UP, SIBLING));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 12: great niece/nephew (SIBLING, DOWN, DOWN) ---
|
||||||
|
@Test
|
||||||
|
void great_niece_nephew_path_emits_SIBLING_DOWN_DOWN() {
|
||||||
|
Person grandparent = person();
|
||||||
|
Person sibling = person();
|
||||||
|
Person greatUncle = person();
|
||||||
|
Person niece = person();
|
||||||
|
Person greatNiece = person();
|
||||||
|
givenEdges(
|
||||||
|
parentOf(grandparent, sibling),
|
||||||
|
parentOf(grandparent, greatUncle),
|
||||||
|
parentOf(sibling, niece),
|
||||||
|
parentOf(niece, greatNiece));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(greatUncle.getId(), greatNiece.getId()))
|
||||||
|
.hasValue(List.of(SIBLING, DOWN, DOWN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 13: parent-in-law (SPOUSE, UP) ---
|
||||||
|
@Test
|
||||||
|
void inlaw_parent_path_emits_SPOUSE_UP() {
|
||||||
|
Person inlaw = person();
|
||||||
|
Person spouse = person();
|
||||||
|
Person me = person();
|
||||||
|
givenEdges(
|
||||||
|
parentOf(inlaw, spouse),
|
||||||
|
spouseOf(me, spouse));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(me.getId(), inlaw.getId()))
|
||||||
|
.hasValue(List.of(SPOUSE, UP));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 14: child-in-law (DOWN, SPOUSE) ---
|
||||||
|
@Test
|
||||||
|
void inlaw_child_path_emits_DOWN_SPOUSE() {
|
||||||
|
Person me = person();
|
||||||
|
Person child = person();
|
||||||
|
Person inlawChild = person();
|
||||||
|
givenEdges(
|
||||||
|
parentOf(me, child),
|
||||||
|
spouseOf(child, inlawChild));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(me.getId(), inlawChild.getId()))
|
||||||
|
.hasValue(List.of(DOWN, SPOUSE));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 15: sibling-in-law via my spouse's sibling (SPOUSE, SIBLING) ---
|
||||||
|
@Test
|
||||||
|
void sibling_inlaw_via_spouse_emits_SPOUSE_SIBLING() {
|
||||||
|
Person me = person();
|
||||||
|
Person spouse = person();
|
||||||
|
Person spouseSibling = person();
|
||||||
|
givenEdges(
|
||||||
|
spouseOf(me, spouse),
|
||||||
|
siblingOf(spouse, spouseSibling));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(me.getId(), spouseSibling.getId()))
|
||||||
|
.hasValue(List.of(SPOUSE, SIBLING));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 16: cousin (UP, SIBLING, DOWN) ---
|
||||||
|
@Test
|
||||||
|
void cousin_1_path_emits_UP_SIBLING_DOWN() {
|
||||||
|
Person ggp = person();
|
||||||
|
Person parentMine = person();
|
||||||
|
Person uncle = person();
|
||||||
|
Person me = person();
|
||||||
|
Person cousin = person();
|
||||||
|
givenEdges(
|
||||||
|
parentOf(ggp, parentMine),
|
||||||
|
parentOf(ggp, uncle),
|
||||||
|
parentOf(parentMine, me),
|
||||||
|
parentOf(uncle, cousin));
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(me.getId(), cousin.getId()))
|
||||||
|
.hasValue(List.of(UP, SIBLING, DOWN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 17: distant (label fallback for long chains) ---
|
||||||
|
@Test
|
||||||
|
void distant_label_for_long_chain() {
|
||||||
|
// Seven-generation ancestor: chain of seven PARENT_OF edges.
|
||||||
|
Person a0 = person();
|
||||||
|
Person a1 = person();
|
||||||
|
Person a2 = person();
|
||||||
|
Person a3 = person();
|
||||||
|
Person a4 = person();
|
||||||
|
Person a5 = person();
|
||||||
|
Person a6 = person();
|
||||||
|
Person a7 = person();
|
||||||
|
givenEdges(
|
||||||
|
parentOf(a0, a1),
|
||||||
|
parentOf(a1, a2),
|
||||||
|
parentOf(a2, a3),
|
||||||
|
parentOf(a3, a4),
|
||||||
|
parentOf(a4, a5),
|
||||||
|
parentOf(a5, a6),
|
||||||
|
parentOf(a6, a7));
|
||||||
|
|
||||||
|
Optional<InferredRelationshipDTO> inferred = service.infer(a7.getId(), a0.getId());
|
||||||
|
assertThat(inferred).hasValueSatisfying(r -> {
|
||||||
|
assertThat(r.hops()).isEqualTo(7);
|
||||||
|
assertThat(r.labelFromA()).isEqualTo("distant");
|
||||||
|
assertThat(r.labelFromB()).isEqualTo("distant");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 18: no path ---
|
||||||
|
@Test
|
||||||
|
void returns_empty_when_no_path() {
|
||||||
|
Person a = person();
|
||||||
|
Person b = person();
|
||||||
|
// No edges between them.
|
||||||
|
givenEdges(/* none */);
|
||||||
|
|
||||||
|
assertThat(service.findShortestPath(a.getId(), b.getId())).isEmpty();
|
||||||
|
assertThat(service.infer(a.getId(), b.getId())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 19: findAllFor delegates person resolution to PersonService ---
|
||||||
|
@Test
|
||||||
|
void findAllFor_resolves_persons_via_PersonService() {
|
||||||
|
Person parent = person();
|
||||||
|
Person child = person();
|
||||||
|
givenEdges(parentOf(parent, child));
|
||||||
|
when(personService.getAllById(anyList())).thenReturn(List.of(child));
|
||||||
|
|
||||||
|
List<InferredRelationshipWithPersonDTO> results = service.findAllFor(parent.getId());
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
assertThat(results.get(0).person().displayName()).isEqualTo(child.getDisplayName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
private void givenEdges(PersonRelationship... edges) {
|
||||||
|
when(relationshipRepository.findAllByRelationTypeIn(anyCollection()))
|
||||||
|
.thenReturn(edges.length == 0 ? emptyList() : List.of(edges));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Person person() {
|
||||||
|
return Person.builder().id(UUID.randomUUID()).lastName("X").familyMember(true).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersonRelationship parentOf(Person parent, Person child) {
|
||||||
|
return edge(parent, child, PARENT_OF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersonRelationship spouseOf(Person a, Person b) {
|
||||||
|
return edge(a, b, SPOUSE_OF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersonRelationship siblingOf(Person a, Person b) {
|
||||||
|
return edge(a, b, SIBLING_OF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersonRelationship edge(Person a, Person b, RelationType type) {
|
||||||
|
return PersonRelationship.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.person(a)
|
||||||
|
.relatedPerson(b)
|
||||||
|
.relationType(type)
|
||||||
|
.createdAt(Instant.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
|
||||||
|
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||||
|
import org.raddatz.familienarchiv.service.PersonService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sara blocker 1 — service+DB integration over the family-network constraints.
|
||||||
|
* Hits the real Postgres so unique_rel, ON DELETE CASCADE, and the partial
|
||||||
|
* sibling index actually fire.
|
||||||
|
*/
|
||||||
|
@DataJpaTest
|
||||||
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
|
@Import({
|
||||||
|
PostgresContainerConfig.class,
|
||||||
|
FlywayConfig.class,
|
||||||
|
RelationshipService.class,
|
||||||
|
RelationshipInferenceService.class,
|
||||||
|
PersonService.class
|
||||||
|
})
|
||||||
|
class RelationshipServiceIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired RelationshipService relationshipService;
|
||||||
|
@Autowired PersonRepository personRepository;
|
||||||
|
@Autowired PersonRelationshipRepository relationshipRepository;
|
||||||
|
// PersonService → PersonNameAliasRepository; @DataJpaTest auto-loads it.
|
||||||
|
@Autowired PersonNameAliasRepository aliasRepository;
|
||||||
|
@Autowired EntityManager entityManager;
|
||||||
|
|
||||||
|
Person alice;
|
||||||
|
Person bob;
|
||||||
|
Person charlie;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
relationshipRepository.deleteAll();
|
||||||
|
aliasRepository.deleteAll();
|
||||||
|
personRepository.deleteAll();
|
||||||
|
alice = personRepository.save(Person.builder().firstName("Alice").lastName("Müller").familyMember(true).build());
|
||||||
|
bob = personRepository.save(Person.builder().firstName("Bob").lastName("Müller").familyMember(true).build());
|
||||||
|
charlie = personRepository.save(Person.builder().firstName("Charlie").lastName("Schmidt").familyMember(false).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_stores_and_is_readable() {
|
||||||
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, null);
|
||||||
|
|
||||||
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
|
assertThat(created.id()).isNotNull();
|
||||||
|
assertThat(created.personId()).isEqualTo(alice.getId());
|
||||||
|
assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
|
||||||
|
|
||||||
|
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
|
||||||
|
assertThat(rels).hasSize(1);
|
||||||
|
assertThat(rels.get(0).relationType()).isEqualTo(RelationType.PARENT_OF);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_409_when_duplicate() {
|
||||||
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
||||||
|
relationshipService.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_409_when_circular_parent() {
|
||||||
|
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
|
||||||
|
relationshipService.addRelationship(alice.getId(),
|
||||||
|
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
||||||
|
|
||||||
|
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
|
||||||
|
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() {
|
||||||
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
|
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
||||||
|
|
||||||
|
// Charlie is unrelated to this row.
|
||||||
|
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.FORBIDDEN);
|
||||||
|
|
||||||
|
// The row is still there.
|
||||||
|
assertThat(relationshipRepository.findById(created.id())).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
|
||||||
|
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF)
|
||||||
|
// and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
|
||||||
|
relationshipService.addRelationship(alice.getId(),
|
||||||
|
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
|
||||||
|
|
||||||
|
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null);
|
||||||
|
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
|
||||||
|
// alice SPOUSE_OF bob. Bob deletes from his side.
|
||||||
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
|
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
|
||||||
|
|
||||||
|
relationshipService.deleteRelationship(bob.getId(), created.id());
|
||||||
|
|
||||||
|
assertThat(relationshipRepository.findById(created.id())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setFamilyMember_true_makes_person_appear_in_network() {
|
||||||
|
// charlie starts with familyMember = false. Add a PARENT_OF edge alice→charlie
|
||||||
|
// so the edge exists, then flip charlie's flag and verify he appears in nodes.
|
||||||
|
relationshipService.addRelationship(alice.getId(),
|
||||||
|
new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null));
|
||||||
|
|
||||||
|
NetworkDTO before = relationshipService.getFamilyNetwork();
|
||||||
|
assertThat(before.nodes()).extracting("id").doesNotContain(charlie.getId());
|
||||||
|
|
||||||
|
relationshipService.setFamilyMember(charlie.getId(), true);
|
||||||
|
|
||||||
|
NetworkDTO after = relationshipService.getFamilyNetwork();
|
||||||
|
assertThat(after.nodes()).extracting("id").contains(charlie.getId());
|
||||||
|
assertThat(after.edges())
|
||||||
|
.anyMatch(e -> e.personId().equals(alice.getId()) && e.relatedPersonId().equals(charlie.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void delete_person_cascades_to_relationships() {
|
||||||
|
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||||
|
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
||||||
|
UUID relId = created.id();
|
||||||
|
assertThat(relationshipRepository.findById(relId)).isPresent();
|
||||||
|
|
||||||
|
// Detach managed entities so deleteById's cascade isn't fought by the
|
||||||
|
// persistence context (the rel row still references bob in memory).
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
// Delete bob (the relatedPerson) — DB CASCADE must remove the row.
|
||||||
|
personRepository.deleteById(bob.getId());
|
||||||
|
personRepository.flush();
|
||||||
|
|
||||||
|
assertThat(relationshipRepository.findById(relId)).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package org.raddatz.familienarchiv.relationship;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
|
||||||
|
import org.raddatz.familienarchiv.service.PersonService;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Felix Brandt — TDD red for RelationshipService domain rules.
|
||||||
|
*
|
||||||
|
* <p>Required by the plan (Nora blockers 1 + 2):
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person}</li>
|
||||||
|
* <li>{@code addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists}</li>
|
||||||
|
* </ul>
|
||||||
|
* Plus: duplicate constraint, self-relationship, year-range, happy-path persistence,
|
||||||
|
* and ownership permitted from either side.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class RelationshipServiceTest {
|
||||||
|
|
||||||
|
@Mock PersonRelationshipRepository relationshipRepository;
|
||||||
|
@Mock PersonService personService;
|
||||||
|
@Mock RelationshipInferenceService inferenceService;
|
||||||
|
@InjectMocks RelationshipService service;
|
||||||
|
|
||||||
|
Person alice;
|
||||||
|
Person bob;
|
||||||
|
Person charlie;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
alice = person("Alice");
|
||||||
|
bob = person("Bob");
|
||||||
|
charlie = person("Charlie");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Nora blocker 1 ---
|
||||||
|
@Test
|
||||||
|
void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = parentOf(alice, bob, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.FORBIDDEN);
|
||||||
|
verify(relationshipRepository, never()).delete(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Nora blocker 2 ---
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() {
|
||||||
|
// alice PARENT_OF bob already exists. Now we try to add bob PARENT_OF alice.
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
|
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
|
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
|
||||||
|
|
||||||
|
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_DUPLICATE_when_db_constraint_violated() {
|
||||||
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
|
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
||||||
|
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
|
||||||
|
|
||||||
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
|
||||||
|
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.FRIEND, null, null, null);
|
||||||
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() {
|
||||||
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, 1950, 1940, null);
|
||||||
|
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||||
|
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_persists_with_storage_truth() {
|
||||||
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
|
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
||||||
|
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> {
|
||||||
|
PersonRelationship r = inv.getArgument(0);
|
||||||
|
r.setId(UUID.randomUUID());
|
||||||
|
r.setCreatedAt(Instant.now());
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, "first born");
|
||||||
|
var result = service.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
|
assertThat(result.personId()).isEqualTo(alice.getId());
|
||||||
|
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
|
||||||
|
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
|
||||||
|
assertThat(result.fromYear()).isEqualTo(1900);
|
||||||
|
assertThat(result.notes()).isEqualTo("first born");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteRelationship_succeeds_when_viewpoint_is_object() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
PersonRelationship rel = parentOf(alice, bob, relId);
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||||
|
|
||||||
|
// Bob is the storage related_person; deleting from his viewpoint should work.
|
||||||
|
service.deleteRelationship(bob.getId(), relId);
|
||||||
|
verify(relationshipRepository).delete(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteRelationship_throws_NOT_FOUND_when_relId_unknown() {
|
||||||
|
UUID relId = UUID.randomUUID();
|
||||||
|
when(relationshipRepository.findById(relId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.deleteRelationship(alice.getId(), relId))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code")
|
||||||
|
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() {
|
||||||
|
// alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result).
|
||||||
|
// Two edges exist: alice→bob (both family) and alice→charlie (one non-family).
|
||||||
|
// Only the alice→bob edge must appear in the returned NetworkDTO.
|
||||||
|
UUID aliceBobRelId = UUID.randomUUID();
|
||||||
|
UUID aliceCharlieRelId = UUID.randomUUID();
|
||||||
|
PersonRelationship aliceBob = parentOf(alice, bob, aliceBobRelId);
|
||||||
|
PersonRelationship aliceCharlie = parentOf(alice, charlie, aliceCharlieRelId);
|
||||||
|
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(alice, bob));
|
||||||
|
when(relationshipRepository.findAllByRelationTypeIn(any())).thenReturn(List.of(aliceBob, aliceCharlie));
|
||||||
|
|
||||||
|
NetworkDTO result = service.getFamilyNetwork();
|
||||||
|
|
||||||
|
assertThat(result.nodes()).hasSize(2);
|
||||||
|
assertThat(result.edges()).hasSize(1);
|
||||||
|
assertThat(result.edges().get(0).personId()).isEqualTo(alice.getId());
|
||||||
|
assertThat(result.edges().get(0).relatedPersonId()).isEqualTo(bob.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
private static Person person(String name) {
|
||||||
|
return Person.builder().id(UUID.randomUUID()).lastName(name).familyMember(true).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
|
||||||
|
return PersonRelationship.builder()
|
||||||
|
.id(id)
|
||||||
|
.person(parent)
|
||||||
|
.relatedPerson(child)
|
||||||
|
.relationType(RelationType.PARENT_OF)
|
||||||
|
.createdAt(Instant.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
import org.raddatz.familienarchiv.model.*;
|
import org.raddatz.familienarchiv.model.*;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
@@ -24,6 +25,7 @@ class TrainingBlockQueryTest {
|
|||||||
@Autowired TranscriptionBlockRepository blockRepository;
|
@Autowired TranscriptionBlockRepository blockRepository;
|
||||||
@Autowired DocumentRepository documentRepository;
|
@Autowired DocumentRepository documentRepository;
|
||||||
@Autowired AnnotationRepository annotationRepository;
|
@Autowired AnnotationRepository annotationRepository;
|
||||||
|
@Autowired PersonRepository personRepository;
|
||||||
|
|
||||||
private UUID kurrentDocId;
|
private UUID kurrentDocId;
|
||||||
private UUID typewriterDocId;
|
private UUID typewriterDocId;
|
||||||
@@ -36,7 +38,7 @@ class TrainingBlockQueryTest {
|
|||||||
.title("Kurrent Brief")
|
.title("Kurrent Brief")
|
||||||
.originalFilename("kurrent.pdf")
|
.originalFilename("kurrent.pdf")
|
||||||
.status(DocumentStatus.UPLOADED)
|
.status(DocumentStatus.UPLOADED)
|
||||||
.trainingLabels(new java.util.HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
|
.trainingLabels(kurrentLabels())
|
||||||
.build());
|
.build());
|
||||||
kurrentDocId = kurrentDoc.getId();
|
kurrentDocId = kurrentDoc.getId();
|
||||||
|
|
||||||
@@ -111,8 +113,105 @@ class TrainingBlockQueryTest {
|
|||||||
assertThat(result).hasSize(2);
|
assertThat(result).hasSize(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── sender-based queries ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findManualKurrentBlocksByPerson_includesBlockFromKurrentLabelledDocument() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief von Karl")
|
||||||
|
.originalFilename("karl.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.trainingLabels(kurrentLabels())
|
||||||
|
.build());
|
||||||
|
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
|
||||||
|
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, false));
|
||||||
|
|
||||||
|
List<TranscriptionBlock> result = blockRepository.findManualKurrentBlocksByPerson(sender.getId());
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findManualKurrentBlocksByPerson_excludesDocumentWithoutKurrentLabel() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief von Karl")
|
||||||
|
.originalFilename("karl.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.build());
|
||||||
|
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
|
||||||
|
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, false));
|
||||||
|
|
||||||
|
List<TranscriptionBlock> result = blockRepository.findManualKurrentBlocksByPerson(sender.getId());
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findManualKurrentBlocksByPerson_excludesOcrBlocks() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief von Karl")
|
||||||
|
.originalFilename("karl.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.trainingLabels(kurrentLabels())
|
||||||
|
.build());
|
||||||
|
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
|
||||||
|
blockRepository.save(block(doc.getId(), annId, BlockSource.OCR, false));
|
||||||
|
|
||||||
|
List<TranscriptionBlock> result = blockRepository.findManualKurrentBlocksByPerson(sender.getId());
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findManualKurrentBlocksByPerson_excludesOtherSender() {
|
||||||
|
Person karl = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
|
||||||
|
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Test").build());
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief von Karl")
|
||||||
|
.originalFilename("karl.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(karl)
|
||||||
|
.trainingLabels(kurrentLabels())
|
||||||
|
.build());
|
||||||
|
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
|
||||||
|
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, false));
|
||||||
|
|
||||||
|
List<TranscriptionBlock> result = blockRepository.findManualKurrentBlocksByPerson(anna.getId());
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countManualKurrentBlocksByPerson_matchesFindResult() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief von Karl")
|
||||||
|
.originalFilename("karl.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.trainingLabels(kurrentLabels())
|
||||||
|
.build());
|
||||||
|
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
|
||||||
|
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, false));
|
||||||
|
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, true));
|
||||||
|
|
||||||
|
long count = blockRepository.countManualKurrentBlocksByPerson(sender.getId());
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static Set<TrainingLabel> kurrentLabels() {
|
||||||
|
return new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION));
|
||||||
|
}
|
||||||
|
|
||||||
private DocumentAnnotation annotation(UUID docId) {
|
private DocumentAnnotation annotation(UUID docId) {
|
||||||
return DocumentAnnotation.builder()
|
return DocumentAnnotation.builder()
|
||||||
.documentId(docId)
|
.documentId(docId)
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.model.PersonMention;
|
||||||
|
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@DataJpaTest
|
||||||
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
|
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||||
|
class TranscriptionBlockMentionsRepositoryTest {
|
||||||
|
|
||||||
|
@Autowired TranscriptionBlockRepository blockRepository;
|
||||||
|
@Autowired DocumentRepository documentRepository;
|
||||||
|
@Autowired AnnotationRepository annotationRepository;
|
||||||
|
@Autowired EntityManager em;
|
||||||
|
|
||||||
|
private UUID documentId;
|
||||||
|
private UUID annotationId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Letter")
|
||||||
|
.originalFilename("letter.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.build());
|
||||||
|
documentId = doc.getId();
|
||||||
|
|
||||||
|
DocumentAnnotation annotation = annotationRepository.save(DocumentAnnotation.builder()
|
||||||
|
.documentId(documentId)
|
||||||
|
.pageNumber(1)
|
||||||
|
.x(0.1).y(0.2).width(0.3).height(0.4)
|
||||||
|
.color("#00C7B1")
|
||||||
|
.build());
|
||||||
|
annotationId = annotation.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mentionedPersons_roundTripsTwoEntries() {
|
||||||
|
UUID auguste = UUID.randomUUID();
|
||||||
|
UUID hermann = UUID.randomUUID();
|
||||||
|
|
||||||
|
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||||
|
.annotationId(annotationId)
|
||||||
|
.documentId(documentId)
|
||||||
|
.text("Liebe Tante @Auguste Raddatz, Onkel @Hermann Müller schreibt …")
|
||||||
|
.sortOrder(0)
|
||||||
|
.mentionedPersons(List.of(
|
||||||
|
new PersonMention(auguste, "Auguste Raddatz"),
|
||||||
|
new PersonMention(hermann, "Hermann Müller")
|
||||||
|
))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
|
||||||
|
|
||||||
|
assertThat(reloaded.getMentionedPersons())
|
||||||
|
.extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
|
||||||
|
.containsExactlyInAnyOrder(
|
||||||
|
org.assertj.core.groups.Tuple.tuple(auguste, "Auguste Raddatz"),
|
||||||
|
org.assertj.core.groups.Tuple.tuple(hermann, "Hermann Müller"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mentionedPersons_defaultsToEmptyList_whenNotSet() {
|
||||||
|
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||||
|
.annotationId(annotationId)
|
||||||
|
.documentId(documentId)
|
||||||
|
.text("Plain text without mentions")
|
||||||
|
.sortOrder(0)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
|
||||||
|
assertThat(reloaded.getMentionedPersons()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByPersonIdWithMentionsFetched_returnsOnlyBlocksReferencingPerson_withMentionsLoaded() {
|
||||||
|
UUID augusteId = UUID.randomUUID();
|
||||||
|
UUID hermannId = UUID.randomUUID();
|
||||||
|
|
||||||
|
blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||||
|
.annotationId(annotationId).documentId(documentId)
|
||||||
|
.text("Brief von @Auguste Raddatz an @Hermann Müller.")
|
||||||
|
.sortOrder(0)
|
||||||
|
.mentionedPersons(List.of(
|
||||||
|
new PersonMention(augusteId, "Auguste Raddatz"),
|
||||||
|
new PersonMention(hermannId, "Hermann Müller")))
|
||||||
|
.build());
|
||||||
|
blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||||
|
.annotationId(annotationId).documentId(documentId)
|
||||||
|
.text("Unrelated block without Auguste.")
|
||||||
|
.sortOrder(1)
|
||||||
|
.mentionedPersons(List.of(new PersonMention(hermannId, "Hermann Müller")))
|
||||||
|
.build());
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
List<TranscriptionBlock> result =
|
||||||
|
blockRepository.findByPersonIdWithMentionsFetched(augusteId);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getMentionedPersons())
|
||||||
|
.extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
|
||||||
|
.containsExactlyInAnyOrder(
|
||||||
|
org.assertj.core.groups.Tuple.tuple(augusteId, "Auguste Raddatz"),
|
||||||
|
org.assertj.core.groups.Tuple.tuple(hermannId, "Hermann Müller"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,10 @@ import java.util.UUID;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class PersonServiceTest {
|
class PersonServiceTest {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import org.raddatz.familienarchiv.model.BlockSource;
|
|||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.model.PersonMention;
|
||||||
import org.raddatz.familienarchiv.model.ScriptType;
|
import org.raddatz.familienarchiv.model.ScriptType;
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
||||||
@@ -98,7 +99,9 @@ class TranscriptionServiceTest {
|
|||||||
return b;
|
return b;
|
||||||
});
|
});
|
||||||
|
|
||||||
CreateTranscriptionBlockDTO dto = new CreateTranscriptionBlockDTO(1, 0.1, 0.2, 0.3, 0.4, "hello", null);
|
CreateTranscriptionBlockDTO dto = CreateTranscriptionBlockDTO.builder()
|
||||||
|
.pageNumber(1).x(0.1).y(0.2).width(0.3).height(0.4)
|
||||||
|
.text("hello").build();
|
||||||
|
|
||||||
TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId);
|
TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId);
|
||||||
|
|
||||||
@@ -168,7 +171,7 @@ class TranscriptionServiceTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
|
|
||||||
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("new text", null);
|
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder().text("new text").build();
|
||||||
|
|
||||||
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId);
|
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId);
|
||||||
|
|
||||||
@@ -189,7 +192,7 @@ class TranscriptionServiceTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
|
|
||||||
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("text", "Anrede");
|
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder().text("text").label("Anrede").build();
|
||||||
|
|
||||||
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
|
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
|
||||||
|
|
||||||
@@ -208,11 +211,65 @@ class TranscriptionServiceTest {
|
|||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
|
|
||||||
TranscriptionBlock result = transcriptionService.updateBlock(
|
TranscriptionBlock result = transcriptionService.updateBlock(
|
||||||
docId, blockId, new UpdateTranscriptionBlockDTO("new", null), UUID.randomUUID());
|
docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new").build(), UUID.randomUUID());
|
||||||
|
|
||||||
assertThat(result.getSource()).isEqualTo(BlockSource.MANUAL);
|
assertThat(result.getSource()).isEqualTo(BlockSource.MANUAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateBlock_replacesMentionedPersonsFromDto() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
UUID personId = UUID.randomUUID();
|
||||||
|
|
||||||
|
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||||
|
.id(blockId).documentId(docId).text("old").build();
|
||||||
|
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
|
||||||
|
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
|
|
||||||
|
PersonMention mention = new PersonMention(personId, "Auguste");
|
||||||
|
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder()
|
||||||
|
.text("@Auguste text")
|
||||||
|
.mentionedPersons(List.of(mention))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThat(result.getMentionedPersons())
|
||||||
|
.containsExactly(mention);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateBlock_clearsPriorMentions_beforeApplyingDto() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
|
||||||
|
PersonMention prior = new PersonMention(UUID.randomUUID(), "Heinrich");
|
||||||
|
PersonMention incoming = new PersonMention(UUID.randomUUID(), "Auguste");
|
||||||
|
|
||||||
|
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||||
|
.id(blockId).documentId(docId).text("old").build();
|
||||||
|
block.getMentionedPersons().add(prior);
|
||||||
|
|
||||||
|
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
|
||||||
|
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
|
|
||||||
|
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder()
|
||||||
|
.text("@Auguste text")
|
||||||
|
.mentionedPersons(List.of(incoming))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThat(result.getMentionedPersons())
|
||||||
|
.containsExactly(incoming)
|
||||||
|
.doesNotContain(prior);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateBlock_triggersTraining_whenKurrentSenderPresent() {
|
void updateBlock_triggersTraining_whenKurrentSenderPresent() {
|
||||||
UUID docId = UUID.randomUUID();
|
UUID docId = UUID.randomUUID();
|
||||||
@@ -226,7 +283,7 @@ class TranscriptionServiceTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).sender(sender).build());
|
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).sender(sender).build());
|
||||||
|
|
||||||
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null), UUID.randomUUID());
|
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("text").build(), UUID.randomUUID());
|
||||||
|
|
||||||
verify(senderModelService).checkAndTriggerTraining(senderId);
|
verify(senderModelService).checkAndTriggerTraining(senderId);
|
||||||
}
|
}
|
||||||
@@ -242,7 +299,7 @@ class TranscriptionServiceTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).build());
|
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).build());
|
||||||
|
|
||||||
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null), UUID.randomUUID());
|
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("text").build(), UUID.randomUUID());
|
||||||
|
|
||||||
verify(senderModelService, never()).checkAndTriggerTraining(any());
|
verify(senderModelService, never()).checkAndTriggerTraining(any());
|
||||||
}
|
}
|
||||||
@@ -477,7 +534,7 @@ class TranscriptionServiceTest {
|
|||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
when(annotationRepository.findById(annotId)).thenReturn(Optional.of(annotation));
|
when(annotationRepository.findById(annotId)).thenReturn(Optional.of(annotation));
|
||||||
|
|
||||||
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("new text", null), userId);
|
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new text").build(), userId);
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
|
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
|
||||||
@@ -502,7 +559,7 @@ class TranscriptionServiceTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
|
|
||||||
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("same text", null), userId);
|
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("same text").build(), userId);
|
||||||
|
|
||||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|||||||
55
docs/adr/006-synchronous-domain-events-in-transaction.md
Normal file
55
docs/adr/006-synchronous-domain-events-in-transaction.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# ADR-006: Synchronous domain events inside the publisher's transaction
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Issue #362 introduced the first cross-domain side-effect in this codebase: when a Person's display name changes, every transcription block that mentions the person must be rewritten — both `block.text` (the literal `@OldName` substring) and the `mentionedPersons` sidecar (the `displayName` field on the matching `PersonMention`). The rewrite is bidirectionally referential — Person depends on Transcription to make the rename atomic, and Transcription depends on Person to know what the new display name is.
|
||||||
|
|
||||||
|
A direct method call from `PersonService` into `TranscriptionBlockService` would invert the existing dependency arrow (Document → Person, not Person → Transcription) and introduce a runtime-circular reference at the package level. Avoiding the cycle while keeping the rename atomic is the constraint this ADR addresses.
|
||||||
|
|
||||||
|
Two prior pieces of infrastructure constrain the solution:
|
||||||
|
|
||||||
|
- `transcription_blocks.version` (JPA `@Version`) — concurrent autosave on a referenced block must roll back the rename instead of silently overwriting the autosave.
|
||||||
|
- `OcrTrainingService.recoverOrphanedRuns` is the only existing `@EventListener` and it consumes Spring's built-in `ApplicationReadyEvent` — no precedent for a custom domain event in this codebase before now.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`PersonService.updatePerson` publishes `PersonDisplayNameChangedEvent(personId, oldDisplayName, newDisplayName)` via `ApplicationEventPublisher` whenever `Person.getDisplayName()` flips between the pre-save snapshot and the post-save value. `PersonMentionPropagationListener` (in the transcription package's `service/` layer) handles the event with `@EventListener @Transactional`, finds blocks via `findByMentionedPersons_PersonId`, rewrites text + sidecar, and calls `saveAllAndFlush`.
|
||||||
|
|
||||||
|
**Synchronous on purpose.** Spring's default event dispatcher invokes listeners on the publishing thread, inside the publisher's transaction. The propagation runs as part of the same `@Transactional` boundary as the rename — `OptimisticLockingFailureException` from a referenced block bubbles back up, the surrounding transaction rolls back, and `PersonService.updatePerson` translates it to `DomainException(PERSON_RENAME_CONFLICT, 409)`.
|
||||||
|
|
||||||
|
**Pattern for future cross-domain decoupling:**
|
||||||
|
1. Event record in `model/` of the publishing domain (e.g. `PersonDisplayNameChangedEvent`).
|
||||||
|
2. Listener in `service/` of the consuming domain (e.g. `PersonMentionPropagationListener`).
|
||||||
|
3. `@EventListener @Transactional` on the listener method — no `@TransactionalEventListener` unless the work genuinely doesn't need to commit with the publisher.
|
||||||
|
4. `saveAllAndFlush` (not `saveAll`) on any write where exceptions must surface inside the listener call so the publisher can catch and translate them — `saveAll` defers exceptions to commit time, after the publisher's `try` block has exited.
|
||||||
|
5. Audit log line at `INFO` level on the listener method — historical-text mutation needs an audit trail.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
| Alternative | Why rejected |
|
||||||
|
|---|---|
|
||||||
|
| `PersonService` calls `TranscriptionBlockService.propagateDisplayNameChange(...)` directly | Inverts the dependency arrow. Person becomes runtime-coupled to Transcription; future domains that also care about renames (Comments, Notifications) compound the coupling. Events keep Person agnostic of who consumes them. |
|
||||||
|
| `@TransactionalEventListener(AFTER_COMMIT) + @Async` | The propagation would run after the rename commits, on a separate transaction. A failed propagation could leave block text out of sync with the renamed person until manual repair. Atomic transactional coupling is the safer default for historical-text mutation; switch to async only when the block count makes sync latency unacceptable (rough threshold: tens of thousands of blocks per renamed person). |
|
||||||
|
| Database trigger on `persons.last_name` | PL/pgSQL trigger would have to reach into `transcription_block_mentioned_persons` and `transcription_blocks.text`, smearing domain logic across SQL and Java. JPA's `@Version` would also be invisible to the trigger, so concurrent block autosaves would race silently. |
|
||||||
|
| Hibernate entity listener (`@PostUpdate` on Person) | Couples to Hibernate internals; harder to test in isolation; mixes lifecycle hooks with cross-domain side effects. Spring's `ApplicationEventPublisher` keeps the integration declarative and unit-testable. |
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Easier:**
|
||||||
|
- Person domain stays free of any compile-time dependency on Transcription. Future consumers (Comments, Notifications) subscribe to the same event without `PersonService` knowing they exist.
|
||||||
|
- Rename + propagation share one transaction → no half-applied state visible to readers, no orphaned rewrites if the rename fails after propagation, no "eventually-consistent" window for an archive that prizes historical fidelity.
|
||||||
|
- Concurrent autosaves on referenced blocks raise a structured 409 the frontend can render meaningfully (`error_person_rename_conflict`) instead of a generic 500.
|
||||||
|
- The pattern itself (record event in `model/`, listener in consumer's `service/`, sync `@EventListener @Transactional`, `saveAllAndFlush`) is reusable for the next cross-domain side effect.
|
||||||
|
|
||||||
|
**Harder:**
|
||||||
|
- Listener latency adds to the rename request's response time. The 200-block latency floor (< 2 s) is a merge-blocking regression test; if archive growth pushes it up, the migration path is one-annotation: switch to `@TransactionalEventListener(AFTER_COMMIT) + @Async` and add a manual-repair tool for propagation failures.
|
||||||
|
- Tests for the listener path require routing the publisher mock through a real listener (see `PersonServiceTest#updatePerson_throwsConflict_whenBlockSaveAllAndFlushHitsOptimisticLock`). Slightly more setup than a pure-Mockito test, but exercises the production call chain.
|
||||||
|
- `saveAllAndFlush` is mandatory in any synchronous listener that must surface JPA exceptions to the publisher's `try`-block. `saveAll` alone defers the flush to transaction commit, which happens after the publisher returns.
|
||||||
|
|
||||||
|
## Future Direction
|
||||||
|
|
||||||
|
If a single rename starts touching tens of thousands of blocks, switch the listener to `@TransactionalEventListener(phase = AFTER_COMMIT)` paired with `@Async` and add (a) an idempotency key to the event so a retry doesn't double-rewrite, (b) an admin tool that scans for sidecar entries whose `displayName` doesn't match the current `Person.getDisplayName()` and repairs them. At that point the orphan-guard path (existsById check before the rewrite) re-enters the listener as a deliberate piece of the async machinery rather than dead code.
|
||||||
987
docs/specs/stammbaum-doc-badge-spec.html
Normal file
987
docs/specs/stammbaum-doc-badge-spec.html
Normal file
@@ -0,0 +1,987 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Stammbaum — Document Badge · Inline Pill Variant · Familienarchiv</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
|
||||||
|
.doc{max-width:1300px;margin:0 auto;padding:48px 32px 120px}
|
||||||
|
|
||||||
|
/* ── Masthead ── */
|
||||||
|
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:60px}
|
||||||
|
.mh h1{font-size:23px;font-weight:900;color:#012851;letter-spacing:-.4px}
|
||||||
|
.mh p{font-size:13px;color:#555;max-width:740px;line-height:1.75;margin-top:8px}
|
||||||
|
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:10px}
|
||||||
|
.tag-row{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
|
||||||
|
.tag{background:#012851;color:#A1DCD8;padding:2px 8px;border-radius:2px;font-size:8px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
|
||||||
|
.tag.amber{background:#7c4a00;color:#fde68a}
|
||||||
|
|
||||||
|
/* ── Section headers ── */
|
||||||
|
.sh{margin:0 0 28px}
|
||||||
|
.sh h2{font-size:16px;font-weight:900;color:#012851;letter-spacing:-.2px}
|
||||||
|
.sh p{font-size:12.5px;color:#666;max-width:720px;line-height:1.7;margin-top:5px}
|
||||||
|
.section{margin-bottom:80px;padding-bottom:80px;border-bottom:2px dashed #C8C4BE}
|
||||||
|
.section:last-of-type{border-bottom:none;margin-bottom:0;padding-bottom:0}
|
||||||
|
|
||||||
|
/* ── Token tables ── */
|
||||||
|
.token-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
|
||||||
|
.token-table{border-radius:6px;overflow:hidden}
|
||||||
|
.token-table.light{background:#fff;border:1px solid #E0DDD6}
|
||||||
|
.token-table.dark{background:#0F1923;border:1px solid #1E2D3D}
|
||||||
|
.token-head{padding:8px 14px;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;border-bottom:1px solid #E0DDD6}
|
||||||
|
.token-table.light .token-head{background:#F4F2EC;color:#888;border-bottom-color:#E0DDD6}
|
||||||
|
.token-table.dark .token-head{background:#0A1218;color:#4E6070;border-bottom-color:#1E2D3D}
|
||||||
|
.token-table table{width:100%;border-collapse:collapse;font-size:11px}
|
||||||
|
.token-table.light td{padding:6px 14px;border-bottom:1px solid #F0EEE8;vertical-align:middle}
|
||||||
|
.token-table.dark td{padding:6px 14px;border-bottom:1px solid #1A2830;vertical-align:middle;color:#8AAABB}
|
||||||
|
.token-table tr:last-child td{border-bottom:none}
|
||||||
|
.token-table.light td:first-child{font-size:9px;font-weight:700;color:#888;width:160px}
|
||||||
|
.token-table.dark td:first-child{font-size:9px;font-weight:700;color:#4E6070;width:160px}
|
||||||
|
.swatch{display:inline-block;width:12px;height:12px;border-radius:2px;vertical-align:middle;margin-right:6px}
|
||||||
|
.swatch.bordered{border:1px solid #DDD}
|
||||||
|
.warn{display:inline-block;background:#FEF3C7;color:#92400E;font-size:8px;font-weight:700;padding:1px 5px;border-radius:2px;margin-left:4px;vertical-align:middle}
|
||||||
|
.pass{display:inline-block;background:#D1FAE5;color:#065F46;font-size:8px;font-weight:700;padding:1px 5px;border-radius:2px;margin-left:4px;vertical-align:middle}
|
||||||
|
|
||||||
|
/* ── Browser chrome ── */
|
||||||
|
.chrome{border:1.5px solid #C4C0BA;border-radius:8px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.1)}
|
||||||
|
.chrome.dark{background:#010e1e;border-color:#0d3358}
|
||||||
|
.chrome-bar{height:20px;background:#E8E6E0;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;padding:0 8px;gap:4px;flex-shrink:0}
|
||||||
|
.chrome.dark .chrome-bar{background:#010a18;border-bottom-color:#0d3358}
|
||||||
|
.chrome-dot{width:6px;height:6px;border-radius:50%;background:#BDB8B1}
|
||||||
|
.chrome.dark .chrome-dot{background:#1a2a3a}
|
||||||
|
.chrome-url{flex:1;height:9px;background:#CCC8C2;border-radius:5px;margin-left:6px}
|
||||||
|
.chrome.dark .chrome-url{background:#1a2a3a}
|
||||||
|
|
||||||
|
/* ── App nav ── */
|
||||||
|
.app-nav{height:34px;background:#012851;border-top:4px solid #A1DCD8;display:flex;align-items:center;padding:0 12px;gap:10px;flex-shrink:0}
|
||||||
|
.app-logo{font-family:'Tinos',Georgia,serif;font-size:7px;font-weight:700;color:#fff;letter-spacing:.5px}
|
||||||
|
.app-link{font-size:5.5px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:rgba(255,255,255,.4);white-space:nowrap}
|
||||||
|
.app-link.on{color:rgba(255,255,255,.9)}
|
||||||
|
.app-nav-r{margin-left:auto;display:flex;gap:6px;align-items:center}
|
||||||
|
.app-av{width:16px;height:16px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5)}
|
||||||
|
|
||||||
|
/* ── Sub-header bar ── */
|
||||||
|
.sub-header{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 12px;gap:6px;flex-shrink:0}
|
||||||
|
.chrome.dark .sub-header{background:#011526;border-bottom-color:#0d3358}
|
||||||
|
.back-btn{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0}
|
||||||
|
.chrome.dark .back-btn{color:#8b97a5}
|
||||||
|
.sh-divider{width:1px;height:18px;background:#E4E2D7;flex-shrink:0;margin:0 4px}
|
||||||
|
.chrome.dark .sh-divider{background:#0d3358}
|
||||||
|
.sh-doc-title{font-family:'Tinos',Georgia,serif;font-size:10px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
|
||||||
|
.chrome.dark .sh-doc-title{color:#f0efe9}
|
||||||
|
/* person chips in sub-header */
|
||||||
|
.sh-persons{display:flex;align-items:center;gap:5px;flex-shrink:0}
|
||||||
|
.sh-chip{display:flex;align-items:center;gap:4px}
|
||||||
|
.sh-av{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0}
|
||||||
|
.sh-name{font-size:8px;font-weight:600;color:#4b5563;white-space:nowrap}
|
||||||
|
.chrome.dark .sh-name{color:#9ca3af}
|
||||||
|
.sh-arrow{color:#A1DCD8;flex-shrink:0}
|
||||||
|
.chrome.dark .sh-arrow{color:#00c7b1}
|
||||||
|
/* INLINE PILL */
|
||||||
|
.pill{display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;margin-left:5px;vertical-align:middle;line-height:1.5}
|
||||||
|
.chrome.dark .pill{background:rgba(0,199,177,.10);border-color:#00c7b1;color:#f0efe9}
|
||||||
|
/* sub-header actions */
|
||||||
|
.sh-actions{display:flex;align-items:center;gap:5px;flex-shrink:0;margin-left:8px}
|
||||||
|
.sh-btn-ghost{height:22px;padding:0 7px;border:1.5px solid #E4E2D7;border-radius:3px;font-size:6.5px;font-weight:700;color:#4b5563;display:flex;align-items:center;gap:3px;flex-shrink:0}
|
||||||
|
.chrome.dark .sh-btn-ghost{border-color:#0d3358;color:#8b97a5}
|
||||||
|
.sh-btn-primary{height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center;gap:3px;flex-shrink:0}
|
||||||
|
.chrome.dark .sh-btn-primary{background:#A1DCD8;color:#012851}
|
||||||
|
.sh-btn-icon{width:22px;height:22px;border:1.5px solid #E4E2D7;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0}
|
||||||
|
.chrome.dark .sh-btn-icon{border-color:#0d3358;color:#8b97a5}
|
||||||
|
|
||||||
|
/* ── Metadata drawer ── */
|
||||||
|
.meta-drawer{background:#ffffff;border-bottom:1px solid #E4E2D7;padding:14px 16px;flex-shrink:0}
|
||||||
|
.chrome.dark .meta-drawer{background:#011526;border-bottom-color:#0d3358}
|
||||||
|
.meta-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
|
||||||
|
.meta-col-head{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#6b7280;margin-bottom:8px}
|
||||||
|
.chrome.dark .meta-col-head{color:#8b97a5}
|
||||||
|
.meta-field{margin-bottom:8px}
|
||||||
|
.meta-label{font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px}
|
||||||
|
.chrome.dark .meta-label{color:#8b97a5}
|
||||||
|
.meta-value{font-family:'Tinos',Georgia,serif;font-size:10px;color:#012851}
|
||||||
|
.chrome.dark .meta-value{color:#f0efe9}
|
||||||
|
|
||||||
|
/* ── Person card in metadata ── */
|
||||||
|
.person-card{display:flex;align-items:center;gap:5px;padding:3px 5px;border-radius:3px}
|
||||||
|
.p-av{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6.5px;font-weight:800;color:#fff;flex-shrink:0}
|
||||||
|
.p-name{font-family:'Tinos',Georgia,serif;font-size:9.5px;color:#012851}
|
||||||
|
.chrome.dark .p-name{color:#f0efe9}
|
||||||
|
|
||||||
|
/* ── PDF placeholder ── */
|
||||||
|
.pdf-area{background:#d4d0c8;flex:1;display:flex;align-items:center;justify-content:center;min-height:80px}
|
||||||
|
.chrome.dark .pdf-area{background:#010e1e}
|
||||||
|
.paper{background:#FFFEF8;width:40%;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:8px 10px;display:flex;flex-direction:column;gap:2px}
|
||||||
|
.chrome.dark .paper{background:#0d1820}
|
||||||
|
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px}
|
||||||
|
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px}
|
||||||
|
.chrome.dark .pl,.chrome.dark .ps{background:#1E2D3D}
|
||||||
|
|
||||||
|
/* ── Side-by-side layout ── */
|
||||||
|
.split-screens{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:16px}
|
||||||
|
.screen-lbl{font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:8px;display:flex;align-items:center;gap:5px}
|
||||||
|
.lbl-dot{width:8px;height:8px;border-radius:50%;display:inline-block}
|
||||||
|
.cap{font-size:10px;color:#999;font-style:italic;line-height:1.6;margin-top:10px;max-width:460px}
|
||||||
|
|
||||||
|
/* ── Edge-case cards ── */
|
||||||
|
.edge-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:12px}
|
||||||
|
.edge-card{background:#fff;border:1px solid #E0DDD6;border-radius:6px;overflow:hidden}
|
||||||
|
.edge-head{background:#F4F2EC;padding:8px 12px;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;border-bottom:1px solid #E0DDD6}
|
||||||
|
.edge-body{padding:10px 12px}
|
||||||
|
.edge-note{font-size:10.5px;color:#555;line-height:1.65;margin-top:8px}
|
||||||
|
.no-badge{font-family:'Tinos',Georgia,serif;font-size:9px;color:#aaa;font-style:italic;padding:4px 5px}
|
||||||
|
|
||||||
|
/* ── Rules / implementation table ── */
|
||||||
|
.rules{background:#fff;border:1px solid #E0DDD6;border-radius:6px;overflow:hidden}
|
||||||
|
.rules table{width:100%;border-collapse:collapse}
|
||||||
|
.rules th{background:#F4F2EC;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;padding:8px 12px;text-align:left;border-bottom:1px solid #E0DDD6}
|
||||||
|
.rules td{font-size:11px;color:#444;padding:8px 12px;border-bottom:1px solid #F0EEE8;vertical-align:top;line-height:1.6}
|
||||||
|
.rules tr:last-child td{border-bottom:none}
|
||||||
|
.rules td:first-child{font-size:9px;font-weight:700;color:#012851;white-space:nowrap;width:200px}
|
||||||
|
.rules td code{font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px;color:#555;white-space:nowrap}
|
||||||
|
|
||||||
|
/* ── Pill anatomy callout ── */
|
||||||
|
.pill-anatomy{display:flex;align-items:center;gap:20px;background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:18px 24px;margin-bottom:16px;flex-wrap:wrap}
|
||||||
|
.pill-demo-light{display:flex;align-items:center;gap:10px;padding:10px 16px;background:#f9f8f4;border-radius:4px}
|
||||||
|
.pill-demo-dark{display:flex;align-items:center;gap:10px;padding:10px 16px;background:#011526;border-radius:4px}
|
||||||
|
.pill-annotation{font-size:9.5px;color:#888;line-height:1.7}
|
||||||
|
.pill-annotation strong{color:#012851;font-weight:700}
|
||||||
|
|
||||||
|
/* ── Responsive preview containers ── */
|
||||||
|
.responsive-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:16px}
|
||||||
|
.responsive-grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:24px;margin-bottom:16px}
|
||||||
|
|
||||||
|
/* ── Tablet sub-header ── */
|
||||||
|
.sub-header-tablet{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0}
|
||||||
|
.sh-title-truncated{font-family:'Tinos',Georgia,serif;font-size:9px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
|
||||||
|
.sh-overflow-btn{width:22px;height:22px;border:1.5px solid #E4E2D7;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#6b7280;font-size:9px;font-weight:700;flex-shrink:0}
|
||||||
|
.meta-stacked{padding:12px 14px;background:#fff;border-bottom:1px solid #E4E2D7;font-size:9px}
|
||||||
|
.meta-stacked .meta-label{font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px}
|
||||||
|
.meta-stacked .meta-value{font-family:'Tinos',Georgia,serif;font-size:9.5px;color:#012851;margin-bottom:10px}
|
||||||
|
.meta-stacked .meta-section-head{font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#6b7280;margin-bottom:8px}
|
||||||
|
|
||||||
|
/* ── Mobile sub-header ── */
|
||||||
|
.sub-header-mobile{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 10px;gap:5px;flex-shrink:0}
|
||||||
|
.sh-title-mobile{font-family:'Tinos',Georgia,serif;font-size:9px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
|
||||||
|
.meta-mobile{padding:10px 12px;background:#fff;border-bottom:1px solid #E4E2D7;font-size:8.5px}
|
||||||
|
.meta-mobile .m-label{font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:2px;margin-top:8px}
|
||||||
|
.meta-mobile .m-label:first-child{margin-top:0}
|
||||||
|
.meta-mobile .m-value{font-family:'Tinos',Georgia,serif;font-size:9px;color:#012851;margin-bottom:2px}
|
||||||
|
.person-row-mobile{display:flex;align-items:center;gap:4px;flex-wrap:nowrap}
|
||||||
|
.person-row-mobile .p-av-sm{width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0}
|
||||||
|
.person-row-mobile .p-nm{font-family:'Tinos',Georgia,serif;font-size:9px;color:#012851;white-space:nowrap}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
<!-- ══ MASTHEAD ══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="mh">
|
||||||
|
<h1>Stammbaum — Document Badge · Inline Pill Variant</h1>
|
||||||
|
<p>
|
||||||
|
Design spec for the inline relationship pill on the Document Detail page. Relationship labels appear
|
||||||
|
as <strong>inline pills directly next to each person's name</strong> — both in the 48 px sub-header bar
|
||||||
|
and in the Personen column of the 3-column metadata drawer. Example: Karl Raddatz
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 7px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin:0 2px">ELTERNTEIL</span>
|
||||||
|
→ Hans Raddatz
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 7px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin:0 2px">KIND</span>.
|
||||||
|
This is View 2 of 3 in the Stammbaum document-badge feature set.
|
||||||
|
</p>
|
||||||
|
<div class="byline">Familienarchiv · 2026-04-27 · Leonie Voss, UX Lead</div>
|
||||||
|
<div class="tag-row">
|
||||||
|
<span class="tag">Stammbaum Feature</span>
|
||||||
|
<span class="tag">View 2 of 3 — Document Badge</span>
|
||||||
|
<span class="tag">Inline Pill Variant</span>
|
||||||
|
<span class="tag">Desktop / Tablet / Mobile</span>
|
||||||
|
<span class="tag">Light + Dark</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══ SECTION 1 — DESIGN TOKENS ════════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="sh">
|
||||||
|
<h2>1 · Design tokens</h2>
|
||||||
|
<p>All colour values used by the inline pill and its surrounding context. Light and dark themes are shown side by side. Contrast ratios are against the respective surface colour.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pill anatomy callout -->
|
||||||
|
<div class="pill-anatomy">
|
||||||
|
<div class="pill-demo-light">
|
||||||
|
<span style="font-family:'Tinos',Georgia,serif;font-size:11px;color:#012851;font-weight:700">Karl Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
<div class="pill-demo-dark">
|
||||||
|
<span style="font-family:'Tinos',Georgia,serif;font-size:11px;color:#f0efe9;font-weight:700">Karl Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
<div class="pill-annotation">
|
||||||
|
<strong>Pill anatomy</strong><br>
|
||||||
|
border-radius: 9999px · padding: 1px 8px<br>
|
||||||
|
font: Montserrat 9px 700 uppercase letter-spacing .07em<br>
|
||||||
|
margin-left: 8px from name span · vertical-align: middle
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="token-grid">
|
||||||
|
<!-- Light -->
|
||||||
|
<div class="token-table light">
|
||||||
|
<div class="token-head">Light theme — surface #ffffff</div>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Pill bg</td>
|
||||||
|
<td><span class="swatch bordered" style="background:rgba(161,220,216,.25)"></span>rgba(161,220,216,.25) — near-white on white<span class="pass">~14:1 AAA ✓ (text on near-white)</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Pill border</td>
|
||||||
|
<td><span class="swatch" style="background:#a1dcd8"></span>#a1dcd8 — mint accent outline</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Pill text</td>
|
||||||
|
<td><span class="swatch" style="background:#012851"></span>#012851 — navy ink<span class="pass">14.5:1 AAA ✓</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Person name</td>
|
||||||
|
<td><span class="swatch" style="background:#4b5563"></span>#4b5563 — Montserrat 11px (sub-header)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Meta person name</td>
|
||||||
|
<td><span class="swatch" style="background:#012851"></span>#012851 — Tinos 9.5px (metadata drawer)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sub-header bg</td>
|
||||||
|
<td><span class="swatch bordered" style="background:#ffffff"></span>#ffffff</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sub-header border</td>
|
||||||
|
<td><span class="swatch" style="background:#e4e2d7"></span>#e4e2d7</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Arrow (decorative)</td>
|
||||||
|
<td><span class="swatch" style="background:#a1dcd8"></span>#a1dcd8 — <code>aria-hidden</code><span class="warn">non-text only</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Meta label</td>
|
||||||
|
<td><span class="swatch" style="background:#6b7280"></span>#6b7280 — Montserrat 9px 700 uppercase<span class="pass">4.8:1 AA ✓</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Meta value</td>
|
||||||
|
<td><span class="swatch" style="background:#012851"></span>#012851 — Tinos 13px<span class="pass">14.5:1 AAA ✓</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Doc title</td>
|
||||||
|
<td>Tinos serif 18px · #012851</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Avatar KR</td>
|
||||||
|
<td><span class="swatch" style="background:#012851"></span>#012851 — navy</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Avatar HR</td>
|
||||||
|
<td><span class="swatch" style="background:#5a2d6f"></span>#5a2d6f — purple</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!-- Dark -->
|
||||||
|
<div class="token-table dark">
|
||||||
|
<div class="token-head">Dark theme — surface #011526</div>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Pill bg</td>
|
||||||
|
<td><span class="swatch bordered" style="background:rgba(0,199,177,.10);border-color:#0d3358"></span>rgba(0,199,177,.10) — dark teal wash<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">passes AA ✓</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Pill border</td>
|
||||||
|
<td><span class="swatch" style="background:#00c7b1"></span>#00c7b1 — turquoise</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Pill text</td>
|
||||||
|
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9 — warm white<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">14.5:1 AAA ✓</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Person name</td>
|
||||||
|
<td><span class="swatch" style="background:#9ca3af"></span>#9ca3af — (sub-header)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Meta person name</td>
|
||||||
|
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9 — (metadata drawer)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sub-header bg</td>
|
||||||
|
<td><span class="swatch" style="background:#011526;border:1px solid #0d3358"></span>#011526</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sub-header border</td>
|
||||||
|
<td><span class="swatch" style="background:#0d3358"></span>#0d3358</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Arrow (decorative)</td>
|
||||||
|
<td><span class="swatch" style="background:#00c7b1"></span>#00c7b1 — <code>aria-hidden</code><span class="warn" style="background:rgba(254,243,199,.1);color:#FDE68A;border:none">non-text only</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Meta label</td>
|
||||||
|
<td><span class="swatch" style="background:#8b97a5"></span>#8b97a5<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">7.1:1 AAA ✓</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Meta value</td>
|
||||||
|
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">14.5:1 AAA ✓</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Doc title</td>
|
||||||
|
<td>Tinos serif 18px · #f0efe9</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size:10.5px;color:#888;font-style:italic;margin-top:6px">
|
||||||
|
⚠ Pill background rgba(161,220,216,.25) is nearly transparent on white — the effective contrast for the text is calculated against the near-white composite, yielding ~14:1.
|
||||||
|
The arrow between sender and receiver chips in the sub-header is <code>aria-hidden="true"</code> — directional meaning is conveyed by DOM order (sender before receiver) and the visual left-to-right reading order.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══ SECTION 2 — DESKTOP LIGHT & DARK ═════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="sh">
|
||||||
|
<h2>2 · Desktop (1280 px) — light & dark</h2>
|
||||||
|
<p>
|
||||||
|
Full document detail page at ~65% scale. Sub-header bar (48 px) shows inline pills next to avatar chips.
|
||||||
|
Metadata drawer is open, showing pills next to person names in the Personen column.
|
||||||
|
Both light and dark themes shown side by side.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split-screens">
|
||||||
|
|
||||||
|
<!-- ── LIGHT ── -->
|
||||||
|
<div>
|
||||||
|
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Light theme</div>
|
||||||
|
<div class="chrome">
|
||||||
|
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||||
|
<!-- App header -->
|
||||||
|
<div class="app-nav">
|
||||||
|
<div class="app-logo">FAMILIENARCHIV</div>
|
||||||
|
<div style="width:1px;height:14px;background:rgba(255,255,255,.12);margin:0 4px;flex-shrink:0"></div>
|
||||||
|
<div class="app-link on">Dokumente</div>
|
||||||
|
<div class="app-link">Personen</div>
|
||||||
|
<div class="app-link">Stammbaum</div>
|
||||||
|
<div class="app-link">Admin</div>
|
||||||
|
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<!-- Sub-header -->
|
||||||
|
<div class="sub-header">
|
||||||
|
<div class="back-btn">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="sh-divider"></div>
|
||||||
|
<div class="sh-doc-title">W-0311 · Divacca</div>
|
||||||
|
<div class="sh-persons">
|
||||||
|
<!-- Sender chip + pill -->
|
||||||
|
<div class="sh-chip">
|
||||||
|
<div class="sh-av" style="background:#012851">KR</div>
|
||||||
|
<span class="sh-name">Karl Raddatz</span>
|
||||||
|
<span class="pill">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
<!-- Arrow -->
|
||||||
|
<svg class="sh-arrow" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5-5 5M6 12h12"/></svg>
|
||||||
|
<!-- Receiver chip + pill -->
|
||||||
|
<div class="sh-chip">
|
||||||
|
<div class="sh-av" style="background:#5a2d6f">HR</div>
|
||||||
|
<span class="sh-name">Hans Raddatz</span>
|
||||||
|
<span class="pill">KIND</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sh-actions">
|
||||||
|
<div class="sh-btn-ghost">Details ▾</div>
|
||||||
|
<div class="sh-btn-ghost">Transkribieren</div>
|
||||||
|
<div class="sh-btn-primary">Bearbeiten</div>
|
||||||
|
<div class="sh-btn-icon">
|
||||||
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Metadata drawer -->
|
||||||
|
<div class="meta-drawer">
|
||||||
|
<div class="meta-grid">
|
||||||
|
<!-- Col 1: Details -->
|
||||||
|
<div>
|
||||||
|
<div class="meta-col-head">Details</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Datum</div>
|
||||||
|
<div class="meta-value">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Ort</div>
|
||||||
|
<div class="meta-value">Divacca</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Status</div>
|
||||||
|
<div class="meta-value">Hochgeladen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Col 2: Personen with inline pills -->
|
||||||
|
<div>
|
||||||
|
<div class="meta-col-head">Personen</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Absender</div>
|
||||||
|
<div class="person-card">
|
||||||
|
<div class="p-av" style="background:#012851">KR</div>
|
||||||
|
<span class="p-name">Karl Raddatz</span>
|
||||||
|
<span class="pill">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Empfänger</div>
|
||||||
|
<div class="person-card">
|
||||||
|
<div class="p-av" style="background:#5a2d6f">HR</div>
|
||||||
|
<span class="p-name">Hans Raddatz</span>
|
||||||
|
<span class="pill">KIND</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Col 3: Tags -->
|
||||||
|
<div>
|
||||||
|
<div class="meta-col-head">Schlagwörter</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:4px">
|
||||||
|
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
|
||||||
|
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
|
||||||
|
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">Berlin</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- PDF area -->
|
||||||
|
<div class="pdf-area">
|
||||||
|
<div class="paper"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div><div class="pl"></div><div class="ps" style="width:75%"></div><div class="pl" style="width:88%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="cap">Light. Pills appear in both the sub-header chip row and the metadata Personen column. Arrow between chips is mint-coloured and aria-hidden.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── DARK ── -->
|
||||||
|
<div>
|
||||||
|
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Dark theme</div>
|
||||||
|
<div class="chrome dark">
|
||||||
|
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||||
|
<div class="app-nav">
|
||||||
|
<div class="app-logo">FAMILIENARCHIV</div>
|
||||||
|
<div style="width:1px;height:14px;background:rgba(255,255,255,.12);margin:0 4px;flex-shrink:0"></div>
|
||||||
|
<div class="app-link on">Dokumente</div>
|
||||||
|
<div class="app-link">Personen</div>
|
||||||
|
<div class="app-link">Stammbaum</div>
|
||||||
|
<div class="app-link">Admin</div>
|
||||||
|
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="sub-header">
|
||||||
|
<div class="back-btn">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="sh-divider"></div>
|
||||||
|
<div class="sh-doc-title">W-0311 · Divacca</div>
|
||||||
|
<div class="sh-persons">
|
||||||
|
<div class="sh-chip">
|
||||||
|
<div class="sh-av" style="background:#012851">KR</div>
|
||||||
|
<span class="sh-name">Karl Raddatz</span>
|
||||||
|
<span class="pill">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
<svg class="sh-arrow" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5-5 5M6 12h12"/></svg>
|
||||||
|
<div class="sh-chip">
|
||||||
|
<div class="sh-av" style="background:#5a2d6f">HR</div>
|
||||||
|
<span class="sh-name">Hans Raddatz</span>
|
||||||
|
<span class="pill">KIND</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sh-actions">
|
||||||
|
<div class="sh-btn-ghost">Details ▾</div>
|
||||||
|
<div class="sh-btn-ghost">Transkribieren</div>
|
||||||
|
<div class="sh-btn-primary">Bearbeiten</div>
|
||||||
|
<div class="sh-btn-icon">
|
||||||
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-drawer">
|
||||||
|
<div class="meta-grid">
|
||||||
|
<div>
|
||||||
|
<div class="meta-col-head">Details</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Datum</div>
|
||||||
|
<div class="meta-value">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Ort</div>
|
||||||
|
<div class="meta-value">Divacca</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Status</div>
|
||||||
|
<div class="meta-value">Hochgeladen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="meta-col-head">Personen</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Absender</div>
|
||||||
|
<div class="person-card">
|
||||||
|
<div class="p-av" style="background:#012851">KR</div>
|
||||||
|
<span class="p-name">Karl Raddatz</span>
|
||||||
|
<span class="pill">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label">Empfänger</div>
|
||||||
|
<div class="person-card">
|
||||||
|
<div class="p-av" style="background:#5a2d6f">HR</div>
|
||||||
|
<span class="p-name">Hans Raddatz</span>
|
||||||
|
<span class="pill">KIND</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="meta-col-head">Schlagwörter</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:4px">
|
||||||
|
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
|
||||||
|
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
|
||||||
|
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Berlin</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-area">
|
||||||
|
<div class="paper"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div><div class="pl"></div><div class="ps" style="width:75%"></div><div class="pl" style="width:88%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="cap">Dark. Pills flip to rgba(0,199,177,.10) bg, #00c7b1 border, #f0efe9 text. Sub-header and metadata surfaces both use #011526.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══ SECTION 3 — TABLET (768 px) ══════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="sh">
|
||||||
|
<h2>3 · Tablet (768 px)</h2>
|
||||||
|
<p>
|
||||||
|
The 3-column metadata grid collapses to a single stacked column. The sub-header truncates the document
|
||||||
|
title and moves secondary actions behind a "…" overflow button. Pills remain inline next to person names in both locations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="responsive-grid">
|
||||||
|
<!-- Tablet light -->
|
||||||
|
<div>
|
||||||
|
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Tablet · 768 px · Light</div>
|
||||||
|
<div class="chrome" style="max-width:400px">
|
||||||
|
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||||
|
<div class="app-nav">
|
||||||
|
<div class="app-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<!-- Tablet sub-header: back + title truncated + overflow -->
|
||||||
|
<div class="sub-header-tablet">
|
||||||
|
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#6b7280">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style="width:1px;height:16px;background:#E4E2D7;margin:0 5px;flex-shrink:0"></div>
|
||||||
|
<div class="sh-title-truncated">W-0311 · Divacca</div>
|
||||||
|
<div style="display:flex;gap:4px;flex-shrink:0">
|
||||||
|
<div class="sh-btn-primary" style="height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center">Bearbeiten</div>
|
||||||
|
<div class="sh-overflow-btn">···</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Stacked metadata — Personen section with pills -->
|
||||||
|
<div class="meta-stacked">
|
||||||
|
<div class="meta-section-head">Personen</div>
|
||||||
|
<div style="margin-bottom:6px">
|
||||||
|
<div class="meta-label">Absender</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#012851;width:18px;height:18px;font-size:6px">KR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9px;color:#012851">Karl Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:10px">
|
||||||
|
<div class="meta-label">Empfänger</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#5a2d6f;width:18px;height:18px;font-size:6px">HR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9px;color:#012851">Hans Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KIND</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-section-head">Details</div>
|
||||||
|
<div class="meta-label">Ort</div>
|
||||||
|
<div class="meta-value">Divacca</div>
|
||||||
|
<div class="meta-label" style="margin-top:6px">Status</div>
|
||||||
|
<div class="meta-value">Hochgeladen</div>
|
||||||
|
<div class="meta-label" style="margin-top:6px">Schlagwörter</div>
|
||||||
|
<div style="display:flex;gap:4px;margin-top:3px">
|
||||||
|
<span style="background:#f5f4ef;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
|
||||||
|
<span style="background:#f5f4ef;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-area" style="min-height:60px">
|
||||||
|
<div class="paper" style="width:55%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="cap">Tablet light. 3-column metadata collapses to single column. Pills stay inline with names. Sub-header shows only title + primary action + overflow menu.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tablet dark -->
|
||||||
|
<div>
|
||||||
|
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Tablet · 768 px · Dark</div>
|
||||||
|
<div class="chrome dark" style="max-width:400px">
|
||||||
|
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||||
|
<div class="app-nav">
|
||||||
|
<div class="app-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="sub-header-tablet" style="background:#011526;border-bottom:1px solid #0d3358">
|
||||||
|
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#8b97a5">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style="width:1px;height:16px;background:#0d3358;margin:0 5px;flex-shrink:0"></div>
|
||||||
|
<div style="font-family:'Tinos',serif;font-size:9px;font-weight:700;color:#f0efe9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0">W-0311 · Divacca</div>
|
||||||
|
<div style="display:flex;gap:4px;flex-shrink:0">
|
||||||
|
<div style="height:22px;padding:0 7px;background:#A1DCD8;border-radius:3px;font-size:6.5px;font-weight:700;color:#012851;display:flex;align-items:center">Bearbeiten</div>
|
||||||
|
<div style="width:22px;height:22px;border:1.5px solid #0d3358;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#8b97a5;font-size:9px;font-weight:700">···</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:12px 14px;background:#011526;border-bottom:1px solid #0d3358;font-size:9px">
|
||||||
|
<div style="font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#8b97a5;margin-bottom:8px">Personen</div>
|
||||||
|
<div style="margin-bottom:6px">
|
||||||
|
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Absender</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div style="width:18px;height:18px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">KR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9">Karl Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:10px">
|
||||||
|
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Empfänger</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div style="width:18px;height:18px;border-radius:50%;background:#5a2d6f;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">HR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9">Hans Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px">KIND</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#8b97a5;margin-bottom:8px">Details</div>
|
||||||
|
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Ort</div>
|
||||||
|
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Divacca</div>
|
||||||
|
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Status</div>
|
||||||
|
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Hochgeladen</div>
|
||||||
|
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Schlagwörter</div>
|
||||||
|
<div style="display:flex;gap:4px;margin-top:3px">
|
||||||
|
<span style="background:#011a30;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
|
||||||
|
<span style="background:#011a30;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-area" style="min-height:60px">
|
||||||
|
<div class="paper" style="width:55%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="cap">Tablet dark. Same collapse behaviour. Dark pill tokens apply throughout.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══ SECTION 4 — MOBILE (375 px) ══════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="sh">
|
||||||
|
<h2>4 · Mobile (375 px)</h2>
|
||||||
|
<p>
|
||||||
|
Sub-header is simplified to back arrow and document title only — no person chips in the bar.
|
||||||
|
Metadata is full-width single column. Each person row is <code>flex; align-items: center; flex-wrap: nowrap</code>
|
||||||
|
— avatar, name, and pill on one line. If the name is very long the row wraps gracefully before the pill.
|
||||||
|
Only primary action buttons are shown.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="responsive-grid">
|
||||||
|
<!-- Mobile light -->
|
||||||
|
<div>
|
||||||
|
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Mobile · 375 px · Light</div>
|
||||||
|
<div class="chrome" style="max-width:260px">
|
||||||
|
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||||
|
<div class="app-nav">
|
||||||
|
<div class="app-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<!-- Mobile sub-header: back + title only -->
|
||||||
|
<div class="sub-header-mobile">
|
||||||
|
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style="width:1px;height:16px;background:#E4E2D7;margin:0 5px;flex-shrink:0"></div>
|
||||||
|
<div class="sh-title-mobile">W-0311 · Divacca</div>
|
||||||
|
<div style="height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center;flex-shrink:0">Bearbeiten</div>
|
||||||
|
</div>
|
||||||
|
<!-- Mobile metadata: full-width stacked -->
|
||||||
|
<div class="meta-mobile">
|
||||||
|
<div class="m-label">Absender</div>
|
||||||
|
<div class="person-row-mobile">
|
||||||
|
<div class="p-av-sm" style="background:#012851">KR</div>
|
||||||
|
<span class="p-nm">Karl Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px;white-space:nowrap">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-label">Empfänger</div>
|
||||||
|
<div class="person-row-mobile">
|
||||||
|
<div class="p-av-sm" style="background:#5a2d6f">HR</div>
|
||||||
|
<span class="p-nm">Hans Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px;white-space:nowrap">KIND</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-label">Ort</div>
|
||||||
|
<div class="m-value">Divacca</div>
|
||||||
|
<div class="m-label">Status</div>
|
||||||
|
<div class="m-value">Hochgeladen</div>
|
||||||
|
<div class="m-label">Schlagwörter</div>
|
||||||
|
<div style="display:flex;gap:3px;margin-top:3px;flex-wrap:wrap">
|
||||||
|
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
|
||||||
|
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
|
||||||
|
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">Berlin</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-area" style="min-height:60px">
|
||||||
|
<div class="paper" style="width:60%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:78%"></div><div class="ps" style="width:62%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="cap">Mobile light. No chips in sub-header — only title + primary action. Person rows: avatar + name + pill, flex-wrap:nowrap. Pill text drops to 6px to fit.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile dark -->
|
||||||
|
<div>
|
||||||
|
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Mobile · 375 px · Dark</div>
|
||||||
|
<div class="chrome dark" style="max-width:260px">
|
||||||
|
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||||
|
<div class="app-nav">
|
||||||
|
<div class="app-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<div style="height:48px;background:#011526;border-bottom:1px solid #0d3358;display:flex;align-items:center;padding:0 10px;gap:5px;flex-shrink:0">
|
||||||
|
<div style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#8b97a5;flex-shrink:0">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div style="width:1px;height:16px;background:#0d3358;margin:0 5px;flex-shrink:0"></div>
|
||||||
|
<div style="font-family:'Tinos',serif;font-size:9px;font-weight:700;color:#f0efe9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0">W-0311 · Divacca</div>
|
||||||
|
<div style="height:22px;padding:0 7px;background:#A1DCD8;border-radius:3px;font-size:6.5px;font-weight:700;color:#012851;display:flex;align-items:center;flex-shrink:0">Bearbeiten</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:10px 12px;background:#011526;border-bottom:1px solid #0d3358;font-size:8.5px">
|
||||||
|
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Absender</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;margin-bottom:2px">
|
||||||
|
<div style="width:18px;height:18px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">KR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;white-space:nowrap">Karl Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px;white-space:nowrap">ELTERNTEIL</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px;margin-top:6px">Empfänger</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;margin-bottom:8px">
|
||||||
|
<div style="width:18px;height:18px;border-radius:50%;background:#5a2d6f;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">HR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;white-space:nowrap">Hans Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px;white-space:nowrap">KIND</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Ort</div>
|
||||||
|
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Divacca</div>
|
||||||
|
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Status</div>
|
||||||
|
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Hochgeladen</div>
|
||||||
|
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Schlagwörter</div>
|
||||||
|
<div style="display:flex;gap:3px;margin-top:3px;flex-wrap:wrap">
|
||||||
|
<span style="background:#011a30;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
|
||||||
|
<span style="background:#011a30;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pdf-area" style="min-height:60px">
|
||||||
|
<div class="paper" style="width:60%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:78%"></div><div class="ps" style="width:62%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="cap">Mobile dark. Pill tokens #00c7b1/#f0efe9 at reduced 6px font — still passes AA on dark surface.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══ SECTION 5 — EDGE CASES ════════════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="sh">
|
||||||
|
<h2>5 · Edge cases — when no pill is rendered</h2>
|
||||||
|
<p>Three cases where the pill is silently omitted. The person name renders as normal — no gap, no placeholder.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="edge-grid">
|
||||||
|
|
||||||
|
<!-- Edge 1: no family relationship -->
|
||||||
|
<div class="edge-card">
|
||||||
|
<div class="edge-head">No family relationship → no pill</div>
|
||||||
|
<div class="edge-body">
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#012851">KR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
|
||||||
|
<!-- no pill -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#888">ME</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Maria Engel</span>
|
||||||
|
<!-- no pill -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="no-badge">— no pill —</div>
|
||||||
|
<div class="edge-note"><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">inferredRelationship === null</code> because the backend returns 404 (no kinship path). Name renders without trailing pill.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edge 2: social relationship (Kollegen) → pill shows label -->
|
||||||
|
<div class="edge-card">
|
||||||
|
<div class="edge-head">Social relationship (Kollegen) → pill shows label</div>
|
||||||
|
<div class="edge-body">
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#012851">KR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KOLLEGE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#3d6b5a">FW</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Fritz Weber</span>
|
||||||
|
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KOLLEGE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edge-note">Non-family relationships (Kollege, Freund, etc.) returned by the inference endpoint still render as pills. The pill component is label-agnostic — it renders whatever <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">inferredRelationship</code> provides.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edge 3: multiple receivers → no pill -->
|
||||||
|
<div class="edge-card">
|
||||||
|
<div class="edge-head">Multiple receivers → no pill</div>
|
||||||
|
<div class="edge-body">
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#012851">KR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-field">
|
||||||
|
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#5a2d6f">HR</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Hans Raddatz</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||||
|
<div class="p-av" style="background:#6a7a52">ER</div>
|
||||||
|
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Elfriede Raddatz</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="no-badge">— no pill —</div>
|
||||||
|
<div class="edge-note"><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">receivers.length > 1</code> — inference endpoint is never called, <code>inferredRelationship</code> is <code>null</code>. No pill on any person chip.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══ SECTION 6 — IMPLEMENTATION REFERENCE TABLE ═══════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="sh">
|
||||||
|
<h2>6 · Implementation reference</h2>
|
||||||
|
<p>Exact CSS/Tailwind values for every element of the pill and its context. Use these as the ground truth during implementation review.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rules">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Element</th>
|
||||||
|
<th>Tailwind / CSS</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Inline pill (light)</td>
|
||||||
|
<td><code>rounded-full border border-[#a1dcd8] bg-[rgba(161,220,216,.25)] px-2 py-0.5 text-[9px] font-bold uppercase tracking-[.07em] text-[#012851] ml-2 align-middle inline</code></td>
|
||||||
|
<td>Montserrat 9px 700. <code>ml-2</code> = 8px from name span. <code>vertical-align: middle</code> aligns cap-height to person name.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Inline pill (dark)</td>
|
||||||
|
<td><code>dark:bg-[rgba(0,199,177,.10)] dark:border-[#00c7b1] dark:text-[#f0efe9]</code></td>
|
||||||
|
<td>All three dark overrides applied together. Rest of pill class unchanged.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Person name span</td>
|
||||||
|
<td><code>font-sans text-[11px] text-[#4b5563] dark:text-[#9ca3af]</code> (sub-header) or <code>font-serif text-[9.5px] text-ink dark:text-[#f0efe9]</code> (metadata)</td>
|
||||||
|
<td>Name and pill share a <code>flex items-center gap-0</code> wrapper. Pill is the immediate next sibling of the name <code><span></code>.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sub-header chip area</td>
|
||||||
|
<td><code>flex items-center gap-1.5</code></td>
|
||||||
|
<td>Wraps one sender chip + arrow + one receiver chip. Placed after the doc-title block, before action buttons.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Chip (avatar + name + pill)</td>
|
||||||
|
<td><code>flex items-center gap-1</code></td>
|
||||||
|
<td>Avatar, name span, and pill as three siblings inside the chip div.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Arrow between chips (sub-header)</td>
|
||||||
|
<td><code>h-2.5 w-2.5 shrink-0 text-[#a1dcd8] dark:text-[#00c7b1]</code> with <code>aria-hidden="true"</code></td>
|
||||||
|
<td>Arrow SVG carries no semantic information. DOM order (sender chip before receiver chip) conveys direction for assistive technology.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Person avatar (sub-header)</td>
|
||||||
|
<td><code>w-5 h-5 rounded-full flex items-center justify-center text-[6px] font-bold text-white shrink-0</code></td>
|
||||||
|
<td>20×20 px. Initials in 6px bold white. Background colour is person-specific (set inline).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Person avatar (metadata)</td>
|
||||||
|
<td><code>w-5 h-5 rounded-full flex items-center justify-center text-[6.5px] font-extrabold text-white shrink-0</code></td>
|
||||||
|
<td>Same 20×20 px. Slightly heavier weight (800) to match existing drawer card style.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Pill condition</td>
|
||||||
|
<td><code>{#if inferredRelationship} … {/if}</code> wraps both the sender pill and the receiver pill</td>
|
||||||
|
<td>Render only when <code>inferredRelationship !== null && receivers.length === 1</code>. The check lives in <code>+page.server.ts</code>, not in the component.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Pill label value</td>
|
||||||
|
<td><code>inferredRelationship.labelFromA</code> next to sender, <code>inferredRelationship.labelFromB</code> next to receiver</td>
|
||||||
|
<td>Labels are pre-translated strings from the backend. No frontend i18n key needed for the label text itself.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Mobile person row</td>
|
||||||
|
<td><code>flex items-center gap-1 flex-nowrap</code></td>
|
||||||
|
<td><code>flex-wrap: nowrap</code> keeps avatar + name + pill on one line. If name overflows container, truncate name with <code>truncate</code>, never truncate the pill.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Mobile pill font-size</td>
|
||||||
|
<td><code>text-[6px]</code> at ≤375 px</td>
|
||||||
|
<td>Reduced from 9px (desktop) to 6px on mobile to fit without overflow. Contrast still passes AA at 6px bold.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sub-header at mobile</td>
|
||||||
|
<td>Chips removed entirely from sub-header at <code>max-width: 767px</code></td>
|
||||||
|
<td>Sub-header shows only back arrow + document title + primary action button. Person chips with pills appear only in the metadata section on mobile.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size:10.5px;color:#888;margin-top:12px;line-height:1.7">
|
||||||
|
<strong style="color:#012851">Accessibility note:</strong> The pill text ("ELTERNTEIL", "KIND") is uppercase visually but the accessible name should be the mixed-case label from the backend (<code>labelFromA</code>). Apply <code>aria-label={labelFromA}</code> on the pill span so screen readers announce "Elternteil" not "E-L-T-E-R-N-T-E-I-L". The visual uppercase is achieved with CSS <code>text-transform: uppercase</code>, not by changing the source string.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div><!-- /doc -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1135
docs/specs/stammbaum-person-edit-spec.html
Normal file
1135
docs/specs/stammbaum-person-edit-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
1043
docs/specs/stammbaum-tree-spec.html
Normal file
1043
docs/specs/stammbaum-tree-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ bun.lockb
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
/.svelte-kit/
|
/.svelte-kit/
|
||||||
/.svelte-kit-backup/
|
/.svelte-kit-backup/
|
||||||
|
/.svelte-kit.old/
|
||||||
|
|
||||||
# Generated files
|
# Generated files
|
||||||
/.svelte-kit-backup/
|
/.svelte-kit-backup/
|
||||||
|
|||||||
197
frontend/CLAUDE.md
Normal file
197
frontend/CLAUDE.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# Frontend — Familienarchiv
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
SvelteKit 2 application providing the Familienarchiv web UI. Server-side rendered (SSR) where beneficial, with client-side interactivity for document viewing, transcription, annotation, and admin workflows.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Framework**: SvelteKit 2 with Svelte 5 (runes mode)
|
||||||
|
- **Language**: TypeScript 5.9
|
||||||
|
- **Styling**: Tailwind CSS 4.1 + custom brand utilities
|
||||||
|
- **Build Tool**: Vite 7
|
||||||
|
- **Adapter**: `@sveltejs/adapter-node` (Node.js server, not static)
|
||||||
|
- **i18n**: Paraglide.js 2.5 (`@inlang/paraglide-js`) — German (default), English, Spanish
|
||||||
|
- **API Client**: `openapi-fetch` + `openapi-typescript` (generated from backend OpenAPI spec)
|
||||||
|
- **PDF Rendering**: `pdfjs-dist` (PDF.js)
|
||||||
|
- **Testing**:
|
||||||
|
- Unit/Server: Vitest 4 (Node environment)
|
||||||
|
- Component: Vitest Browser Mode with Playwright (Chromium)
|
||||||
|
- E2E: Playwright (`frontend/e2e/`)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── routes/ # SvelteKit file-based routing
|
||||||
|
│ ├── +layout.svelte # Global layout: header, nav, auth state
|
||||||
|
│ ├── +layout.server.ts # Loads current user, injects auth cookie
|
||||||
|
│ ├── +page.svelte # Home / document search dashboard
|
||||||
|
│ ├── documents/ # Document CRUD, detail, edit, upload
|
||||||
|
│ ├── persons/ # Person directory, detail, edit, merge
|
||||||
|
│ ├── briefwechsel/ # Bilateral conversation timeline
|
||||||
|
│ ├── chronik/ # Unified activity feed
|
||||||
|
│ ├── admin/ # User, group, tag, OCR, system management
|
||||||
|
│ ├── api/ # Internal API proxies (server-side only)
|
||||||
|
│ ├── login/ logout/ # Auth pages
|
||||||
|
│ └── ...
|
||||||
|
├── lib/
|
||||||
|
│ ├── components/ # Reusable Svelte components
|
||||||
|
│ │ ├── document/ # Document-specific components
|
||||||
|
│ │ ├── chronik/ # Activity feed components
|
||||||
|
│ │ └── user/ # User-related components
|
||||||
|
│ ├── generated/ # Auto-generated API types (openapi-typescript)
|
||||||
|
│ ├── server/ # Server-only utilities (db, auth helpers)
|
||||||
|
│ ├── services/ # Client-side service logic
|
||||||
|
│ ├── stores/ # Svelte stores (global state)
|
||||||
|
│ ├── types.ts # Shared TypeScript types
|
||||||
|
│ ├── errors.ts # Error code mapping (mirrors backend ErrorCode)
|
||||||
|
│ ├── api.server.ts # Typed API client factory
|
||||||
|
│ ├── utils.ts # Shared utilities
|
||||||
|
│ ├── relativeTime.ts # Time formatting
|
||||||
|
│ ├── search.ts # Search utilities
|
||||||
|
│ └── paraglide/ # Generated i18n code
|
||||||
|
├── hooks/ # SvelteKit hooks (handle, handleFetch)
|
||||||
|
└── actions/ # Custom Svelte actions (click outside, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Client Pattern
|
||||||
|
|
||||||
|
All server-side API calls use the typed client from `$lib/api.server.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
|
||||||
|
|
||||||
|
// Always check via response.ok, NOT result.error
|
||||||
|
if (!result.response.ok) {
|
||||||
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
|
}
|
||||||
|
return { person: result.data! };
|
||||||
|
```
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
|
||||||
|
- Use `!result.response.ok` for error checking (not `if (result.error)` — breaks when spec has no error responses defined)
|
||||||
|
- Cast errors as `result.error as unknown as { code?: string }` to extract backend error code
|
||||||
|
- Use `result.data!` after an ok check
|
||||||
|
|
||||||
|
For multipart/form-data (file uploads), bypass the typed client and use raw `fetch`.
|
||||||
|
|
||||||
|
## Form Actions Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// +page.server.ts
|
||||||
|
export const actions = {
|
||||||
|
default: async ({ request, fetch }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
// ...
|
||||||
|
return fail(400, { error: 'message' }); // on error
|
||||||
|
throw redirect(303, '/target'); // on success
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Date Handling
|
||||||
|
|
||||||
|
- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `<input type="hidden" name="documentDate" value={dateIso}>` sends ISO to the backend.
|
||||||
|
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC off-by-one:
|
||||||
|
```typescript
|
||||||
|
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(
|
||||||
|
new Date(doc.documentDate + 'T12:00:00')
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling Conventions (Tailwind CSS 4)
|
||||||
|
|
||||||
|
Brand color utilities (defined in `layout.css`):
|
||||||
|
|
||||||
|
| Class | Value | Usage |
|
||||||
|
| ------------ | --------- | -------------------------------- |
|
||||||
|
| `brand-navy` | `#002850` | Primary text, buttons, headers |
|
||||||
|
| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons |
|
||||||
|
| `brand-sand` | `#E4E2D7` | Page background, card borders |
|
||||||
|
|
||||||
|
Typography:
|
||||||
|
|
||||||
|
- `font-serif` (Merriweather) — body text, document titles, names
|
||||||
|
- `font-sans` (Montserrat) — labels, metadata, UI chrome
|
||||||
|
|
||||||
|
Card pattern for content sections:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
|
||||||
|
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">Section</h2>
|
||||||
|
<!-- content -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key UI Components
|
||||||
|
|
||||||
|
| Component | Props | Description |
|
||||||
|
| -------------------- | ---------------------------------------------------- | ------------------------------------- |
|
||||||
|
| `PersonTypeahead` | `name`, `label`, `value`, `initialName`, `on:change` | Single-person selector with typeahead |
|
||||||
|
| `PersonMultiSelect` | `selectedPersons` (bind) | Chip-based multi-person selector |
|
||||||
|
| `TagInput` | `tags` (bind), `allowCreation?`, `on:change` | Tag chip input with typeahead |
|
||||||
|
| `PdfViewer` | `url`, `annotations`, `on:annotation` | PDF rendering with annotation overlay |
|
||||||
|
| `TranscriptionBlock` | `block`, `mode` | Read/edit transcription block |
|
||||||
|
| `DocumentTopBar` | `document` | Responsive document metadata header |
|
||||||
|
|
||||||
|
## How to Run
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # Dev server on port 5173 (or 3000 if --port 3000)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build & Preview
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # Production build
|
||||||
|
npm run preview # Preview production build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint # Prettier + ESLint check
|
||||||
|
npm run format # Auto-fix formatting
|
||||||
|
npm run check # svelte-check (type checking)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test # Vitest unit + server tests (headless)
|
||||||
|
npm run test:coverage # Coverage report (server project only)
|
||||||
|
npm run test:e2e # Playwright E2E tests
|
||||||
|
npm run test:e2e:headed # Playwright E2E with visible browser
|
||||||
|
npm run test:e2e:ui # Playwright UI mode
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regenerate API Types
|
||||||
|
|
||||||
|
Requires backend running with `--spring.profiles.active=dev`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run generate:api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vite Proxy
|
||||||
|
|
||||||
|
During development, `/api` calls are proxied to the Spring Boot backend. The proxy injects the `Authorization` header from the `auth_token` cookie automatically (see `vite.config.ts`).
|
||||||
|
|
||||||
|
## i18n (Paraglide)
|
||||||
|
|
||||||
|
Translations live in `messages/{de,en,es}.json`. The compiler generates type-safe helpers in `src/lib/paraglide/`. Run compilation manually with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
||||||
|
```
|
||||||
|
|
||||||
|
Or let the Vite plugin handle it automatically during dev/build.
|
||||||
141
frontend/e2e/CLAUDE.md
Normal file
141
frontend/e2e/CLAUDE.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# E2E Tests — Familienarchiv
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
End-to-end tests for the Familienarchiv frontend using Playwright. These tests verify complete user flows across the full stack (SvelteKit frontend + Spring Boot backend + PostgreSQL + MinIO).
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Test Runner**: Playwright (`@playwright/test`)
|
||||||
|
- **Browser**: Chromium (desktop)
|
||||||
|
- **Locale**: `de-DE` (ensures German language detection)
|
||||||
|
- **Auth**: Shared session cookie stored after setup
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/e2e/
|
||||||
|
├── auth.setup.ts # Authentication setup — logs in and saves session
|
||||||
|
├── auth.spec.ts # Authentication flows (login, logout, register)
|
||||||
|
├── admin.spec.ts # Admin panel CRUD operations
|
||||||
|
├── annotations.spec.ts # Document annotation features
|
||||||
|
├── bottom-panel.spec.ts # Bottom panel / transcription panel
|
||||||
|
├── dashboard-*.spec.ts # Dashboard variants and screenshots
|
||||||
|
├── documents.spec.ts # Document upload, edit, search
|
||||||
|
├── focus-rings.spec.ts # Accessibility focus ring tests
|
||||||
|
├── header.spec.ts # Navigation header
|
||||||
|
├── history.spec.ts # Chronik / activity feed
|
||||||
|
├── korrespondenz.spec.ts # Correspondence timeline
|
||||||
|
├── lang.spec.ts # Language switching
|
||||||
|
├── password-reset.spec.ts # Password reset flow
|
||||||
|
├── permissions.spec.ts # Role-based access control
|
||||||
|
├── persons.spec.ts # Person directory CRUD
|
||||||
|
├── profile.spec.ts # User profile
|
||||||
|
├── theme.spec.ts # Dark/light mode
|
||||||
|
├── transcription.spec.ts # Transcription workflows
|
||||||
|
├── accessibility.spec.ts # Axe accessibility scans
|
||||||
|
├── fixtures/ # Test data fixtures
|
||||||
|
└── helpers/ # Test helper utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication Strategy
|
||||||
|
|
||||||
|
Tests share auth state via a stored session cookie:
|
||||||
|
|
||||||
|
1. **Setup** (`auth.setup.ts`): Logs in with test credentials and saves `storageState` to `e2e/.auth/user.json`
|
||||||
|
2. **Tests**: All test projects depend on `setup` and reuse the stored session
|
||||||
|
|
||||||
|
This avoids re-logging in for every test, but means tests **must run sequentially** (`fullyParallel: false`, `workers: 1`).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Config lives in `frontend/playwright.config.ts`:
|
||||||
|
|
||||||
|
| Setting | Value | Notes |
|
||||||
|
| --------------- | ----------------------- | ------------------------------ |
|
||||||
|
| `testDir` | `./e2e` | Test file location |
|
||||||
|
| `fullyParallel` | `false` | Shared auth state |
|
||||||
|
| `workers` | `1` | Sequential execution |
|
||||||
|
| `screenshot` | `'on'` | Always capture |
|
||||||
|
| `video` | `'retain-on-failure'` | Keep on failure |
|
||||||
|
| `trace` | `'retain-on-failure'` | Keep on failure |
|
||||||
|
| `baseURL` | `http://localhost:3000` | Overridable via `E2E_BASE_URL` |
|
||||||
|
|
||||||
|
The `webServer` config auto-starts `npm run dev -- --port 3000` if no server is detected at the base URL.
|
||||||
|
|
||||||
|
## How to Run
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
The full stack must be running (or the `webServer` config will start the frontend dev server):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start infrastructure
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Ensure backend is healthy
|
||||||
|
curl http://localhost:8080/actuator/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run E2E Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Headless (CI mode)
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
|
# With visible browser
|
||||||
|
npm run test:e2e:headed
|
||||||
|
|
||||||
|
# Interactive UI mode
|
||||||
|
npm run test:e2e:ui
|
||||||
|
|
||||||
|
# Run a specific test file
|
||||||
|
npx playwright test documents.spec.ts
|
||||||
|
|
||||||
|
# Run with a different base URL (e.g., docker frontend on 5173)
|
||||||
|
E2E_BASE_URL=http://localhost:5173 npx playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing New E2E Tests
|
||||||
|
|
||||||
|
1. Create a new `.spec.ts` file in `frontend/e2e/`
|
||||||
|
2. Use the shared auth state (no manual login needed)
|
||||||
|
3. Use page object patterns or helper functions from `helpers/`
|
||||||
|
4. Add `test-data-id` attributes to components for stable selectors
|
||||||
|
5. Run with `--debug` or `--ui` to troubleshoot
|
||||||
|
|
||||||
|
### Example Test Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('user can create a document', async ({ page }) => {
|
||||||
|
await page.goto('/documents/new');
|
||||||
|
await page.getByTestId('document-title').fill('Test Document');
|
||||||
|
await page.getByTestId('save-button').click();
|
||||||
|
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accessibility Testing
|
||||||
|
|
||||||
|
`accessibility.spec.ts` runs Axe scans on key pages. Violations fail the test.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test accessibility.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
| --------------------- | ---------------------------------------- |
|
||||||
|
| Auth failures | Delete `e2e/.auth/user.json` and re-run |
|
||||||
|
| Backend not reachable | Ensure `docker-compose up -d` is running |
|
||||||
|
| Flaky tests | Increase timeout or add explicit waits |
|
||||||
|
| Screenshots missing | Check `test-results/e2e/` |
|
||||||
|
|
||||||
|
## CI Integration
|
||||||
|
|
||||||
|
E2E tests are **not** currently run in CI (the pipeline stops at unit/component tests). To add them, extend `infra/gitea/workflows/ci.yml` with a Playwright job that starts the full Docker Compose stack first.
|
||||||
163
frontend/e2e/person-mention-read.spec.ts
Normal file
163
frontend/e2e/person-mention-read.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { test, expect, devices } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||||
|
const STORAGE_STATE = path.resolve(__dirname, '.auth/user.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E for issue #362 — Person @mentions, read-mode rendering + hover card (B20/B21).
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* - Create a document, a Person, and a transcription block whose text contains
|
||||||
|
* `@DisplayName` and whose mentionedPersons sidecar links to that person.
|
||||||
|
* - Open the document in read mode.
|
||||||
|
* - B20: page.hover() on the .person-mention link → hover card mounts.
|
||||||
|
* - B21: with context.setHasTouch(true), page.tap() on the link → navigates
|
||||||
|
* to /persons/{id} without ever showing the hover card.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let docId: string;
|
||||||
|
let personId: string;
|
||||||
|
let docHref: string;
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.describe('Person mention — read mode', () => {
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
// 1. Person we will mention.
|
||||||
|
const personRes = await request.post('/api/persons', {
|
||||||
|
data: {
|
||||||
|
firstName: 'Auguste',
|
||||||
|
lastName: 'Raddatz',
|
||||||
|
personType: 'PERSON',
|
||||||
|
birthYear: 1882,
|
||||||
|
deathYear: 1944
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!personRes.ok()) throw new Error(`Create person failed: ${personRes.status()}`);
|
||||||
|
const person = await personRes.json();
|
||||||
|
personId = person.id;
|
||||||
|
|
||||||
|
// 2. Document with a PDF so the transcription panel is mountable.
|
||||||
|
// Sara #3: timestamp the title so a previous run that crashed in beforeAll
|
||||||
|
// (and therefore skipped afterAll cleanup) cannot collide with this one.
|
||||||
|
const uniqueSuffix = Date.now();
|
||||||
|
const docRes = await request.post('/api/documents', {
|
||||||
|
multipart: {
|
||||||
|
title: `E2E Person Mention Read ${uniqueSuffix}`,
|
||||||
|
documentDate: '1945-05-08'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
|
||||||
|
const doc = await docRes.json();
|
||||||
|
docId = doc.id;
|
||||||
|
docHref = `${baseURL}/documents/${docId}`;
|
||||||
|
|
||||||
|
await request.put(`/api/documents/${docId}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title as string,
|
||||||
|
documentDate: '1945-05-08',
|
||||||
|
file: {
|
||||||
|
name: 'minimal.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Annotation to anchor the block on the page.
|
||||||
|
const annRes = await request.post(`/api/documents/${docId}/annotations`, {
|
||||||
|
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.5, height: 0.1, color: '#00C7B1' }
|
||||||
|
});
|
||||||
|
if (!annRes.ok()) throw new Error(`Create annotation failed: ${annRes.status()}`);
|
||||||
|
|
||||||
|
// 4. Block text contains @Auguste Raddatz; sidecar links it to personId.
|
||||||
|
const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||||
|
data: {
|
||||||
|
pageNumber: 1,
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.1,
|
||||||
|
width: 0.5,
|
||||||
|
height: 0.1,
|
||||||
|
text: 'Brief an @Auguste Raddatz vom Mai 1944',
|
||||||
|
label: null,
|
||||||
|
mentionedPersons: [{ personId, displayName: 'Auguste Raddatz' }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
if (docId) await request.delete(`/api/documents/${docId}`);
|
||||||
|
if (personId) await request.delete(`/api/persons/${personId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the @mention as an underlined anchor link to /persons/{id}', async ({ page }) => {
|
||||||
|
await page.goto(docHref);
|
||||||
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||||
|
|
||||||
|
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||||
|
await expect(link).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(link).toHaveAttribute('href', `/persons/${personId}`);
|
||||||
|
// The @ trigger is stripped from the rendered text per spec
|
||||||
|
await expect(link).toHaveText('Auguste Raddatz');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('B20: desktop hover mounts the hover card with loaded person data', async ({ page }) => {
|
||||||
|
await page.goto(docHref);
|
||||||
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||||
|
|
||||||
|
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||||
|
await link.hover();
|
||||||
|
|
||||||
|
const card = page.getByTestId('person-hover-card');
|
||||||
|
await expect(card).toBeVisible({ timeout: 5000 });
|
||||||
|
// Loaded state: person displayName is rendered inside the card
|
||||||
|
await expect(page.getByTestId('person-hover-card-name')).toHaveText('Auguste Raddatz');
|
||||||
|
// Footer link points to /persons/{id}
|
||||||
|
await expect(card.locator(`a[href="/persons/${personId}"]`)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('B20: hover card unmounts on mouseleave', async ({ page }) => {
|
||||||
|
await page.goto(docHref);
|
||||||
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||||
|
|
||||||
|
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||||
|
await link.hover();
|
||||||
|
await expect(page.getByTestId('person-hover-card')).toBeVisible();
|
||||||
|
|
||||||
|
// Move pointer away
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
await expect(page.getByTestId('person-hover-card')).toBeHidden({ timeout: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('B21: touch-device tap navigates without showing the hover card', async ({ browser }) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
...devices['Pixel 7'],
|
||||||
|
storageState: STORAGE_STATE
|
||||||
|
});
|
||||||
|
const touchPage = await context.newPage();
|
||||||
|
try {
|
||||||
|
await touchPage.goto(docHref);
|
||||||
|
await touchPage.getByRole('button', { name: 'Transkription' }).click();
|
||||||
|
|
||||||
|
const link = touchPage.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||||
|
await expect(link).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Sara #2: assert no card *before* the tap so the test actually proves
|
||||||
|
// the touch device suppression worked, not just that we navigated away.
|
||||||
|
await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0);
|
||||||
|
|
||||||
|
await link.tap();
|
||||||
|
// The card never mounted — the tap navigated directly per spec.
|
||||||
|
await expect(touchPage).toHaveURL(new RegExp(`/persons/${personId}`));
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
202
frontend/e2e/person-typeahead.spec.ts
Normal file
202
frontend/e2e/person-typeahead.spec.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* E2E regression tests for PersonTypeahead dropdown visibility.
|
||||||
|
*
|
||||||
|
* These tests verify that the dropdown list is never clipped by a parent
|
||||||
|
* container's stacking context — the root cause of issue #343.
|
||||||
|
*
|
||||||
|
* The tests run at both desktop (1280×720) and tablet (768×1024) viewports
|
||||||
|
* as required by the acceptance criteria.
|
||||||
|
*/
|
||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a document edit URL to use as the test page.
|
||||||
|
* Falls back to /documents/new if no existing document is found.
|
||||||
|
*/
|
||||||
|
async function getDocumentEditUrl(page: Page): Promise<string> {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
const firstDocLink = page.locator('a[href^="/documents/"]').first();
|
||||||
|
const href = await firstDocLink.getAttribute('href').catch(() => null);
|
||||||
|
if (href) {
|
||||||
|
return `${href}/edit`;
|
||||||
|
}
|
||||||
|
return '/documents/new';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for the listbox to become visible after triggering a search. */
|
||||||
|
async function waitForListbox(page: Page): Promise<void> {
|
||||||
|
await page.waitForSelector('[role="listbox"]', { state: 'visible', timeout: 2000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('PersonTypeahead — dropdown visibility (desktop)', () => {
|
||||||
|
test.use({ viewport: { width: 1280, height: 720 } });
|
||||||
|
|
||||||
|
test('sender dropdown items are visible and not clipped in document edit', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Find the sender typeahead input (the visible text input, not the hidden one)
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await expect(senderInput).toBeVisible();
|
||||||
|
|
||||||
|
// Type to trigger the dropdown
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
|
||||||
|
// Wait for the dropdown to appear (handles debounce automatically)
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const dropdown = page.locator('[role="listbox"]').first();
|
||||||
|
await expect(dropdown).toBeVisible();
|
||||||
|
|
||||||
|
const firstOption = dropdown.locator('[role="option"]').first();
|
||||||
|
await expect(firstOption).toBeVisible();
|
||||||
|
|
||||||
|
// Verify the bounding box is within the viewport (not clipped)
|
||||||
|
const box = await firstOption.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box!.y).toBeGreaterThan(0);
|
||||||
|
expect(box!.y + box!.height).toBeLessThan(720);
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-desktop.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dropdown is positioned below the input field (not hidden behind parent)', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await expect(senderInput).toBeVisible();
|
||||||
|
|
||||||
|
const inputBox = await senderInput.boundingBox();
|
||||||
|
expect(inputBox).not.toBeNull();
|
||||||
|
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const dropdown = page.locator('[role="listbox"]').first();
|
||||||
|
await expect(dropdown).toBeVisible();
|
||||||
|
|
||||||
|
const dropdownBox = await dropdown.boundingBox();
|
||||||
|
expect(dropdownBox).not.toBeNull();
|
||||||
|
|
||||||
|
// Dropdown must appear below the input, not on top or clipped behind it
|
||||||
|
expect(dropdownBox!.y).toBeGreaterThanOrEqual(inputBox!.y + inputBox!.height - 5);
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-position.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('PersonTypeahead — dropdown visibility (tablet)', () => {
|
||||||
|
test.use({ viewport: { width: 768, height: 1024 } });
|
||||||
|
|
||||||
|
test('sender dropdown items are visible and not clipped on tablet viewport', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await expect(senderInput).toBeVisible();
|
||||||
|
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const dropdown = page.locator('[role="listbox"]').first();
|
||||||
|
await expect(dropdown).toBeVisible();
|
||||||
|
|
||||||
|
const firstOption = dropdown.locator('[role="option"]').first();
|
||||||
|
await expect(firstOption).toBeVisible();
|
||||||
|
|
||||||
|
const box = await firstOption.boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box!.y).toBeGreaterThan(0);
|
||||||
|
expect(box!.y + box!.height).toBeLessThan(1024);
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-tablet.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('PersonTypeahead — keyboard navigation', () => {
|
||||||
|
test.use({ viewport: { width: 1280, height: 720 } });
|
||||||
|
|
||||||
|
test('ArrowDown moves focus to the first option', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
await senderInput.press('ArrowDown');
|
||||||
|
// First option should now be the active descendant
|
||||||
|
const activeDescendant = await senderInput.getAttribute('aria-activedescendant');
|
||||||
|
expect(activeDescendant).toBeTruthy();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/person-typeahead-keyboard-nav.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape key closes the dropdown', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const dropdown = page.locator('[role="listbox"]').first();
|
||||||
|
await expect(dropdown).toBeVisible();
|
||||||
|
await senderInput.press('Escape');
|
||||||
|
await expect(dropdown).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aria-expanded is true when dropdown is open', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
|
||||||
|
// Initially closed
|
||||||
|
const initialExpanded = await senderInput.getAttribute('aria-expanded');
|
||||||
|
expect(initialExpanded).toBe('false');
|
||||||
|
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const expanded = await senderInput.getAttribute('aria-expanded');
|
||||||
|
expect(expanded).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('PersonTypeahead — click-outside dismiss (fixed position)', () => {
|
||||||
|
test.use({ viewport: { width: 1280, height: 720 } });
|
||||||
|
|
||||||
|
test('clicking outside a fixed-position dropdown closes it', async ({ page }) => {
|
||||||
|
const editUrl = await getDocumentEditUrl(page);
|
||||||
|
await page.goto(editUrl);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const senderInput = page.locator('#senderId-search');
|
||||||
|
await senderInput.click();
|
||||||
|
await senderInput.fill('a');
|
||||||
|
await waitForListbox(page);
|
||||||
|
|
||||||
|
const dropdown = page.locator('[role="listbox"]').first();
|
||||||
|
await expect(dropdown).toBeVisible();
|
||||||
|
// Click somewhere else on the page
|
||||||
|
await page.click('body', { position: { x: 10, y: 10 } });
|
||||||
|
await expect(dropdown).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
60
frontend/e2e/stammbaum.spec.ts
Normal file
60
frontend/e2e/stammbaum.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// Tests skipped until Playwright Chromium is installed in CI — see issue #363.
|
||||||
|
test.describe('Stammbaum — issue #358', () => {
|
||||||
|
test.skip();
|
||||||
|
|
||||||
|
test('nav swap: /briefwechsel still renders without 404', async ({ page }) => {
|
||||||
|
// Plan journey 4: the /briefwechsel route must stay intact even though the
|
||||||
|
// AppNav now points at /stammbaum.
|
||||||
|
const response = await page.goto('/briefwechsel');
|
||||||
|
expect(response?.status()).toBeLessThan(400);
|
||||||
|
await expect(page).toHaveURL(/\/briefwechsel/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/stammbaum renders the page heading', async ({ page }) => {
|
||||||
|
await page.goto('/stammbaum');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Stammbaum' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/stammbaum either shows an empty state or at least one node', async ({ page }) => {
|
||||||
|
// Plan journey 3 (empty branch) and journey 1 (populated branch) covered jointly:
|
||||||
|
// the test passes whenever the page renders one of the two coherent states.
|
||||||
|
await page.goto('/stammbaum');
|
||||||
|
const empty = page.getByRole('heading', { name: 'Noch keine Familienmitglieder' });
|
||||||
|
const anyNode = page.locator('svg[role="img"][aria-label="Stammbaum"] g[role="button"]');
|
||||||
|
await expect(async () => {
|
||||||
|
const emptyVisible = await empty.isVisible().catch(() => false);
|
||||||
|
const nodeCount = await anyNode.count();
|
||||||
|
expect(emptyVisible || nodeCount > 0).toBe(true);
|
||||||
|
}).toPass();
|
||||||
|
|
||||||
|
if (await empty.isVisible().catch(() => false)) {
|
||||||
|
await expect(page.getByRole('link', { name: /Zur Personenliste/ })).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('person edit Stammbaum card surfaces the year-range error', async ({ page }) => {
|
||||||
|
// Plan task 36: Bis < Von triggers the inline error and keeps the form unsubmitted.
|
||||||
|
// We pick the first person, open the edit page, expand the add-rel form, and
|
||||||
|
// inspect the validation message bound to the Bis field.
|
||||||
|
await page.goto('/persons');
|
||||||
|
const firstPerson = page.locator('a[href^="/persons/"]').first();
|
||||||
|
await firstPerson.click();
|
||||||
|
await expect(page).toHaveURL(/\/persons\/[^/]+/);
|
||||||
|
await page.goto(page.url() + '/edit');
|
||||||
|
|
||||||
|
// Open the add-rel form
|
||||||
|
const addBtn = page.getByRole('button', { name: /Beziehung hinzufügen/i });
|
||||||
|
await addBtn.click();
|
||||||
|
|
||||||
|
// Enter Von 1935, Bis 1920 → expect the year-range error
|
||||||
|
const fromInput = page.locator('input[name="fromYear"]');
|
||||||
|
const toInput = page.locator('input[name="toYear"]');
|
||||||
|
await fromInput.fill('1935');
|
||||||
|
await toInput.fill('1920');
|
||||||
|
|
||||||
|
await expect(page.locator('#add-rel-year-error')).toBeVisible();
|
||||||
|
await expect(page.locator('#add-rel-year-error')).toContainText(/Bis.*Von|nicht vor/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,7 +12,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
|||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
includeIgnoreFile(gitignorePath),
|
includeIgnoreFile(gitignorePath),
|
||||||
{ ignores: ['src/paraglide/**'] },
|
{ ignores: ['src/paraglide/**', '.svelte-kit.old/**'] },
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...ts.configs.recommended,
|
...ts.configs.recommended,
|
||||||
...svelte.configs.recommended,
|
...svelte.configs.recommended,
|
||||||
|
|||||||
@@ -420,6 +420,15 @@
|
|||||||
"notification_unread": "ungelesen",
|
"notification_unread": "ungelesen",
|
||||||
"mention_btn_label": "Person erwähnen",
|
"mention_btn_label": "Person erwähnen",
|
||||||
"mention_popup_empty": "Keine Nutzer gefunden",
|
"mention_popup_empty": "Keine Nutzer gefunden",
|
||||||
|
"person_mention_open_link": "Zur Person",
|
||||||
|
"person_mention_hover_hint": "Klick öffnet Seite",
|
||||||
|
"person_mention_load_error": "Person konnte nicht geladen werden.",
|
||||||
|
"person_mention_loading": "Lade Person…",
|
||||||
|
"person_mention_popup_empty": "Keine Personen gefunden",
|
||||||
|
"person_mention_btn_label": "Person verlinken",
|
||||||
|
"person_mention_create_new": "Neue Person anlegen",
|
||||||
|
"transcription_editor_aria_label": "Transkriptionstext",
|
||||||
|
"person_born_name_prefix": "geb.",
|
||||||
"page_title_home": "Archiv",
|
"page_title_home": "Archiv",
|
||||||
"page_title_persons": "Personen",
|
"page_title_persons": "Personen",
|
||||||
"page_title_admin": "Administration",
|
"page_title_admin": "Administration",
|
||||||
@@ -493,7 +502,7 @@
|
|||||||
"doc_details_more_receivers": "+{count} weitere",
|
"doc_details_more_receivers": "+{count} weitere",
|
||||||
"transcription_mode_label": "Transkribieren",
|
"transcription_mode_label": "Transkribieren",
|
||||||
"transcription_mode_stop": "Fertig",
|
"transcription_mode_stop": "Fertig",
|
||||||
"transcription_block_placeholder": "Text hier eingeben...",
|
"transcription_block_placeholder": "Text eingeben — mit @Name eine Person aus dem Archiv verknüpfen",
|
||||||
"transcription_block_save_saving": "Speichere...",
|
"transcription_block_save_saving": "Speichere...",
|
||||||
"transcription_block_save_saved": "Gespeichert",
|
"transcription_block_save_saved": "Gespeichert",
|
||||||
"transcription_block_save_error": "Nicht gespeichert",
|
"transcription_block_save_error": "Nicht gespeichert",
|
||||||
@@ -907,5 +916,80 @@
|
|||||||
"bulk_edit_loading": "Dokumente werden geladen…",
|
"bulk_edit_loading": "Dokumente werden geladen…",
|
||||||
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
|
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
|
||||||
"bulk_edit_topbar_title": "Massenbearbeitung",
|
"bulk_edit_topbar_title": "Massenbearbeitung",
|
||||||
"bulk_edit_count_pill": "{count} werden bearbeitet"
|
"bulk_edit_count_pill": "{count} werden bearbeitet",
|
||||||
|
|
||||||
|
"nav_stammbaum": "Stammbaum",
|
||||||
|
|
||||||
|
"error_relationship_not_found": "Die Beziehung wurde nicht gefunden.",
|
||||||
|
"error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.",
|
||||||
|
"error_duplicate_relationship": "Diese Beziehung gibt es bereits.",
|
||||||
|
|
||||||
|
"relation_parent_of": "Elternteil von",
|
||||||
|
"relation_child_of": "Kind von",
|
||||||
|
"relation_spouse_of": "Ehegatte",
|
||||||
|
"relation_sibling_of": "Geschwister",
|
||||||
|
"relation_friend": "Freund",
|
||||||
|
"relation_colleague": "Kollege",
|
||||||
|
"relation_employer": "Arbeitgeber",
|
||||||
|
"relation_doctor": "Arzt",
|
||||||
|
"relation_neighbor": "Nachbar",
|
||||||
|
"relation_other": "Sonstige",
|
||||||
|
|
||||||
|
"relation_inferred_parent": "Elternteil",
|
||||||
|
"relation_inferred_child": "Kind",
|
||||||
|
"relation_inferred_spouse": "Ehegatte",
|
||||||
|
"relation_inferred_sibling": "Geschwister",
|
||||||
|
"relation_inferred_grandparent": "Großelternteil",
|
||||||
|
"relation_inferred_grandchild": "Enkelkind",
|
||||||
|
"relation_inferred_great_grandparent": "Urgroßelternteil",
|
||||||
|
"relation_inferred_great_grandchild": "Urenkel",
|
||||||
|
"relation_inferred_uncle_aunt": "Onkel/Tante",
|
||||||
|
"relation_inferred_niece_nephew": "Nichte/Neffe",
|
||||||
|
"relation_inferred_great_uncle_aunt": "Großonkel/Großtante",
|
||||||
|
"relation_inferred_great_niece_nephew": "Großnichte/Großneffe",
|
||||||
|
"relation_inferred_inlaw_parent": "Schwiegerelternteil",
|
||||||
|
"relation_inferred_inlaw_child": "Schwiegerkind",
|
||||||
|
"relation_inferred_sibling_inlaw": "Schwager/Schwägerin",
|
||||||
|
"relation_inferred_cousin_1": "Cousin/Cousine",
|
||||||
|
"relation_inferred_distant": "Weitläufige Verwandtschaft",
|
||||||
|
|
||||||
|
"doc_details_field_relationship": "Verwandtschaft",
|
||||||
|
|
||||||
|
"stammbaum_empty_heading": "Noch keine Familienmitglieder",
|
||||||
|
"stammbaum_empty_body": "Markiere Personen auf ihrer Bearbeitungsseite als Familienmitglied, damit sie hier erscheinen.",
|
||||||
|
"stammbaum_empty_link": "→ Zur Personenliste",
|
||||||
|
"stammbaum_panel_direct_rels": "Direkte Beziehungen",
|
||||||
|
"stammbaum_panel_derived_rels": "Abgeleitete Beziehungen",
|
||||||
|
"stammbaum_panel_to_person": "Zur Personenseite →",
|
||||||
|
"stammbaum_panel_add_rel": "+ Beziehung hinzufügen",
|
||||||
|
"stammbaum_relationships_heading": "Stammbaum & Beziehungen",
|
||||||
|
"stammbaum_zoom_in": "Vergrößern",
|
||||||
|
"stammbaum_zoom_out": "Verkleinern",
|
||||||
|
"stammbaum_generations": "Generationen",
|
||||||
|
|
||||||
|
"relation_error_duplicate": "Diese Beziehung gibt es bereits.",
|
||||||
|
"relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.",
|
||||||
|
"relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.",
|
||||||
|
"relation_year_from": "ab {year}",
|
||||||
|
"relation_year_to": "bis {year}",
|
||||||
|
"relation_year_error_bis_before_von": "Bis-Jahr darf nicht vor Von-Jahr liegen.",
|
||||||
|
"relation_label_family_member": "Als Familienmitglied",
|
||||||
|
"relation_toggle_add_to_tree": "Zum Stammbaum hinzufügen",
|
||||||
|
"relation_toggle_remove_from_tree": "Aus Stammbaum entfernen",
|
||||||
|
"relation_label_in_tree": "Erscheint im Stammbaum",
|
||||||
|
"relation_label_view_in_tree": "Ansehen →",
|
||||||
|
"relation_label_direct": "Direkte Beziehungen",
|
||||||
|
"relation_label_derived": "Abgeleitete Beziehungen",
|
||||||
|
"relation_btn_add": "Hinzufügen",
|
||||||
|
"relation_btn_save": "Speichern",
|
||||||
|
"relation_btn_cancel": "Abbrechen",
|
||||||
|
"relation_form_group_family": "Familie",
|
||||||
|
"relation_form_group_social": "Sozial",
|
||||||
|
"relation_form_field_type": "Typ",
|
||||||
|
"relation_form_field_from_year": "Von Jahr",
|
||||||
|
"relation_form_field_to_year": "Bis Jahr",
|
||||||
|
"relation_form_year_placeholder": "z.B. 1920",
|
||||||
|
|
||||||
|
"person_relationships_heading": "Beziehungen",
|
||||||
|
"person_relationships_empty": "Noch keine Beziehungen bekannt."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -420,6 +420,15 @@
|
|||||||
"notification_unread": "unread",
|
"notification_unread": "unread",
|
||||||
"mention_btn_label": "Mention person",
|
"mention_btn_label": "Mention person",
|
||||||
"mention_popup_empty": "No users found",
|
"mention_popup_empty": "No users found",
|
||||||
|
"person_mention_open_link": "Open person",
|
||||||
|
"person_mention_hover_hint": "Click opens the page",
|
||||||
|
"person_mention_load_error": "Could not load person.",
|
||||||
|
"person_mention_loading": "Loading person…",
|
||||||
|
"person_mention_popup_empty": "No persons found",
|
||||||
|
"person_mention_btn_label": "Link person",
|
||||||
|
"person_mention_create_new": "Create new person",
|
||||||
|
"transcription_editor_aria_label": "Transcription text",
|
||||||
|
"person_born_name_prefix": "née",
|
||||||
"page_title_home": "Archive",
|
"page_title_home": "Archive",
|
||||||
"page_title_persons": "Persons",
|
"page_title_persons": "Persons",
|
||||||
"page_title_admin": "Administration",
|
"page_title_admin": "Administration",
|
||||||
@@ -493,7 +502,7 @@
|
|||||||
"doc_details_more_receivers": "+{count} more",
|
"doc_details_more_receivers": "+{count} more",
|
||||||
"transcription_mode_label": "Transcribe",
|
"transcription_mode_label": "Transcribe",
|
||||||
"transcription_mode_stop": "Done",
|
"transcription_mode_stop": "Done",
|
||||||
"transcription_block_placeholder": "Type text here...",
|
"transcription_block_placeholder": "Type text — use @name to link a person from the archive",
|
||||||
"transcription_block_save_saving": "Saving...",
|
"transcription_block_save_saving": "Saving...",
|
||||||
"transcription_block_save_saved": "Saved",
|
"transcription_block_save_saved": "Saved",
|
||||||
"transcription_block_save_error": "Not saved",
|
"transcription_block_save_error": "Not saved",
|
||||||
@@ -907,5 +916,80 @@
|
|||||||
"bulk_edit_loading": "Loading documents…",
|
"bulk_edit_loading": "Loading documents…",
|
||||||
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
|
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
|
||||||
"bulk_edit_topbar_title": "Bulk edit",
|
"bulk_edit_topbar_title": "Bulk edit",
|
||||||
"bulk_edit_count_pill": "{count} will be edited"
|
"bulk_edit_count_pill": "{count} will be edited",
|
||||||
|
|
||||||
|
"nav_stammbaum": "Family tree",
|
||||||
|
|
||||||
|
"error_relationship_not_found": "Relationship not found.",
|
||||||
|
"error_circular_relationship": "This relationship would form a cycle.",
|
||||||
|
"error_duplicate_relationship": "This relationship already exists.",
|
||||||
|
|
||||||
|
"relation_parent_of": "Parent of",
|
||||||
|
"relation_child_of": "Child of",
|
||||||
|
"relation_spouse_of": "Spouse",
|
||||||
|
"relation_sibling_of": "Sibling",
|
||||||
|
"relation_friend": "Friend",
|
||||||
|
"relation_colleague": "Colleague",
|
||||||
|
"relation_employer": "Employer",
|
||||||
|
"relation_doctor": "Doctor",
|
||||||
|
"relation_neighbor": "Neighbour",
|
||||||
|
"relation_other": "Other",
|
||||||
|
|
||||||
|
"relation_inferred_parent": "Parent",
|
||||||
|
"relation_inferred_child": "Child",
|
||||||
|
"relation_inferred_spouse": "Spouse",
|
||||||
|
"relation_inferred_sibling": "Sibling",
|
||||||
|
"relation_inferred_grandparent": "Grandparent",
|
||||||
|
"relation_inferred_grandchild": "Grandchild",
|
||||||
|
"relation_inferred_great_grandparent": "Great-grandparent",
|
||||||
|
"relation_inferred_great_grandchild": "Great-grandchild",
|
||||||
|
"relation_inferred_uncle_aunt": "Uncle/Aunt",
|
||||||
|
"relation_inferred_niece_nephew": "Niece/Nephew",
|
||||||
|
"relation_inferred_great_uncle_aunt": "Great-uncle/Aunt",
|
||||||
|
"relation_inferred_great_niece_nephew": "Great-niece/Nephew",
|
||||||
|
"relation_inferred_inlaw_parent": "Parent-in-law",
|
||||||
|
"relation_inferred_inlaw_child": "Child-in-law",
|
||||||
|
"relation_inferred_sibling_inlaw": "Sibling-in-law",
|
||||||
|
"relation_inferred_cousin_1": "Cousin",
|
||||||
|
"relation_inferred_distant": "Distant relative",
|
||||||
|
|
||||||
|
"doc_details_field_relationship": "Relationship",
|
||||||
|
|
||||||
|
"stammbaum_empty_heading": "No family members yet",
|
||||||
|
"stammbaum_empty_body": "Mark a person as a family member on their edit page so they appear here.",
|
||||||
|
"stammbaum_empty_link": "→ Go to person list",
|
||||||
|
"stammbaum_panel_direct_rels": "Direct relationships",
|
||||||
|
"stammbaum_panel_derived_rels": "Derived relationships",
|
||||||
|
"stammbaum_panel_to_person": "Go to person page →",
|
||||||
|
"stammbaum_panel_add_rel": "+ Add relationship",
|
||||||
|
"stammbaum_relationships_heading": "Family tree & relationships",
|
||||||
|
"stammbaum_zoom_in": "Zoom in",
|
||||||
|
"stammbaum_zoom_out": "Zoom out",
|
||||||
|
"stammbaum_generations": "Generations",
|
||||||
|
|
||||||
|
"relation_error_duplicate": "This relationship already exists.",
|
||||||
|
"relation_error_circular": "This relationship would form a cycle.",
|
||||||
|
"relation_error_self": "A person cannot be related to themselves.",
|
||||||
|
"relation_year_from": "from {year}",
|
||||||
|
"relation_year_to": "until {year}",
|
||||||
|
"relation_year_error_bis_before_von": "End year must not precede start year.",
|
||||||
|
"relation_label_family_member": "Family member",
|
||||||
|
"relation_toggle_add_to_tree": "Add to family tree",
|
||||||
|
"relation_toggle_remove_from_tree": "Remove from family tree",
|
||||||
|
"relation_label_in_tree": "Appears in the family tree",
|
||||||
|
"relation_label_view_in_tree": "View →",
|
||||||
|
"relation_label_direct": "Direct relationships",
|
||||||
|
"relation_label_derived": "Derived relationships",
|
||||||
|
"relation_btn_add": "Add",
|
||||||
|
"relation_btn_save": "Save",
|
||||||
|
"relation_btn_cancel": "Cancel",
|
||||||
|
"relation_form_group_family": "Family",
|
||||||
|
"relation_form_group_social": "Social",
|
||||||
|
"relation_form_field_type": "Type",
|
||||||
|
"relation_form_field_from_year": "From year",
|
||||||
|
"relation_form_field_to_year": "To year",
|
||||||
|
"relation_form_year_placeholder": "e.g. 1920",
|
||||||
|
|
||||||
|
"person_relationships_heading": "Relationships",
|
||||||
|
"person_relationships_empty": "No relationships known yet."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -420,6 +420,15 @@
|
|||||||
"notification_unread": "no leído",
|
"notification_unread": "no leído",
|
||||||
"mention_btn_label": "Mencionar persona",
|
"mention_btn_label": "Mencionar persona",
|
||||||
"mention_popup_empty": "No se encontraron usuarios",
|
"mention_popup_empty": "No se encontraron usuarios",
|
||||||
|
"person_mention_open_link": "Ir a la persona",
|
||||||
|
"person_mention_hover_hint": "Clic abre la página",
|
||||||
|
"person_mention_load_error": "No se pudo cargar la persona.",
|
||||||
|
"person_mention_loading": "Cargando persona…",
|
||||||
|
"person_mention_popup_empty": "No se encontraron personas",
|
||||||
|
"person_mention_btn_label": "Vincular persona",
|
||||||
|
"person_mention_create_new": "Crear nueva persona",
|
||||||
|
"transcription_editor_aria_label": "Texto de transcripción",
|
||||||
|
"person_born_name_prefix": "n.",
|
||||||
"page_title_home": "Archivo",
|
"page_title_home": "Archivo",
|
||||||
"page_title_persons": "Personas",
|
"page_title_persons": "Personas",
|
||||||
"page_title_admin": "Administración",
|
"page_title_admin": "Administración",
|
||||||
@@ -493,7 +502,7 @@
|
|||||||
"doc_details_more_receivers": "+{count} más",
|
"doc_details_more_receivers": "+{count} más",
|
||||||
"transcription_mode_label": "Transcribir",
|
"transcription_mode_label": "Transcribir",
|
||||||
"transcription_mode_stop": "Listo",
|
"transcription_mode_stop": "Listo",
|
||||||
"transcription_block_placeholder": "Escriba el texto aquí...",
|
"transcription_block_placeholder": "Escriba el texto — use @nombre para vincular a una persona del archivo",
|
||||||
"transcription_block_save_saving": "Guardando...",
|
"transcription_block_save_saving": "Guardando...",
|
||||||
"transcription_block_save_saved": "Guardado",
|
"transcription_block_save_saved": "Guardado",
|
||||||
"transcription_block_save_error": "No guardado",
|
"transcription_block_save_error": "No guardado",
|
||||||
@@ -907,5 +916,80 @@
|
|||||||
"bulk_edit_loading": "Cargando documentos…",
|
"bulk_edit_loading": "Cargando documentos…",
|
||||||
"bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
|
"bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
|
||||||
"bulk_edit_topbar_title": "Edición masiva",
|
"bulk_edit_topbar_title": "Edición masiva",
|
||||||
"bulk_edit_count_pill": "Se editarán {count}"
|
"bulk_edit_count_pill": "Se editarán {count}",
|
||||||
|
|
||||||
|
"nav_stammbaum": "Árbol genealógico",
|
||||||
|
|
||||||
|
"error_relationship_not_found": "La relación no fue encontrada.",
|
||||||
|
"error_circular_relationship": "Esta relación crearía un ciclo.",
|
||||||
|
"error_duplicate_relationship": "Esta relación ya existe.",
|
||||||
|
|
||||||
|
"relation_parent_of": "Progenitor de",
|
||||||
|
"relation_child_of": "Hijo/a de",
|
||||||
|
"relation_spouse_of": "Cónyuge",
|
||||||
|
"relation_sibling_of": "Hermano/a",
|
||||||
|
"relation_friend": "Amigo/a",
|
||||||
|
"relation_colleague": "Colega",
|
||||||
|
"relation_employer": "Empleador",
|
||||||
|
"relation_doctor": "Médico",
|
||||||
|
"relation_neighbor": "Vecino/a",
|
||||||
|
"relation_other": "Otro",
|
||||||
|
|
||||||
|
"relation_inferred_parent": "Progenitor",
|
||||||
|
"relation_inferred_child": "Hijo/a",
|
||||||
|
"relation_inferred_spouse": "Cónyuge",
|
||||||
|
"relation_inferred_sibling": "Hermano/a",
|
||||||
|
"relation_inferred_grandparent": "Abuelo/a",
|
||||||
|
"relation_inferred_grandchild": "Nieto/a",
|
||||||
|
"relation_inferred_great_grandparent": "Bisabuelo/a",
|
||||||
|
"relation_inferred_great_grandchild": "Bisnieto/a",
|
||||||
|
"relation_inferred_uncle_aunt": "Tío/Tía",
|
||||||
|
"relation_inferred_niece_nephew": "Sobrino/a",
|
||||||
|
"relation_inferred_great_uncle_aunt": "Tío/a abuelo/a",
|
||||||
|
"relation_inferred_great_niece_nephew": "Sobrino/a nieto/a",
|
||||||
|
"relation_inferred_inlaw_parent": "Suegro/a",
|
||||||
|
"relation_inferred_inlaw_child": "Yerno/Nuera",
|
||||||
|
"relation_inferred_sibling_inlaw": "Cuñado/a",
|
||||||
|
"relation_inferred_cousin_1": "Primo/a",
|
||||||
|
"relation_inferred_distant": "Pariente lejano",
|
||||||
|
|
||||||
|
"doc_details_field_relationship": "Parentesco",
|
||||||
|
|
||||||
|
"stammbaum_empty_heading": "Aún no hay miembros de la familia",
|
||||||
|
"stammbaum_empty_body": "Marca a una persona como miembro de la familia en su página de edición para que aparezca aquí.",
|
||||||
|
"stammbaum_empty_link": "→ Ir a la lista de personas",
|
||||||
|
"stammbaum_panel_direct_rels": "Relaciones directas",
|
||||||
|
"stammbaum_panel_derived_rels": "Relaciones derivadas",
|
||||||
|
"stammbaum_panel_to_person": "Ir a la persona →",
|
||||||
|
"stammbaum_panel_add_rel": "+ Añadir relación",
|
||||||
|
"stammbaum_relationships_heading": "Árbol genealógico & relaciones",
|
||||||
|
"stammbaum_zoom_in": "Acercar",
|
||||||
|
"stammbaum_zoom_out": "Alejar",
|
||||||
|
"stammbaum_generations": "Generaciones",
|
||||||
|
|
||||||
|
"relation_error_duplicate": "Esta relación ya existe.",
|
||||||
|
"relation_error_circular": "Esta relación crearía un ciclo.",
|
||||||
|
"relation_error_self": "Una persona no puede estar relacionada consigo misma.",
|
||||||
|
"relation_year_from": "desde {year}",
|
||||||
|
"relation_year_to": "hasta {year}",
|
||||||
|
"relation_year_error_bis_before_von": "El año final no puede ser anterior al año inicial.",
|
||||||
|
"relation_label_family_member": "Miembro de la familia",
|
||||||
|
"relation_toggle_add_to_tree": "Añadir al árbol genealógico",
|
||||||
|
"relation_toggle_remove_from_tree": "Quitar del árbol genealógico",
|
||||||
|
"relation_label_in_tree": "Aparece en el árbol genealógico",
|
||||||
|
"relation_label_view_in_tree": "Ver →",
|
||||||
|
"relation_label_direct": "Relaciones directas",
|
||||||
|
"relation_label_derived": "Relaciones derivadas",
|
||||||
|
"relation_btn_add": "Añadir",
|
||||||
|
"relation_btn_save": "Guardar",
|
||||||
|
"relation_btn_cancel": "Cancelar",
|
||||||
|
"relation_form_group_family": "Familia",
|
||||||
|
"relation_form_group_social": "Social",
|
||||||
|
"relation_form_field_type": "Tipo",
|
||||||
|
"relation_form_field_from_year": "Desde año",
|
||||||
|
"relation_form_field_to_year": "Hasta año",
|
||||||
|
"relation_form_year_placeholder": "ej. 1920",
|
||||||
|
|
||||||
|
"person_relationships_heading": "Relaciones",
|
||||||
|
"person_relationships_empty": "Aún no se conocen relaciones."
|
||||||
}
|
}
|
||||||
|
|||||||
553
frontend/package-lock.json
generated
553
frontend/package-lock.json
generated
@@ -8,6 +8,9 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/extension-mention": "3.22.5",
|
||||||
|
"@tiptap/starter-kit": "3.22.5",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"openapi-fetch": "^0.13.5",
|
"openapi-fetch": "^0.13.5",
|
||||||
"pdfjs-dist": "^5.5.207"
|
"pdfjs-dist": "^5.5.207"
|
||||||
@@ -2188,6 +2191,403 @@
|
|||||||
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0"
|
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/core": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-blockquote": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bold": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bullet-list": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code-block": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-document": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-dropcursor": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-gapcursor": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-hard-break": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-heading": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-italic": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-link": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"linkifyjs": "^4.3.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-item": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-keymap": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-mention": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-rGTbTjyxLc5C/6QjfbQF53nMbxjVgJU1VK6Si1i1J2c5DU09COgEFlYvi4YHjb3xz39SprPfG+GTtgD96eg7Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5",
|
||||||
|
"@tiptap/suggestion": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-ordered-list": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-paragraph": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-strike": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-text": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-underline": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extensions": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/pm": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-changeset": "^2.3.0",
|
||||||
|
"prosemirror-commands": "^1.6.2",
|
||||||
|
"prosemirror-dropcursor": "^1.8.1",
|
||||||
|
"prosemirror-gapcursor": "^1.3.2",
|
||||||
|
"prosemirror-history": "^1.4.1",
|
||||||
|
"prosemirror-keymap": "^1.2.2",
|
||||||
|
"prosemirror-model": "^1.24.1",
|
||||||
|
"prosemirror-schema-list": "^1.5.0",
|
||||||
|
"prosemirror-state": "^1.4.3",
|
||||||
|
"prosemirror-tables": "^1.6.4",
|
||||||
|
"prosemirror-transform": "^1.10.2",
|
||||||
|
"prosemirror-view": "^1.38.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/starter-kit": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/core": "^3.22.5",
|
||||||
|
"@tiptap/extension-blockquote": "^3.22.5",
|
||||||
|
"@tiptap/extension-bold": "^3.22.5",
|
||||||
|
"@tiptap/extension-bullet-list": "^3.22.5",
|
||||||
|
"@tiptap/extension-code": "^3.22.5",
|
||||||
|
"@tiptap/extension-code-block": "^3.22.5",
|
||||||
|
"@tiptap/extension-document": "^3.22.5",
|
||||||
|
"@tiptap/extension-dropcursor": "^3.22.5",
|
||||||
|
"@tiptap/extension-gapcursor": "^3.22.5",
|
||||||
|
"@tiptap/extension-hard-break": "^3.22.5",
|
||||||
|
"@tiptap/extension-heading": "^3.22.5",
|
||||||
|
"@tiptap/extension-horizontal-rule": "^3.22.5",
|
||||||
|
"@tiptap/extension-italic": "^3.22.5",
|
||||||
|
"@tiptap/extension-link": "^3.22.5",
|
||||||
|
"@tiptap/extension-list": "^3.22.5",
|
||||||
|
"@tiptap/extension-list-item": "^3.22.5",
|
||||||
|
"@tiptap/extension-list-keymap": "^3.22.5",
|
||||||
|
"@tiptap/extension-ordered-list": "^3.22.5",
|
||||||
|
"@tiptap/extension-paragraph": "^3.22.5",
|
||||||
|
"@tiptap/extension-strike": "^3.22.5",
|
||||||
|
"@tiptap/extension-text": "^3.22.5",
|
||||||
|
"@tiptap/extension-underline": "^3.22.5",
|
||||||
|
"@tiptap/extensions": "^3.22.5",
|
||||||
|
"@tiptap/pm": "^3.22.5"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/suggestion": {
|
||||||
|
"version": "3.22.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.22.5.tgz",
|
||||||
|
"integrity": "sha512-Uv79Ht/o4mx1GWIT65jeQTE67LMrA+K7d8p51XOe9PJw0H0fS3iCdeMJ8tAo3h6QrMJFejdsB7z8jJL9UbAnhA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/pm": "3.22.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@@ -4270,6 +4670,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linkifyjs": {
|
||||||
|
"version": "4.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
|
||||||
|
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/locate-character": {
|
"node_modules/locate-character": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||||
@@ -4499,6 +4905,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/orderedmap": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/p-limit": {
|
"node_modules/p-limit": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
@@ -4934,6 +5346,135 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prosemirror-changeset": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-commands": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-dropcursor": {
|
||||||
|
"version": "1.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||||
|
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0",
|
||||||
|
"prosemirror-view": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-gapcursor": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.0.0",
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-history": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.2.2",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.31.0",
|
||||||
|
"rope-sequence": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-keymap": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"w3c-keyname": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-model": {
|
||||||
|
"version": "1.25.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||||
|
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"orderedmap": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-list": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.7.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-state": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.27.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-tables": {
|
||||||
|
"version": "1.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||||
|
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.4",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-transform": "^1.10.5",
|
||||||
|
"prosemirror-view": "^1.41.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-transform": {
|
||||||
|
"version": "1.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
|
||||||
|
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-view": {
|
||||||
|
"version": "1.41.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
|
||||||
|
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.20.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -5044,6 +5585,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rope-sequence": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/sade": {
|
"node_modules/sade": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||||
@@ -5761,6 +6308,12 @@
|
|||||||
"vitest": "^4.0.0"
|
"vitest": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/webpack-virtual-modules": {
|
"node_modules/webpack-virtual-modules": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
|
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/extension-mention": "3.22.5",
|
||||||
|
"@tiptap/starter-kit": "3.22.5",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"openapi-fetch": "^0.13.5",
|
"openapi-fetch": "^0.13.5",
|
||||||
"pdfjs-dist": "^5.5.207"
|
"pdfjs-dist": "^5.5.207"
|
||||||
|
|||||||
202
frontend/src/lib/components/AddRelationshipForm.svelte
Normal file
202
frontend/src/lib/components/AddRelationshipForm.svelte
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
||||||
|
|
||||||
|
export type RelFormData = {
|
||||||
|
relatedPersonId: string;
|
||||||
|
relationType: RelationType;
|
||||||
|
fromYear?: number;
|
||||||
|
toYear?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
personId: string;
|
||||||
|
onSubmit?: (data: RelFormData) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { personId, onSubmit }: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let addType = $state<RelationType>('PARENT_OF');
|
||||||
|
let addRelatedPersonId = $state('');
|
||||||
|
let addRelatedPersonName = $state('');
|
||||||
|
let addFromYear = $state('');
|
||||||
|
let addToYear = $state('');
|
||||||
|
let callbackError = $state<string | null>(null);
|
||||||
|
|
||||||
|
const yearError = $derived.by(() => {
|
||||||
|
const from = addFromYear.trim();
|
||||||
|
const to = addToYear.trim();
|
||||||
|
if (!from || !to) return null;
|
||||||
|
const fromInt = parseInt(from, 10);
|
||||||
|
const toInt = parseInt(to, 10);
|
||||||
|
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null;
|
||||||
|
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selfError = $derived(
|
||||||
|
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitDisabled = $derived(
|
||||||
|
yearError !== null || selfError !== null || addRelatedPersonId === ''
|
||||||
|
);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
addType = 'PARENT_OF';
|
||||||
|
addRelatedPersonId = '';
|
||||||
|
addRelatedPersonName = '';
|
||||||
|
addFromYear = '';
|
||||||
|
addToYear = '';
|
||||||
|
callbackError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
open = false;
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCallbackSubmit(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (submitDisabled || !onSubmit) return;
|
||||||
|
const data: RelFormData = { relatedPersonId: addRelatedPersonId, relationType: addType };
|
||||||
|
const from = parseInt(addFromYear.trim(), 10);
|
||||||
|
if (!Number.isNaN(from)) data.fromYear = from;
|
||||||
|
const to = parseInt(addToYear.trim(), 10);
|
||||||
|
if (!Number.isNaN(to)) data.toYear = to;
|
||||||
|
try {
|
||||||
|
await onSubmit(data);
|
||||||
|
open = false;
|
||||||
|
reset();
|
||||||
|
} catch {
|
||||||
|
callbackError = m.error_internal_error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet formFields()}
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<label class="block">
|
||||||
|
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_type()}</span>
|
||||||
|
<select
|
||||||
|
name="relationType"
|
||||||
|
bind:value={addType}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<optgroup label={m.relation_form_group_family()}>
|
||||||
|
<option value="PARENT_OF">{m.relation_parent_of()}</option>
|
||||||
|
<option value="SPOUSE_OF">{m.relation_spouse_of()}</option>
|
||||||
|
<option value="SIBLING_OF">{m.relation_sibling_of()}</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label={m.relation_form_group_social()}>
|
||||||
|
<option value="FRIEND">{m.relation_friend()}</option>
|
||||||
|
<option value="COLLEAGUE">{m.relation_colleague()}</option>
|
||||||
|
<option value="EMPLOYER">{m.relation_employer()}</option>
|
||||||
|
<option value="DOCTOR">{m.relation_doctor()}</option>
|
||||||
|
<option value="NEIGHBOR">{m.relation_neighbor()}</option>
|
||||||
|
<option value="OTHER">{m.relation_other()}</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<PersonTypeahead
|
||||||
|
name="relatedPersonId"
|
||||||
|
label="Person"
|
||||||
|
bind:value={addRelatedPersonId}
|
||||||
|
initialName={addRelatedPersonName}
|
||||||
|
excludePersonId={personId}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label class="block">
|
||||||
|
<span class="font-sans text-xs font-medium text-ink-2"
|
||||||
|
>{m.relation_form_field_from_year()}</span
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="fromYear"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
bind:value={addFromYear}
|
||||||
|
placeholder={m.relation_form_year_placeholder()}
|
||||||
|
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_to_year()}</span
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="toYear"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
bind:value={addToYear}
|
||||||
|
aria-describedby={yearError ? 'add-rel-year-error' : undefined}
|
||||||
|
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
{#if yearError}
|
||||||
|
<p id="add-rel-year-error" class="mt-1 text-xs text-red-700" role="alert">
|
||||||
|
{yearError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{#if selfError}
|
||||||
|
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if callbackError}
|
||||||
|
<p class="mt-2 text-xs text-red-700" role="alert">{callbackError}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-3 flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={cancel}
|
||||||
|
class="rounded-sm border border-line bg-surface px-3 py-1.5 font-sans text-xs font-medium text-ink-2 transition hover:bg-muted"
|
||||||
|
>
|
||||||
|
{m.relation_btn_cancel()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitDisabled}
|
||||||
|
class="rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{m.relation_btn_add()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if !open}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (open = true)}
|
||||||
|
class="mt-2 inline-flex items-center gap-1 font-sans text-xs font-medium text-ink-2 hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.stammbaum_panel_add_rel()}
|
||||||
|
</button>
|
||||||
|
{:else if onSubmit}
|
||||||
|
<form onsubmit={handleCallbackSubmit} class="mt-3 rounded-sm border border-line bg-muted/40 p-3">
|
||||||
|
{@render formFields()}
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addRelationship"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
await update();
|
||||||
|
if (result.type === 'success') {
|
||||||
|
open = false;
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mt-3 rounded-sm border border-line bg-muted/40 p-3"
|
||||||
|
>
|
||||||
|
{@render formFields()}
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import AddRelationshipForm from './AddRelationshipForm.svelte';
|
||||||
|
|
||||||
|
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||||
|
vi.mock('$lib/components/PersonTypeahead.svelte', () => ({ default: () => null }));
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('AddRelationshipForm', () => {
|
||||||
|
it('shows add-relationship button initially and no form', async () => {
|
||||||
|
render(AddRelationshipForm, { personId: 'person-1' });
|
||||||
|
await expect.element(page.getByRole('button')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows relationType select when add button is clicked', async () => {
|
||||||
|
render(AddRelationshipForm, { personId: 'person-1' });
|
||||||
|
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
|
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides form and shows button when cancel is clicked', async () => {
|
||||||
|
render(AddRelationshipForm, { personId: 'person-1' });
|
||||||
|
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
|
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||||
|
(b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '')
|
||||||
|
);
|
||||||
|
cancelBtn!.click();
|
||||||
|
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submit is disabled when no person is selected', async () => {
|
||||||
|
render(AddRelationshipForm, { personId: 'person-1' });
|
||||||
|
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
|
await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('form has no server action when onSubmit prop is provided', async () => {
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(AddRelationshipForm, { personId: 'person-1', onSubmit });
|
||||||
|
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
|
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
const form = document.querySelector('form');
|
||||||
|
expect(form?.hasAttribute('action')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows year-range error when toYear is before fromYear', async () => {
|
||||||
|
render(AddRelationshipForm, { personId: 'person-1' });
|
||||||
|
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
|
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const fromInput = document.querySelector<HTMLInputElement>('input[name="fromYear"]')!;
|
||||||
|
fromInput.value = '1935';
|
||||||
|
fromInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
const toInput = document.querySelector<HTMLInputElement>('input[name="toYear"]')!;
|
||||||
|
toInput.value = '1920';
|
||||||
|
toInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('alert')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
||||||
import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
|
import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
|
||||||
|
import RelationshipPill from '$lib/components/RelationshipPill.svelte';
|
||||||
|
|
||||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||||
type Tag = { id: string; name: string };
|
type Tag = { id: string; name: string };
|
||||||
@@ -14,9 +15,18 @@ type Props = {
|
|||||||
sender: Person | null;
|
sender: Person | null;
|
||||||
receivers: Person[];
|
receivers: Person[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
|
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { documentDate, location, status, sender, receivers, tags }: Props = $props();
|
let {
|
||||||
|
documentDate,
|
||||||
|
location,
|
||||||
|
status,
|
||||||
|
sender,
|
||||||
|
receivers,
|
||||||
|
tags,
|
||||||
|
inferredRelationship = null
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const VISIBLE_RECEIVER_LIMIT = 5;
|
const VISIBLE_RECEIVER_LIMIT = 5;
|
||||||
|
|
||||||
@@ -37,7 +47,7 @@ function getFullName(person: Person): string {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet personCard(person: Person)}
|
{#snippet personCard(person: Person, relationLabel: string | null = null)}
|
||||||
<a
|
<a
|
||||||
href="/persons/{person.id}"
|
href="/persons/{person.id}"
|
||||||
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
|
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
|
||||||
@@ -49,7 +59,10 @@ function getFullName(person: Person): string {
|
|||||||
>
|
>
|
||||||
{getInitials(person.displayName)}
|
{getInitials(person.displayName)}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
|
<span class="min-w-0 truncate font-serif text-sm text-ink">{getFullName(person)}</span>
|
||||||
|
{#if relationLabel}
|
||||||
|
<RelationshipPill label={relationLabel} />
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
@@ -88,7 +101,7 @@ function getFullName(person: Person): string {
|
|||||||
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
|
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
|
||||||
{m.doc_details_field_sender()}
|
{m.doc_details_field_sender()}
|
||||||
</p>
|
</p>
|
||||||
{@render personCard(sender)}
|
{@render personCard(sender, inferredRelationship?.labelFromA ?? null)}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if receivers.length > 0}
|
{#if receivers.length > 0}
|
||||||
@@ -97,8 +110,16 @@ function getFullName(person: Person): string {
|
|||||||
{m.doc_details_field_receivers()}
|
{m.doc_details_field_receivers()}
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
{#each displayedReceivers as receiver (receiver.id)}
|
{#each displayedReceivers as receiver, i (receiver.id)}
|
||||||
{@render personCard(receiver)}
|
{@render personCard(
|
||||||
|
receiver,
|
||||||
|
// Badge only shown when there is exactly one receiver — with multiple
|
||||||
|
// receivers the inferred label is computed from the sender's viewpoint
|
||||||
|
// and cannot be attributed to a specific receiver.
|
||||||
|
i === 0 && receivers.length === 1
|
||||||
|
? (inferredRelationship?.labelFromB ?? null)
|
||||||
|
: null
|
||||||
|
)}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if hiddenReceiverCount > 0 && !showAllReceivers}
|
{#if hiddenReceiverCount > 0 && !showAllReceivers}
|
||||||
|
|||||||
@@ -81,6 +81,25 @@ describe('DocumentMetadataDrawer — persons column', () => {
|
|||||||
renderDrawer({ sender: null, receivers: [] });
|
renderDrawer({ sender: null, receivers: [] });
|
||||||
await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument();
|
await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders inferred relationship pills inline next to sender and receiver', async () => {
|
||||||
|
renderDrawer({
|
||||||
|
receivers: [receivers[0]],
|
||||||
|
inferredRelationship: { labelFromA: 'Elternteil', labelFromB: 'Kind' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sender link contains its pill, receiver link contains its pill.
|
||||||
|
const senderLink = page.getByRole('link', { name: /Karl Müller.*Elternteil/i });
|
||||||
|
await expect.element(senderLink).toBeInTheDocument();
|
||||||
|
const receiverLink = page.getByRole('link', { name: /Anna Schmidt.*Kind/i });
|
||||||
|
await expect.element(receiverLink).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the pills when no inferred relationship is provided', async () => {
|
||||||
|
renderDrawer();
|
||||||
|
const elternteil = page.getByText('Elternteil');
|
||||||
|
expect(await elternteil.elements()).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Tags column ─────────────────────────────────────────────────────────────
|
// ─── Tags column ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -30,9 +30,16 @@ type Props = {
|
|||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
transcribeMode: boolean;
|
transcribeMode: boolean;
|
||||||
|
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { doc, canWrite, fileUrl, transcribeMode = $bindable() }: Props = $props();
|
let {
|
||||||
|
doc,
|
||||||
|
canWrite,
|
||||||
|
fileUrl,
|
||||||
|
transcribeMode = $bindable(),
|
||||||
|
inferredRelationship = null
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let detailsOpen = $state(false);
|
let detailsOpen = $state(false);
|
||||||
|
|
||||||
@@ -275,6 +282,7 @@ let mobileMenuOpen = $state(false);
|
|||||||
sender={doc.sender ?? null}
|
sender={doc.sender ?? null}
|
||||||
receivers={doc.receivers ? [...doc.receivers] : []}
|
receivers={doc.receivers ? [...doc.receivers] : []}
|
||||||
tags={doc.tags ? [...doc.tags] : []}
|
tags={doc.tags ? [...doc.tags] : []}
|
||||||
|
inferredRelationship={inferredRelationship}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
170
frontend/src/lib/components/MentionDropdown.svelte
Normal file
170
frontend/src/lib/components/MentionDropdown.svelte
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
|
// The dropdown receives a single reactive state object. PersonMentionEditor
|
||||||
|
// mutates fields on this object (model.items = ..., etc.) and Svelte's $state
|
||||||
|
// proxy reactivity propagates the change here. This is the supported way to
|
||||||
|
// update an imperatively-mounted Svelte 5 component — `mount` does not return
|
||||||
|
// settable prop accessors.
|
||||||
|
type DropdownState = {
|
||||||
|
items: Person[];
|
||||||
|
command: (item: Person) => void;
|
||||||
|
clientRect: (() => DOMRect | null) | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { model }: { model: DropdownState } = $props();
|
||||||
|
|
||||||
|
// highlightedIndex must be both writable (keyboard handler mutates it) and
|
||||||
|
// reset when `items` changes (so it never points past the end of a new list).
|
||||||
|
// A pure $derived is read-only and cannot serve both needs, so $state + $effect
|
||||||
|
// is the correct pattern here. The autofixer suggestion to use $derived does not
|
||||||
|
// apply: the mutation in onKeyDown is not a derivation.
|
||||||
|
let highlightedIndex = $state(0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Read model.items to subscribe; reset index whenever the list is replaced.
|
||||||
|
void model.items;
|
||||||
|
highlightedIndex = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Positioning — flip strategy: open upward when there is not enough room
|
||||||
|
// below the cursor to show the dropdown without clipping the viewport.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Position = {
|
||||||
|
top: string | null;
|
||||||
|
bottom: string | null;
|
||||||
|
left: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DROPDOWN_CLEARANCE_PX = 200;
|
||||||
|
|
||||||
|
const position = $derived.by<Position>(() => {
|
||||||
|
const cr = model.clientRect;
|
||||||
|
if (!cr) return { top: '0px', bottom: null, left: '0px' };
|
||||||
|
const rect = cr();
|
||||||
|
if (!rect) return { top: '0px', bottom: null, left: '0px' };
|
||||||
|
|
||||||
|
// Some editors report a caret DOMRect with zero width; fall back to rect.x.
|
||||||
|
const left = `${rect.width === 0 ? rect.x : rect.left}px`;
|
||||||
|
|
||||||
|
if (window.innerHeight - rect.bottom < DROPDOWN_CLEARANCE_PX) {
|
||||||
|
// Not enough space below — anchor bottom of dropdown to top of caret.
|
||||||
|
return {
|
||||||
|
top: null,
|
||||||
|
bottom: `${window.innerHeight - rect.top}px`,
|
||||||
|
left
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { top: `${rect.bottom}px`, bottom: null, left };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Keyboard handler — exported so Tiptap's render() can forward events.
|
||||||
|
// Returns true when the event is consumed (prevents the editor's default).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function onKeyDown(event: KeyboardEvent): boolean {
|
||||||
|
const len = model.items.length;
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
highlightedIndex = (highlightedIndex + 1) % Math.max(len, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
highlightedIndex = (highlightedIndex - 1 + Math.max(len, 1)) % Math.max(len, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
const selected = model.items[highlightedIndex];
|
||||||
|
if (selected) {
|
||||||
|
model.command(selected);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape: let the suggestion plugin handle it (return false = not consumed).
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(item: Person) {
|
||||||
|
model.command(item);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Mounted imperatively to document.body by the Tiptap suggestion plugin.
|
||||||
|
Positioned absolutely relative to the viewport using inline styles derived
|
||||||
|
from the Tiptap clientRect() callback.
|
||||||
|
|
||||||
|
SECURITY: This component receives pre-filtered Person[] items from the
|
||||||
|
parent — it does NOT fetch. The parent's fetch relies on the SvelteKit Vite
|
||||||
|
proxy injecting the auth_token cookie as the Authorization header.
|
||||||
|
Mounted in transcribe mode behind WRITE_ALL — never reachable to
|
||||||
|
unauthenticated users.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
class="fixed z-50 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
||||||
|
role="listbox"
|
||||||
|
aria-label={m.person_mention_btn_label()}
|
||||||
|
style:top={position.top}
|
||||||
|
style:bottom={position.bottom}
|
||||||
|
style:left={position.left}
|
||||||
|
>
|
||||||
|
{#if model.items.length === 0}
|
||||||
|
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
|
||||||
|
{m.person_mention_popup_empty()}
|
||||||
|
</p>
|
||||||
|
<!--
|
||||||
|
Empty-state escape hatch — without it the transcriber has to close
|
||||||
|
the dropdown, navigate to /persons/new, come back, and re-type the
|
||||||
|
query. target=_blank keeps the document and editor state intact;
|
||||||
|
rel=noopener prevents reverse-tabnabbing on the new tab. Leonie #5621.
|
||||||
|
-->
|
||||||
|
<a
|
||||||
|
href="/persons/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none"
|
||||||
|
onmousedown={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{m.person_mention_create_new()}
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
{#each model.items as person, i (person.id)}
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
|
||||||
|
// brand-mint ring (≈2.5:1 on white) fails WCAG 1.4.11 Non-Text
|
||||||
|
// Contrast for a meaningful keyboard-highlight indicator. brand-navy
|
||||||
|
// gives ~14.5:1 against the bg-brand-mint/20 row. Leonie #5621.
|
||||||
|
i === highlightedIndex && 'bg-brand-mint/20 ring-2 ring-brand-navy ring-inset'
|
||||||
|
]}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === highlightedIndex}
|
||||||
|
data-test-person-id={person.id}
|
||||||
|
tabindex="-1"
|
||||||
|
onmousedown={(e) => {
|
||||||
|
// Prevent blur on the editor before the selection fires.
|
||||||
|
e.preventDefault();
|
||||||
|
selectItem(person);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="truncate font-serif text-base text-ink">{person.displayName}</span>
|
||||||
|
{#if formatLifeDateRange(person.birthYear, person.deathYear)}
|
||||||
|
<span class="truncate font-sans text-xs text-ink-3">
|
||||||
|
{formatLifeDateRange(person.birthYear, person.deathYear)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
303
frontend/src/lib/components/PersonHoverCard.svelte
Normal file
303
frontend/src/lib/components/PersonHoverCard.svelte
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||||
|
import { chipLabel, otherName } from '$lib/relationshipLabels';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import type { LoadState } from '$lib/types/personHoverCard';
|
||||||
|
|
||||||
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
personId: string;
|
||||||
|
cardId: string;
|
||||||
|
position: { top: number; left: number };
|
||||||
|
state: LoadState;
|
||||||
|
onmouseenter?: () => void;
|
||||||
|
onmouseleave?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { personId, cardId, position, state, onmouseenter, onmouseleave }: Props = $props();
|
||||||
|
|
||||||
|
const FAMILY_REL_TYPES: ReadonlySet<RelationshipDTO['relationType']> = new Set([
|
||||||
|
'PARENT_OF',
|
||||||
|
'SPOUSE_OF',
|
||||||
|
'SIBLING_OF'
|
||||||
|
]);
|
||||||
|
const NOTES_MAX = 120;
|
||||||
|
|
||||||
|
const familyChips = $derived(
|
||||||
|
state.status === 'loaded'
|
||||||
|
? state.relationships.filter((r) => FAMILY_REL_TYPES.has(r.relationType))
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateRange = $derived(
|
||||||
|
state.status === 'loaded'
|
||||||
|
? formatLifeDateRange(state.person.birthYear, state.person.deathYear)
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cut the notes excerpt at the last word boundary inside the NOTES_MAX
|
||||||
|
* window. Mid-word truncation is especially ugly in German compound nouns
|
||||||
|
* ("…Familienzu…"), so prefer the previous space if there is one within
|
||||||
|
* a reasonable distance. Fall back to a hard cut for strings with no
|
||||||
|
* spaces at all (e.g. a single 150-char word). Leonie FINDING-04 / Elicit E5.
|
||||||
|
*/
|
||||||
|
function truncateAtWordBoundary(text: string, max: number): string {
|
||||||
|
if (text.length <= max) return text;
|
||||||
|
const window = text.slice(0, max);
|
||||||
|
const lastSpace = window.lastIndexOf(' ');
|
||||||
|
// If the last space is too close to the start (< 70% of the window) we'd
|
||||||
|
// produce a near-empty excerpt — fall back to the hard cut instead.
|
||||||
|
const minBoundary = Math.floor(max * 0.7);
|
||||||
|
const cut = lastSpace >= minBoundary ? window.slice(0, lastSpace) : window;
|
||||||
|
return cut + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
const notesExcerpt = $derived.by(() => {
|
||||||
|
if (state.status !== 'loaded') return null;
|
||||||
|
const notes = state.person.notes;
|
||||||
|
if (!notes) return null;
|
||||||
|
return truncateAtWordBoundary(notes, NOTES_MAX);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accessible name for the region landmark — required by WCAG 1.3.1.
|
||||||
|
// Falls back to a localised loading label so axe-core never sees an unnamed
|
||||||
|
// region (Leonie FINDING-02 / Elicit NFR concern).
|
||||||
|
const ariaLabel = $derived(
|
||||||
|
state.status === 'loaded' ? state.person.displayName : m.person_mention_loading()
|
||||||
|
);
|
||||||
|
|
||||||
|
// aria-busy="true" while loading so SR clients know the region's contents
|
||||||
|
// will change. Cleared on loaded/error so the new content is announced.
|
||||||
|
const ariaBusy = $derived(state.status === 'loading');
|
||||||
|
|
||||||
|
const showMaidenName = $derived(
|
||||||
|
state.status === 'loaded' &&
|
||||||
|
!!state.person.alias &&
|
||||||
|
state.person.alias !== state.person.lastName &&
|
||||||
|
state.person.alias !== state.person.displayName
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="person-hover-card"
|
||||||
|
data-testid="person-hover-card"
|
||||||
|
id={cardId}
|
||||||
|
role="region"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-busy={ariaBusy ? 'true' : undefined}
|
||||||
|
style:position="fixed"
|
||||||
|
style:top={`${position.top}px`}
|
||||||
|
style:left={`${position.left}px`}
|
||||||
|
onmouseenter={onmouseenter}
|
||||||
|
onmouseleave={onmouseleave}
|
||||||
|
onfocusin={onmouseenter}
|
||||||
|
onfocusout={(e) => {
|
||||||
|
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) {
|
||||||
|
onmouseleave?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if state.status === 'loading'}
|
||||||
|
<div
|
||||||
|
data-testid="person-hover-card-skeleton"
|
||||||
|
class="skeleton"
|
||||||
|
role="status"
|
||||||
|
aria-label={m.person_mention_loading()}
|
||||||
|
>
|
||||||
|
<div class="bar"></div>
|
||||||
|
<div class="bar"></div>
|
||||||
|
<div class="bar"></div>
|
||||||
|
</div>
|
||||||
|
{:else if state.status === 'error'}
|
||||||
|
<div data-testid="person-hover-card-error" class="error-message">
|
||||||
|
{m.person_mention_load_error()}
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()} →</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div data-testid="person-hover-card-content" class="content">
|
||||||
|
<div class="header">
|
||||||
|
<div class="name" data-testid="person-hover-card-name">{state.person.displayName}</div>
|
||||||
|
{#if dateRange}
|
||||||
|
<div class="dates" data-testid="person-hover-card-dates">{dateRange}</div>
|
||||||
|
{/if}
|
||||||
|
{#if showMaidenName}
|
||||||
|
<div class="maiden" data-testid="person-hover-card-maiden">
|
||||||
|
{m.person_born_name_prefix()}
|
||||||
|
{state.person.alias}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if familyChips.length > 0}
|
||||||
|
<div class="chips" data-testid="person-hover-card-chips">
|
||||||
|
{#each familyChips as chip (chip.id)}
|
||||||
|
<span class="chip">
|
||||||
|
<span class="chip-type">{chipLabel(chip, personId)}:</span>
|
||||||
|
{otherName(chip, personId)}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if notesExcerpt}
|
||||||
|
<p class="notes" data-testid="person-hover-card-notes">{notesExcerpt}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="footer">
|
||||||
|
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()} →</a>
|
||||||
|
<span class="hint">{m.person_mention_hover_hint()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.person-hover-card {
|
||||||
|
width: 320px;
|
||||||
|
min-height: 180px;
|
||||||
|
background-color: var(--c-surface);
|
||||||
|
border: 1px solid var(--c-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--c-ink);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On touch devices the card is suppressed entirely — tap navigates directly. */
|
||||||
|
@media (hover: none) {
|
||||||
|
.person-hover-card {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton .bar {
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--c-line);
|
||||||
|
animation: pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton .bar:nth-child(1) {
|
||||||
|
width: 60%;
|
||||||
|
}
|
||||||
|
.skeleton .bar:nth-child(2) {
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
.skeleton .bar:nth-child(3) {
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.skeleton .bar {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
background-color: var(--c-ink);
|
||||||
|
color: var(--c-surface);
|
||||||
|
margin: -14px -16px 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top-left-radius: 6px;
|
||||||
|
border-top-right-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dates,
|
||||||
|
.maiden {
|
||||||
|
font-size: 12px;
|
||||||
|
color: color-mix(in srgb, var(--c-surface) 75%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
background-color: var(--c-accent-bg);
|
||||||
|
color: var(--c-ink);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-type {
|
||||||
|
font-weight: 600;
|
||||||
|
/* opacity 0.7 on --c-ink: ~5.6:1 light, ~7.1:1 dark — WCAG AA ✓ */
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-ink-2);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-ink-2);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid var(--c-line);
|
||||||
|
padding-top: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-link {
|
||||||
|
color: var(--c-ink);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--c-ink-3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
373
frontend/src/lib/components/PersonHoverCard.svelte.spec.ts
Normal file
373
frontend/src/lib/components/PersonHoverCard.svelte.spec.ts
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import PersonHoverCard from './PersonHoverCard.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type Person = components['schemas']['Person'];
|
||||||
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
|
||||||
|
const AUGUSTE: Person = {
|
||||||
|
id: 'p-aug',
|
||||||
|
firstName: 'Auguste',
|
||||||
|
lastName: 'Raddatz',
|
||||||
|
displayName: 'Auguste Raddatz',
|
||||||
|
personType: 'PERSON',
|
||||||
|
familyMember: true,
|
||||||
|
birthYear: 1882,
|
||||||
|
deathYear: 1944
|
||||||
|
} as unknown as Person;
|
||||||
|
|
||||||
|
const POSITION = { top: 100, left: 200 };
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('PersonHoverCard — loading state', () => {
|
||||||
|
it('shows the skeleton when state.status is loading', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loading' }
|
||||||
|
});
|
||||||
|
await expect.element(page.getByTestId('person-hover-card-skeleton')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders three skeleton bars', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loading' }
|
||||||
|
});
|
||||||
|
const bars = document.querySelectorAll('[data-testid="person-hover-card-skeleton"] .bar');
|
||||||
|
expect(bars.length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PersonHoverCard — error state', () => {
|
||||||
|
it('shows a generic error message when state.status is error', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'error' }
|
||||||
|
});
|
||||||
|
await expect.element(page.getByTestId('person-hover-card-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still allows the link footer to navigate (link present in error state)', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'error' }
|
||||||
|
});
|
||||||
|
// The card root must show the footer link even when the body errored —
|
||||||
|
// click navigation works regardless of fetch outcome.
|
||||||
|
const link = document.querySelector('a[href="/persons/p-aug"]');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PersonHoverCard — loaded state', () => {
|
||||||
|
it('renders the person displayName as the header name', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the life-date range when birthYear and deathYear are present', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the life-date line when both years are missing', async () => {
|
||||||
|
const noDates = { ...AUGUSTE, birthYear: undefined, deathYear: undefined } as Person;
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: noDates, relationships: [] }
|
||||||
|
});
|
||||||
|
const dates = document.querySelector('[data-testid="person-hover-card-dates"]');
|
||||||
|
expect(dates).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "geb. <alias>" when alias is set', async () => {
|
||||||
|
const withAlias = { ...AUGUSTE, alias: 'Müller' } as Person;
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: withAlias, relationships: [] }
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('geb. Müller')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the maiden name line when alias is null', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||||
|
});
|
||||||
|
const maiden = document.querySelector('[data-testid="person-hover-card-maiden"]');
|
||||||
|
expect(maiden).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders family relationship chips for PARENT_OF, SPOUSE_OF, SIBLING_OF only', async () => {
|
||||||
|
const relationships: RelationshipDTO[] = [
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
personId: 'p-aug',
|
||||||
|
relatedPersonId: 'p-spouse',
|
||||||
|
personDisplayName: 'Auguste',
|
||||||
|
relatedPersonDisplayName: 'Otto Raddatz',
|
||||||
|
relationType: 'SPOUSE_OF'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r2',
|
||||||
|
personId: 'p-aug',
|
||||||
|
relatedPersonId: 'p-friend',
|
||||||
|
personDisplayName: 'Auguste',
|
||||||
|
relatedPersonDisplayName: 'Karl Friend',
|
||||||
|
relationType: 'FRIEND'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r3',
|
||||||
|
personId: 'p-aug',
|
||||||
|
relatedPersonId: 'p-sibling',
|
||||||
|
personDisplayName: 'Auguste',
|
||||||
|
relatedPersonDisplayName: 'Marie Sister',
|
||||||
|
relationType: 'SIBLING_OF'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: AUGUSTE, relationships }
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Otto Raddatz')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText('Marie Sister')).toBeInTheDocument();
|
||||||
|
// Non-family relationship type must be filtered out
|
||||||
|
const friendChip = page.getByText('Karl Friend');
|
||||||
|
await expect.element(friendChip).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the other person name when hovered person is the object (relatedPersonId) in a PARENT_OF row', async () => {
|
||||||
|
// Storage: Heinrich PARENT_OF Auguste. When viewing Auguste's card,
|
||||||
|
// the chip must show "Heinrich" (the parent), not "Auguste" (herself).
|
||||||
|
const relationships: RelationshipDTO[] = [
|
||||||
|
{
|
||||||
|
id: 'r-parent',
|
||||||
|
personId: 'p-heinrich',
|
||||||
|
relatedPersonId: 'p-aug',
|
||||||
|
personDisplayName: 'Heinrich Raddatz',
|
||||||
|
relatedPersonDisplayName: 'Auguste Raddatz',
|
||||||
|
relationType: 'PARENT_OF'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: AUGUSTE, relationships }
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Heinrich Raddatz')).toBeInTheDocument();
|
||||||
|
// Auguste must NOT appear as her own parent chip name
|
||||||
|
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
|
||||||
|
expect(chips?.textContent).not.toContain('Auguste Raddatz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the chips section entirely when no family relationships', async () => {
|
||||||
|
const onlyFriend: RelationshipDTO[] = [
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
personId: 'p-aug',
|
||||||
|
relatedPersonId: 'p-friend',
|
||||||
|
personDisplayName: 'Auguste',
|
||||||
|
relatedPersonDisplayName: 'Karl Friend',
|
||||||
|
relationType: 'FRIEND'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: AUGUSTE, relationships: onlyFriend }
|
||||||
|
});
|
||||||
|
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
|
||||||
|
expect(chips).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders notes excerpt unchanged when notes ≤ 120 characters', async () => {
|
||||||
|
const withNotes = { ...AUGUSTE, notes: 'Born in Berlin.' } as Person;
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: withNotes, relationships: [] }
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates notes longer than 120 characters with an ellipsis (single long word)', async () => {
|
||||||
|
// Single 150-char word with no spaces: word-boundary cut would yield nothing,
|
||||||
|
// so fall back to a hard cut at 120 + ellipsis (Sara #7: pin the exact length).
|
||||||
|
const long = 'x'.repeat(150);
|
||||||
|
const withLongNotes = { ...AUGUSTE, notes: long } as Person;
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
||||||
|
});
|
||||||
|
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
||||||
|
expect(notes.textContent).toBe('x'.repeat(120) + '…');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates at the last word boundary inside the 120-char window (Leonie FINDING-04)', async () => {
|
||||||
|
// 150-char string with spaces — must cut at the last space, not mid-word.
|
||||||
|
const sentence = 'Sie war eine bekannte Schriftstellerin und engagierte sich '.repeat(3);
|
||||||
|
// length is 180, last space at idx ≤120
|
||||||
|
const withLongNotes = { ...AUGUSTE, notes: sentence } as Person;
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
||||||
|
});
|
||||||
|
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
||||||
|
const text = notes.textContent ?? '';
|
||||||
|
// Ends with ellipsis
|
||||||
|
expect(text.endsWith('…')).toBe(true);
|
||||||
|
// Last char before the ellipsis is NOT a half-word — verify by checking that
|
||||||
|
// the position right before … is the end of a word (i.e., there's no letter
|
||||||
|
// further along in the original text immediately after our cut point).
|
||||||
|
const cut = text.slice(0, -1); // strip the …
|
||||||
|
// Find this cut substring in the original sentence
|
||||||
|
const idx = sentence.indexOf(cut);
|
||||||
|
expect(idx).toBe(0);
|
||||||
|
const charAfterCut = sentence[cut.length];
|
||||||
|
// The next char should be a space — confirming we cut on a boundary
|
||||||
|
expect(charAfterCut).toBe(' ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits notes section when notes is null', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||||
|
});
|
||||||
|
const notes = document.querySelector('[data-testid="person-hover-card-notes"]');
|
||||||
|
expect(notes).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('footer renders an anchor link to /persons/{personId}', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||||
|
});
|
||||||
|
const link = document.querySelector('a[href="/persons/p-aug"]')!;
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PersonHoverCard — accessibility', () => {
|
||||||
|
it('uses aria-live="polite" so screen readers announce loaded content', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loading' }
|
||||||
|
});
|
||||||
|
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||||
|
expect(root.getAttribute('aria-live')).toBe('polite');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-busy="true" while loading so SR announces the state change on load', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loading' }
|
||||||
|
});
|
||||||
|
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||||
|
expect(root.getAttribute('aria-busy')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not set aria-busy when loaded (so the loaded content is announced)', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||||
|
});
|
||||||
|
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||||
|
// aria-busy is either absent or "false"
|
||||||
|
const busy = root.getAttribute('aria-busy');
|
||||||
|
expect(busy === null || busy === 'false').toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the region with the person displayName when loaded (WCAG 1.3.1)', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||||
|
});
|
||||||
|
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||||
|
expect(root.getAttribute('aria-label')).toBe('Auguste Raddatz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the region with a generic loading label while loading', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loading' }
|
||||||
|
});
|
||||||
|
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||||
|
// Region must have an accessible name in every state — axe-core flags
|
||||||
|
// role="region" without aria-label / aria-labelledby.
|
||||||
|
expect(root.getAttribute('aria-label')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-xyz',
|
||||||
|
position: POSITION,
|
||||||
|
state: { status: 'loading' }
|
||||||
|
});
|
||||||
|
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||||
|
expect(root.id).toBe('card-xyz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('positions itself fixed at the given top/left', async () => {
|
||||||
|
render(PersonHoverCard, {
|
||||||
|
personId: 'p-aug',
|
||||||
|
cardId: 'card-1',
|
||||||
|
position: { top: 333, left: 444 },
|
||||||
|
state: { status: 'loading' }
|
||||||
|
});
|
||||||
|
const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement;
|
||||||
|
expect(root.style.top).toBe('333px');
|
||||||
|
expect(root.style.left).toBe('444px');
|
||||||
|
expect(root.style.position).toBe('fixed');
|
||||||
|
});
|
||||||
|
});
|
||||||
295
frontend/src/lib/components/PersonMentionEditor.svelte
Normal file
295
frontend/src/lib/components/PersonMentionEditor.svelte
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy, mount, unmount } from 'svelte';
|
||||||
|
import { Editor } from '@tiptap/core';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import { Mention } from '@tiptap/extension-mention';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import type { PersonMention } from '$lib/types';
|
||||||
|
import { deserialize, serialize } from '$lib/utils/mentionSerializer';
|
||||||
|
import MentionDropdown from './MentionDropdown.svelte';
|
||||||
|
|
||||||
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
mentionedPersons: PersonMention[];
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onfocus?: () => void;
|
||||||
|
onblur?: () => void;
|
||||||
|
onSelectionChange?: (text: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
mentionedPersons = $bindable([]),
|
||||||
|
placeholder = '',
|
||||||
|
disabled = false,
|
||||||
|
onfocus,
|
||||||
|
onblur,
|
||||||
|
onSelectionChange
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let editorEl: HTMLDivElement;
|
||||||
|
let editor: Editor | null = null;
|
||||||
|
|
||||||
|
// Single reactive state object shared with MentionDropdown. Mutating these
|
||||||
|
// fields propagates to the mounted dropdown via Svelte's $state proxy —
|
||||||
|
// this is required because Svelte 5's `mount()` does NOT return prop
|
||||||
|
// accessors; setting `instance.items = ...` does not update the component.
|
||||||
|
let dropdownState = $state<{
|
||||||
|
items: Person[];
|
||||||
|
command: (item: Person) => void;
|
||||||
|
clientRect: (() => DOMRect | null) | null;
|
||||||
|
}>({
|
||||||
|
items: [],
|
||||||
|
command: () => {},
|
||||||
|
clientRect: null
|
||||||
|
});
|
||||||
|
|
||||||
|
type DropdownExports = {
|
||||||
|
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Custom Mention node: uses personId / displayName instead of the
|
||||||
|
// default id / label attribute names so the mentionSerializer can
|
||||||
|
// round-trip correctly without attribute remapping.
|
||||||
|
const CustomMention = Mention.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
personId: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (el) => el.getAttribute('data-person-id'),
|
||||||
|
renderHTML: (attrs) => ({ 'data-person-id': attrs.personId })
|
||||||
|
},
|
||||||
|
displayName: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (el) => el.getAttribute('data-display-name'),
|
||||||
|
renderHTML: (attrs) => ({ 'data-display-name': attrs.displayName })
|
||||||
|
},
|
||||||
|
mentionSuggestionChar: {
|
||||||
|
default: '@',
|
||||||
|
parseHTML: (el) => el.getAttribute('data-mention-suggestion-char'),
|
||||||
|
renderHTML: (attrs) => ({
|
||||||
|
'data-mention-suggestion-char': attrs.mentionSuggestionChar
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
editor = new Editor({
|
||||||
|
element: editorEl,
|
||||||
|
// Initial editable state honors the `disabled` prop. The reactive
|
||||||
|
// $effect below keeps it in sync if the prop flips after mount —
|
||||||
|
// without this, a keyboard user can tab into the contenteditable
|
||||||
|
// even when the wrapper has pointer-events-none (WCAG 2.1.1).
|
||||||
|
editable: !disabled,
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
heading: false,
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
strike: false,
|
||||||
|
code: false,
|
||||||
|
blockquote: false,
|
||||||
|
codeBlock: false,
|
||||||
|
bulletList: false,
|
||||||
|
orderedList: false,
|
||||||
|
hardBreak: false,
|
||||||
|
horizontalRule: false
|
||||||
|
}),
|
||||||
|
CustomMention.configure({
|
||||||
|
renderHTML({ node }) {
|
||||||
|
// Underline color matches the read-mode .person-mention rule
|
||||||
|
// (ink at ~50% alpha) — brand-mint on white fails WCAG 1.4.11
|
||||||
|
// Non-Text Contrast (≈1.7:1, needs 3:1). Leonie #5621.
|
||||||
|
return [
|
||||||
|
'span',
|
||||||
|
{
|
||||||
|
'data-type': 'mention',
|
||||||
|
'data-person-id': node.attrs.personId,
|
||||||
|
'data-display-name': node.attrs.displayName,
|
||||||
|
class:
|
||||||
|
'mention-token underline decoration-ink/50 underline-offset-2 text-brand-navy font-medium'
|
||||||
|
},
|
||||||
|
`@${node.attrs.displayName}`
|
||||||
|
];
|
||||||
|
},
|
||||||
|
renderText({ node }) {
|
||||||
|
return `@${node.attrs.displayName}`;
|
||||||
|
},
|
||||||
|
suggestion: {
|
||||||
|
char: '@',
|
||||||
|
allowSpaces: true,
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// EXCEPTION to frontend/CLAUDE.md "no client-side API fetch":
|
||||||
|
// Tiptap's suggestion plugin lives entirely on the client and
|
||||||
|
// fires on every keystroke after `@`. Routing each query through
|
||||||
|
// a SvelteKit form action would round-trip through SSR for a
|
||||||
|
// dropdown that needs to feel instantaneous, and a +server.ts
|
||||||
|
// endpoint would only proxy the same call. Auth flows through
|
||||||
|
// the Vite proxy in dev and Caddy in prod (cookie-based), so the
|
||||||
|
// network surface is identical to a server-driven call.
|
||||||
|
// Markus #5616: an ADR will formalise this. Open follow-up:
|
||||||
|
// "ADR: client-side fetch exception for editor suggestion plugins."
|
||||||
|
// Nora #5618 #3 — separate issue tracks the GET /api/persons
|
||||||
|
// response-shape audit (PersonSummaryDTO leaks `notes`).
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
items: async ({ query }: { query: string }) => {
|
||||||
|
if (!query) return [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return ((await res.json()) as Person[]).slice(0, 5);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// AC-1 fix: insert the typed query as displayName, not person.displayName.
|
||||||
|
command({ editor: ed, range, props }) {
|
||||||
|
const p = props as unknown as { personId: string; displayName: string };
|
||||||
|
const nodeAfter = ed.view.state.selection.$to.nodeAfter;
|
||||||
|
if (nodeAfter?.text?.startsWith(' ')) range.to += 1;
|
||||||
|
ed.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContentAt(range, [
|
||||||
|
{
|
||||||
|
type: 'mention',
|
||||||
|
attrs: { personId: p.personId, displayName: p.displayName }
|
||||||
|
},
|
||||||
|
{ type: 'text', text: ' ' }
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
let component: object | null = null;
|
||||||
|
let exports: DropdownExports | null = null;
|
||||||
|
|
||||||
|
// Tiptap's SuggestionProps types `command` against the default
|
||||||
|
// MentionNodeAttrs (id/label). Our custom Mention extension uses
|
||||||
|
// personId/displayName, so we cast the renderProps locally.
|
||||||
|
type LooseRenderProps = {
|
||||||
|
items: unknown;
|
||||||
|
command: (props: { personId: string; displayName: string }) => void;
|
||||||
|
query: string;
|
||||||
|
clientRect?: (() => DOMRect | null) | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateState = (renderProps: LooseRenderProps) => {
|
||||||
|
dropdownState.items = renderProps.items as Person[];
|
||||||
|
// AC-1: pass typed query as displayName, not person.displayName
|
||||||
|
dropdownState.command = (item: Person) =>
|
||||||
|
renderProps.command({
|
||||||
|
personId: item.id,
|
||||||
|
displayName: renderProps.query
|
||||||
|
});
|
||||||
|
dropdownState.clientRect = renderProps.clientRect ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart(renderProps) {
|
||||||
|
updateState(renderProps as unknown as LooseRenderProps);
|
||||||
|
const mounted = mount(MentionDropdown, {
|
||||||
|
target: document.body,
|
||||||
|
props: { model: dropdownState }
|
||||||
|
});
|
||||||
|
component = mounted as object;
|
||||||
|
exports = mounted as unknown as DropdownExports;
|
||||||
|
},
|
||||||
|
onUpdate(renderProps) {
|
||||||
|
updateState(renderProps as unknown as LooseRenderProps);
|
||||||
|
},
|
||||||
|
onKeyDown({ event }) {
|
||||||
|
// Escape is handled by the suggestion plugin itself.
|
||||||
|
if (event.key === 'Escape') return false;
|
||||||
|
return exports?.onKeyDown(event) ?? false;
|
||||||
|
},
|
||||||
|
onExit() {
|
||||||
|
if (component) {
|
||||||
|
unmount(component);
|
||||||
|
component = null;
|
||||||
|
exports = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
content: deserialize(value, mentionedPersons),
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
role: 'textbox',
|
||||||
|
'aria-multiline': 'true',
|
||||||
|
'aria-label': m.transcription_editor_aria_label(),
|
||||||
|
'data-editor-inner': '',
|
||||||
|
class: [
|
||||||
|
'min-h-[120px] px-1 py-2.5',
|
||||||
|
'font-serif text-base leading-relaxed text-ink',
|
||||||
|
'focus:outline-none',
|
||||||
|
'tiptap-editor-inner'
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUpdate({ editor: ed }) {
|
||||||
|
const { text, mentionedPersons: mp } = serialize(ed.getJSON());
|
||||||
|
value = text;
|
||||||
|
mentionedPersons = mp;
|
||||||
|
},
|
||||||
|
onFocus() {
|
||||||
|
onfocus?.();
|
||||||
|
},
|
||||||
|
onBlur() {
|
||||||
|
onblur?.();
|
||||||
|
},
|
||||||
|
onSelectionUpdate({ editor: ed }) {
|
||||||
|
const { from, to } = ed.state.selection;
|
||||||
|
onSelectionChange?.(from !== to ? ed.state.doc.textBetween(from, to) : null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
editor?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the data-placeholder attribute in sync with actual emptiness so the
|
||||||
|
// placeholder CSS only fires when there is no content (not just on blur).
|
||||||
|
$effect(() => {
|
||||||
|
if (!editor || !placeholder) return;
|
||||||
|
void value; // Tiptap's onUpdate always fires on content change, but $effect needs a
|
||||||
|
// reactive read to re-run — void value registers value as a dependency without using it.
|
||||||
|
const inner = editorEl?.querySelector('[data-editor-inner]') as HTMLElement | null;
|
||||||
|
if (!inner) return;
|
||||||
|
if (editor.isEmpty) {
|
||||||
|
inner.setAttribute('data-placeholder', placeholder);
|
||||||
|
} else {
|
||||||
|
inner.removeAttribute('data-placeholder');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep editor in sync with the reactive `disabled` prop. Tiptap's setEditable
|
||||||
|
// flips contenteditable on the inner DOM and stops accepting input — matches
|
||||||
|
// the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1).
|
||||||
|
//
|
||||||
|
// Guard: setEditable triggers a ProseMirror transaction which fires onUpdate;
|
||||||
|
// onUpdate writes through bind:value / bind:mentionedPersons. Without this
|
||||||
|
// idempotence check, the effect would loop on every prop pass-through.
|
||||||
|
$effect(() => {
|
||||||
|
const shouldBeEditable = !disabled;
|
||||||
|
if (editor && editor.isEditable !== shouldBeEditable) {
|
||||||
|
editor.setEditable(shouldBeEditable);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
|
||||||
|
class:opacity-50={disabled}
|
||||||
|
class:pointer-events-none={disabled}
|
||||||
|
aria-disabled={disabled ? 'true' : undefined}
|
||||||
|
bind:this={editorEl}
|
||||||
|
></div>
|
||||||
424
frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts
Normal file
424
frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
/**
|
||||||
|
* PersonMentionEditor — Tiptap-based component tests.
|
||||||
|
*
|
||||||
|
* All old tests used document.querySelector('textarea') which is dead after
|
||||||
|
* the Tiptap migration. These tests drive the contenteditable via
|
||||||
|
* userEvent.type() and inspect the serialized output from the test host.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type Person = components['schemas']['Person'];
|
||||||
|
type PersonMention = components['schemas']['PersonMention'];
|
||||||
|
|
||||||
|
const AUGUSTE: Person = {
|
||||||
|
id: 'p-aug',
|
||||||
|
firstName: 'Auguste',
|
||||||
|
lastName: 'Raddatz',
|
||||||
|
displayName: 'Auguste Raddatz',
|
||||||
|
birthYear: 1882,
|
||||||
|
deathYear: 1944
|
||||||
|
} as unknown as Person;
|
||||||
|
|
||||||
|
const ANNA: Person = {
|
||||||
|
id: 'p-anna',
|
||||||
|
firstName: 'Anna',
|
||||||
|
lastName: 'Schmidt',
|
||||||
|
displayName: 'Anna Schmidt',
|
||||||
|
birthYear: 1860
|
||||||
|
} as unknown as Person;
|
||||||
|
|
||||||
|
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFetchEmpty() {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
|
||||||
|
|
||||||
|
function renderHost(
|
||||||
|
initial: { value?: string; mentionedPersons?: PersonMention[]; disabled?: boolean } = {}
|
||||||
|
) {
|
||||||
|
let snapshot: Snapshot = {
|
||||||
|
value: initial.value ?? '',
|
||||||
|
mentionedPersons: initial.mentionedPersons ?? []
|
||||||
|
};
|
||||||
|
render(PersonMentionEditorHost, {
|
||||||
|
initialValue: initial.value ?? '',
|
||||||
|
initialMentions: initial.mentionedPersons ?? [],
|
||||||
|
disabled: initial.disabled ?? false,
|
||||||
|
onChange: (snap: Snapshot) => {
|
||||||
|
snapshot = snap;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
get snapshot() {
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — rendering', () => {
|
||||||
|
it('renders the editor as a textbox (ARIA role from editorProps)', async () => {
|
||||||
|
render(PersonMentionEditorHost, {
|
||||||
|
initialValue: '',
|
||||||
|
initialMentions: [],
|
||||||
|
onChange: () => {}
|
||||||
|
});
|
||||||
|
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects bound initial value as visible text', async () => {
|
||||||
|
render(PersonMentionEditorHost, {
|
||||||
|
initialValue: 'Hallo Welt',
|
||||||
|
initialMentions: [],
|
||||||
|
onChange: () => {}
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Typeahead opens on @ ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — typeahead', () => {
|
||||||
|
it('opens the dropdown when typing @ + query and shows results', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
renderHost();
|
||||||
|
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hits /api/persons?q= with the typed query', async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
renderHost();
|
||||||
|
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows life dates next to the name in the dropdown', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
renderHost();
|
||||||
|
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no persons match', async () => {
|
||||||
|
mockFetchEmpty();
|
||||||
|
renderHost();
|
||||||
|
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@xyz');
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers a "create new person" link in the empty state', async () => {
|
||||||
|
mockFetchEmpty();
|
||||||
|
renderHost();
|
||||||
|
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@xyz');
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
const link = page.getByRole('link', { name: /Neue Person anlegen/ });
|
||||||
|
await expect.element(link).toBeVisible();
|
||||||
|
await expect.element(link).toHaveAttribute('href', '/persons/new');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
|
||||||
|
it('stores the typed query as displayName, not the person DB name', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
const host = renderHost();
|
||||||
|
|
||||||
|
// User types "@Aug" (not the full "Auguste Raddatz") and selects Auguste Raddatz
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
||||||
|
expect(host.snapshot.mentionedPersons[0]).toEqual({
|
||||||
|
personId: 'p-aug',
|
||||||
|
displayName: 'Aug' // typed text, not "Auguste Raddatz"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regression: text value contains the typed query, not the full DB name', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
const host = renderHost();
|
||||||
|
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
// Text should contain "@Aug " (typed text + space), not "@Auguste Raddatz "
|
||||||
|
expect(host.snapshot.value).toContain('@Aug');
|
||||||
|
expect(host.snapshot.value).not.toContain('@Auguste Raddatz');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pushes {personId, displayName} into mentionedPersons sidecar', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
const host = renderHost();
|
||||||
|
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
const host = renderHost({
|
||||||
|
value: '@Aug ',
|
||||||
|
mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Keyboard navigation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — keyboard navigation', () => {
|
||||||
|
it('Enter selects the highlighted result', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
const host = renderHost();
|
||||||
|
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@A');
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowDown moves the highlight to the next result', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
renderHost();
|
||||||
|
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@A');
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
const annaOption = page.getByRole('option', { name: /Anna Schmidt/ });
|
||||||
|
await expect.element(annaOption).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape closes the dropdown without inserting', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
const host = renderHost();
|
||||||
|
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.keyboard('{Escape}');
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.snapshot.mentionedPersons).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Disabled state (WCAG 2.1.1 — keyboard users) ────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — disabled state', () => {
|
||||||
|
it('sets contenteditable=false on the editor when disabled', async () => {
|
||||||
|
renderHost({ value: 'Bestehender Text', disabled: true });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||||||
|
expect(textbox).not.toBeNull();
|
||||||
|
expect(textbox!.getAttribute('contenteditable')).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes aria-disabled=true on the editor wrapper when disabled', async () => {
|
||||||
|
renderHost({ disabled: true });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const wrapper = document.querySelector('[aria-disabled="true"]');
|
||||||
|
expect(wrapper).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the editor editable (contenteditable=true) when not disabled', async () => {
|
||||||
|
renderHost({ disabled: false });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||||||
|
expect(textbox).not.toBeNull();
|
||||||
|
expect(textbox!.getAttribute('contenteditable')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Security — XSS in displayName (CWE-79) ──────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — XSS resistance', () => {
|
||||||
|
it('renders a malicious displayName as text, not as HTML elements', async () => {
|
||||||
|
// A historical sidecar entry whose displayName contains an HTML payload
|
||||||
|
// that would execute if interpolated as raw HTML. Tiptap's renderHTML
|
||||||
|
// returns the @-prefixed string as the third tuple entry, which
|
||||||
|
// ProseMirror's DOMSerializer treats as a Text node — escaping it.
|
||||||
|
const maliciousMention: PersonMention = {
|
||||||
|
personId: '00000000-0000-0000-0000-000000000001',
|
||||||
|
displayName: '<img src=x onerror=alert(1)>'
|
||||||
|
};
|
||||||
|
|
||||||
|
renderHost({
|
||||||
|
value: '@<img src=x onerror=alert(1)>',
|
||||||
|
mentionedPersons: [maliciousMention]
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||||||
|
expect(textbox).not.toBeNull();
|
||||||
|
// No element from the malicious payload should have appeared as a real
|
||||||
|
// DOM node. (Tiptap inserts its own ProseMirror-separator <img> in empty
|
||||||
|
// paragraphs — that is internal markup and never carries user attrs;
|
||||||
|
// guard against the injection by checking the user-controlled attrs.)
|
||||||
|
expect(textbox!.querySelector('img[onerror]')).toBeNull();
|
||||||
|
expect(textbox!.querySelector('img[src="x"]')).toBeNull();
|
||||||
|
expect(textbox!.querySelector('script')).toBeNull();
|
||||||
|
// The payload should appear as visible text content instead.
|
||||||
|
expect(textbox!.textContent ?? '').toContain('<img src=x onerror=alert(1)>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Placeholder behavior ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — placeholder behavior', () => {
|
||||||
|
it('sets data-placeholder on the inner element when editor is empty', async () => {
|
||||||
|
render(PersonMentionEditorHost, {
|
||||||
|
initialValue: '',
|
||||||
|
initialMentions: [],
|
||||||
|
placeholder: 'Gib Text ein...',
|
||||||
|
onChange: () => {}
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
|
||||||
|
expect(inner).not.toBeNull();
|
||||||
|
expect(inner!.getAttribute('data-placeholder')).toBe('Gib Text ein...');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits data-placeholder on the inner element when editor has content', async () => {
|
||||||
|
render(PersonMentionEditorHost, {
|
||||||
|
initialValue: 'Bestehender Text',
|
||||||
|
initialMentions: [],
|
||||||
|
placeholder: 'Gib Text ein...',
|
||||||
|
onChange: () => {}
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
|
||||||
|
expect(inner).not.toBeNull();
|
||||||
|
expect(inner!.hasAttribute('data-placeholder')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── i18n message content ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — i18n message content', () => {
|
||||||
|
it('transcription_block_placeholder contains @ mention trigger for discoverability', () => {
|
||||||
|
expect(m.transcription_block_placeholder()).toContain('@');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonMentionEditor — touch target', () => {
|
||||||
|
it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
renderHost();
|
||||||
|
|
||||||
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await expect.element(page.getByRole('option').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
const option = document.querySelector('[role="option"]') as HTMLElement;
|
||||||
|
expect(option).not.toBeNull();
|
||||||
|
expect(option.className).toContain('min-h-[44px]');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import PersonMentionEditor from './PersonMentionEditor.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type PersonMention = components['schemas']['PersonMention'];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
initialValue?: string;
|
||||||
|
initialMentions?: PersonMention[];
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
initialValue = '',
|
||||||
|
initialMentions = [],
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
onChange
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// initial* props seed mount-time state; reading them inside untrack signals
|
||||||
|
// the intentional one-shot capture and silences state_referenced_locally.
|
||||||
|
let value = $state(untrack(() => initialValue));
|
||||||
|
let mentionedPersons = $state<PersonMention[]>(untrack(() => [...initialMentions]));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
onChange({ value, mentionedPersons: [...mentionedPersons] });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PersonMentionEditor
|
||||||
|
bind:value={value}
|
||||||
|
bind:mentionedPersons={mentionedPersons}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
@@ -12,16 +12,25 @@ const PERSONS = [
|
|||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
displayName: 'Max Mustermann',
|
displayName: 'Max Mustermann',
|
||||||
personType: 'PERSON'
|
personType: 'PERSON',
|
||||||
|
familyMember: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
firstName: 'Anna',
|
firstName: 'Anna',
|
||||||
lastName: 'Musterfrau',
|
lastName: 'Musterfrau',
|
||||||
displayName: 'Anna Musterfrau',
|
displayName: 'Anna Musterfrau',
|
||||||
personType: 'PERSON'
|
personType: 'PERSON',
|
||||||
|
familyMember: false
|
||||||
},
|
},
|
||||||
{ id: '3', firstName: 'Karl', lastName: 'König', displayName: 'Karl König', personType: 'PERSON' }
|
{
|
||||||
|
id: '3',
|
||||||
|
firstName: 'Karl',
|
||||||
|
lastName: 'König',
|
||||||
|
displayName: 'Karl König',
|
||||||
|
personType: 'PERSON',
|
||||||
|
familyMember: false
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
function mockFetch(persons = PERSONS) {
|
function mockFetch(persons = PERSONS) {
|
||||||
@@ -62,14 +71,16 @@ describe('PersonMultiSelect – rendering', () => {
|
|||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
displayName: 'Max Mustermann',
|
displayName: 'Max Mustermann',
|
||||||
personType: 'PERSON'
|
personType: 'PERSON',
|
||||||
|
familyMember: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
firstName: 'Anna',
|
firstName: 'Anna',
|
||||||
lastName: 'Musterfrau',
|
lastName: 'Musterfrau',
|
||||||
displayName: 'Anna Musterfrau',
|
displayName: 'Anna Musterfrau',
|
||||||
personType: 'PERSON'
|
personType: 'PERSON',
|
||||||
|
familyMember: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -86,14 +97,16 @@ describe('PersonMultiSelect – rendering', () => {
|
|||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
displayName: 'Max Mustermann',
|
displayName: 'Max Mustermann',
|
||||||
personType: 'PERSON'
|
personType: 'PERSON',
|
||||||
|
familyMember: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
firstName: 'Anna',
|
firstName: 'Anna',
|
||||||
lastName: 'Musterfrau',
|
lastName: 'Musterfrau',
|
||||||
displayName: 'Anna Musterfrau',
|
displayName: 'Anna Musterfrau',
|
||||||
personType: 'PERSON'
|
personType: 'PERSON',
|
||||||
|
familyMember: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -112,7 +125,8 @@ describe('PersonMultiSelect – rendering', () => {
|
|||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
displayName: 'Max Mustermann',
|
displayName: 'Max Mustermann',
|
||||||
personType: 'PERSON'
|
personType: 'PERSON',
|
||||||
|
familyMember: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -166,7 +180,8 @@ describe('PersonMultiSelect – selecting persons', () => {
|
|||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
displayName: 'Max Mustermann',
|
displayName: 'Max Mustermann',
|
||||||
personType: 'PERSON'
|
personType: 'PERSON',
|
||||||
|
familyMember: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -187,7 +202,8 @@ describe('PersonMultiSelect – selecting persons', () => {
|
|||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
displayName: 'Max Mustermann',
|
displayName: 'Max Mustermann',
|
||||||
personType: 'PERSON'
|
personType: 'PERSON',
|
||||||
|
familyMember: false
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
render(PersonMultiSelect, { selectedPersons: [] });
|
render(PersonMultiSelect, { selectedPersons: [] });
|
||||||
@@ -210,14 +226,16 @@ describe('PersonMultiSelect – removing persons', () => {
|
|||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
displayName: 'Max Mustermann',
|
displayName: 'Max Mustermann',
|
||||||
personType: 'PERSON'
|
personType: 'PERSON',
|
||||||
|
familyMember: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
firstName: 'Anna',
|
firstName: 'Anna',
|
||||||
lastName: 'Musterfrau',
|
lastName: 'Musterfrau',
|
||||||
displayName: 'Anna Musterfrau',
|
displayName: 'Anna Musterfrau',
|
||||||
personType: 'PERSON'
|
personType: 'PERSON',
|
||||||
|
familyMember: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -236,14 +254,16 @@ describe('PersonMultiSelect – removing persons', () => {
|
|||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
displayName: 'Max Mustermann',
|
displayName: 'Max Mustermann',
|
||||||
personType: 'PERSON'
|
personType: 'PERSON',
|
||||||
|
familyMember: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
firstName: 'Anna',
|
firstName: 'Anna',
|
||||||
lastName: 'Musterfrau',
|
lastName: 'Musterfrau',
|
||||||
displayName: 'Anna Musterfrau',
|
displayName: 'Anna Musterfrau',
|
||||||
personType: 'PERSON'
|
personType: 'PERSON',
|
||||||
|
familyMember: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface Props {
|
|||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
restrictToCorrespondentsOf?: string;
|
restrictToCorrespondentsOf?: string;
|
||||||
|
excludePersonId?: string;
|
||||||
badge?: 'additive' | 'replace';
|
badge?: 'additive' | 'replace';
|
||||||
onchange?: (value: string) => void;
|
onchange?: (value: string) => void;
|
||||||
onfocused?: () => void;
|
onfocused?: () => void;
|
||||||
@@ -36,6 +37,7 @@ let {
|
|||||||
autofocus = false,
|
autofocus = false,
|
||||||
required = false,
|
required = false,
|
||||||
restrictToCorrespondentsOf,
|
restrictToCorrespondentsOf,
|
||||||
|
excludePersonId,
|
||||||
badge,
|
badge,
|
||||||
onchange,
|
onchange,
|
||||||
onfocused
|
onfocused
|
||||||
@@ -61,21 +63,40 @@ $effect(() => {
|
|||||||
const typeahead = createTypeahead<Person>({
|
const typeahead = createTypeahead<Person>({
|
||||||
fetchUrl: async (term) => {
|
fetchUrl: async (term) => {
|
||||||
const personId = restrictToCorrespondentsOf;
|
const personId = restrictToCorrespondentsOf;
|
||||||
|
const excludeId = excludePersonId;
|
||||||
|
const filter = (results: Person[]) =>
|
||||||
|
excludeId ? results.filter((p) => p.id !== excludeId) : results;
|
||||||
if (personId) {
|
if (personId) {
|
||||||
const url =
|
const url =
|
||||||
term.length >= 1
|
term.length >= 1
|
||||||
? `/api/persons/${personId}/correspondents?q=${encodeURIComponent(term)}`
|
? `/api/persons/${personId}/correspondents?q=${encodeURIComponent(term)}`
|
||||||
: `/api/persons/${personId}/correspondents`;
|
: `/api/persons/${personId}/correspondents`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
return res.ok ? await res.json() : [];
|
return res.ok ? filter(await res.json()) : [];
|
||||||
}
|
}
|
||||||
if (term.length < 1) return [];
|
if (term.length < 1) return [];
|
||||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`);
|
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`);
|
||||||
return res.ok ? await res.json() : [];
|
return res.ok ? filter(await res.json()) : [];
|
||||||
},
|
},
|
||||||
debounceMs: 300
|
debounceMs: 300
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fixed-position dropdown state — escapes any CSS stacking context that would clip it.
|
||||||
|
let inputEl: HTMLInputElement;
|
||||||
|
let dropdownStyle = $state('');
|
||||||
|
let activeIndex = $state(-1);
|
||||||
|
|
||||||
|
// Stable id linking the input's aria-controls to the listbox element.
|
||||||
|
const listboxId = `${name}-listbox`;
|
||||||
|
|
||||||
|
const isOpen = $derived(typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading));
|
||||||
|
|
||||||
|
function updateDropdownPosition() {
|
||||||
|
if (!inputEl) return;
|
||||||
|
const rect = inputEl.getBoundingClientRect();
|
||||||
|
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
|
||||||
|
}
|
||||||
|
|
||||||
function handleInput() {
|
function handleInput() {
|
||||||
if (value && searchTerm !== initialName) {
|
if (value && searchTerm !== initialName) {
|
||||||
value = '';
|
value = '';
|
||||||
@@ -88,6 +109,7 @@ function handleInput() {
|
|||||||
|
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
onfocused?.();
|
onfocused?.();
|
||||||
|
updateDropdownPosition();
|
||||||
if (restrictToCorrespondentsOf) {
|
if (restrictToCorrespondentsOf) {
|
||||||
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -109,13 +131,47 @@ function selectPerson(person: Person) {
|
|||||||
value = person.id!;
|
value = person.id!;
|
||||||
searchTerm = person.displayName;
|
searchTerm = person.displayName;
|
||||||
typeahead.close();
|
typeahead.close();
|
||||||
|
activeIndex = -1;
|
||||||
onchange?.(person.id!);
|
onchange?.(person.id!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
typeahead.close();
|
||||||
|
activeIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const results = typeahead.results;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex = (activeIndex + 1) % results.length;
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex = (activeIndex - 1 + results.length) % results.length;
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeIndex >= 0 && results[activeIndex]) {
|
||||||
|
selectPerson(results[activeIndex]);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep dropdown position current when user scrolls or resizes.
|
||||||
|
// fixed positioning is intentional — it escapes any CSS stacking context (overflow, transform,
|
||||||
|
// shadow-sm + z-index combinations) that would clip an absolute-positioned dropdown.
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
|
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||||
|
|
||||||
|
<div class="relative" use:clickOutside onclickoutside={closeDropdown}>
|
||||||
<label
|
<label
|
||||||
for={name}
|
for="{name}-search"
|
||||||
class={compact
|
class={compact
|
||||||
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||||
: 'block text-sm font-medium text-ink-2'}
|
: 'block text-sm font-medium text-ink-2'}
|
||||||
@@ -125,13 +181,22 @@ function selectPerson(person: Person) {
|
|||||||
<input type="hidden" name={name} bind:value={value} />
|
<input type="hidden" name={name} bind:value={value} />
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
type="text"
|
type="text"
|
||||||
id="{name}-search"
|
id="{name}-search"
|
||||||
|
role="combobox"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autofocus={autofocus}
|
autofocus={autofocus}
|
||||||
bind:value={searchTerm}
|
bind:value={searchTerm}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-controls={listboxId}
|
||||||
|
aria-activedescendant={activeIndex >= 0 && typeahead.results[activeIndex]
|
||||||
|
? `${listboxId}-option-${typeahead.results[activeIndex].id}`
|
||||||
|
: undefined}
|
||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
onfocus={handleFocus}
|
onfocus={handleFocus}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
||||||
class={large
|
class={large
|
||||||
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||||
@@ -140,29 +205,34 @@ function selectPerson(person: Person) {
|
|||||||
: 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
: 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
{#if isOpen}
|
||||||
<div
|
<ul
|
||||||
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
id={listboxId}
|
||||||
|
role="listbox"
|
||||||
|
style={dropdownStyle}
|
||||||
|
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
||||||
>
|
>
|
||||||
{#if typeahead.loading}
|
{#if typeahead.loading}
|
||||||
<div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div>
|
<li class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</li>
|
||||||
{:else}
|
{:else}
|
||||||
{#each typeahead.results as person (person.id)}
|
{#each typeahead.results as person, i (person.id)}
|
||||||
<div
|
<li
|
||||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
|
id="{listboxId}-option-{person.id}"
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === activeIndex}
|
||||||
|
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg {i === activeIndex ? 'bg-accent-bg' : ''}"
|
||||||
onclick={() => selectPerson(person)}
|
onclick={() => selectPerson(person)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||||
role="button"
|
tabindex="-1"
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="block truncate font-medium">
|
<span class="block truncate font-medium">
|
||||||
{person.displayName}
|
{person.displayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page, userEvent } from 'vitest/browser';
|
||||||
import PersonTypeahead from './PersonTypeahead.svelte';
|
import PersonTypeahead from './PersonTypeahead.svelte';
|
||||||
|
|
||||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||||
@@ -130,11 +130,11 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
await expect.element(input).toHaveValue('Max Mustermann');
|
await expect.element(input).toHaveValue('Max Mustermann');
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: 'Max Mustermann' }))
|
.element(page.getByRole('option', { name: 'Max Mustermann' }))
|
||||||
.not.toBeInTheDocument();
|
.not.toBeInTheDocument();
|
||||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
||||||
});
|
});
|
||||||
@@ -145,7 +145,7 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
await tick();
|
await tick();
|
||||||
expect(hiddenInput('senderId')?.value).toBe('1');
|
expect(hiddenInput('senderId')?.value).toBe('1');
|
||||||
@@ -158,7 +158,7 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
expect(onchange).toHaveBeenCalledWith('1');
|
expect(onchange).toHaveBeenCalledWith('1');
|
||||||
});
|
});
|
||||||
@@ -177,7 +177,7 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Ma');
|
await input.fill('Ma');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
await expect.element(input).toHaveValue('Max Mustermann');
|
await expect.element(input).toHaveValue('Max Mustermann');
|
||||||
});
|
});
|
||||||
@@ -194,7 +194,7 @@ describe('PersonTypeahead – clearing a selection', () => {
|
|||||||
|
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
expect(onchange).toHaveBeenCalledWith('1');
|
expect(onchange).toHaveBeenCalledWith('1');
|
||||||
onchange.mockClear();
|
onchange.mockClear();
|
||||||
@@ -285,3 +285,194 @@ describe('PersonTypeahead – click outside', () => {
|
|||||||
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── ARIA roles ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonTypeahead – ARIA roles', () => {
|
||||||
|
it('dropdown uses role="listbox" container and role="option" items', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
// Container must be a listbox
|
||||||
|
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Items must be options, not buttons
|
||||||
|
const options = page.getByRole('option');
|
||||||
|
await expect.element(options.first()).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('option', { name: 'Max Mustermann' })).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('option', { name: 'Anna Musterfrau' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('input has aria-expanded="false" when dropdown is closed', async () => {
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await expect.element(input).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('input has aria-expanded="true" when dropdown is open', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
await expect.element(input).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('input has aria-controls pointing to the listbox id', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
const ariaControls = await input.element().getAttribute('aria-controls');
|
||||||
|
expect(ariaControls).toBeTruthy();
|
||||||
|
|
||||||
|
// The listbox with that id must exist
|
||||||
|
const listbox = document.getElementById(ariaControls!);
|
||||||
|
expect(listbox).not.toBeNull();
|
||||||
|
expect(listbox!.getAttribute('role')).toBe('listbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('input has aria-haspopup="listbox"', async () => {
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await expect.element(input).toHaveAttribute('aria-haspopup', 'listbox');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Keyboard navigation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonTypeahead – keyboard navigation', () => {
|
||||||
|
it('ArrowDown moves highlight to the first option', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
await input.click();
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
// First option should be highlighted (aria-selected="true")
|
||||||
|
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
|
||||||
|
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowDown then ArrowDown moves highlight to the second option', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
await input.click();
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
await tick();
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const secondOption = page.getByRole('option', { name: 'Anna Musterfrau' });
|
||||||
|
await expect.element(secondOption).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowUp from first wraps to last option', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
await input.click();
|
||||||
|
await userEvent.keyboard('{ArrowDown}'); // highlight first
|
||||||
|
await tick();
|
||||||
|
await userEvent.keyboard('{ArrowUp}'); // wrap to last
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const lastOption = page.getByRole('option', { name: 'Anna Musterfrau' });
|
||||||
|
await expect.element(lastOption).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowDown from last wraps to first option', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
await input.click();
|
||||||
|
await userEvent.keyboard('{ArrowDown}'); // highlight first (index 0)
|
||||||
|
await tick();
|
||||||
|
await userEvent.keyboard('{ArrowDown}'); // highlight second (index 1 = last)
|
||||||
|
await tick();
|
||||||
|
await userEvent.keyboard('{ArrowDown}'); // wrap to first (index 0)
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
|
||||||
|
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Enter selects the highlighted option', async () => {
|
||||||
|
mockFetchWithPersons([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
displayName: 'Max Mustermann',
|
||||||
|
personType: 'PERSON'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Ma');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
await input.click();
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
await tick();
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
await expect.element(input).toHaveValue('Max Mustermann');
|
||||||
|
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape closes the dropdown without selecting', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await input.click();
|
||||||
|
await userEvent.keyboard('{Escape}');
|
||||||
|
await tick();
|
||||||
|
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||||
|
// Value unchanged — nothing was selected
|
||||||
|
await expect.element(input).toHaveValue('Mu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('active option id is set as aria-activedescendant on the input', async () => {
|
||||||
|
mockFetchWithPersons();
|
||||||
|
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await input.fill('Mu');
|
||||||
|
await waitForDebounce();
|
||||||
|
|
||||||
|
// No active option before pressing ArrowDown
|
||||||
|
const beforeNav = await input.element().getAttribute('aria-activedescendant');
|
||||||
|
expect(beforeNav).toBeFalsy();
|
||||||
|
|
||||||
|
await input.click();
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const afterNav = await input.element().getAttribute('aria-activedescendant');
|
||||||
|
expect(afterNav).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
49
frontend/src/lib/components/RelationshipChip.svelte
Normal file
49
frontend/src/lib/components/RelationshipChip.svelte
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chipLabel: string;
|
||||||
|
otherName: string;
|
||||||
|
yearRange?: string;
|
||||||
|
canWrite: boolean;
|
||||||
|
relId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li class="flex items-center gap-2 py-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||||
|
>
|
||||||
|
{chipLabel}
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
|
||||||
|
{otherName}
|
||||||
|
</span>
|
||||||
|
{#if yearRange}
|
||||||
|
<span class="shrink-0 font-sans text-xs text-ink-3" data-testid="year-range">{yearRange}</span>
|
||||||
|
{/if}
|
||||||
|
{#if canWrite}
|
||||||
|
<form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0">
|
||||||
|
<input type="hidden" name="relId" value={relId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label="{m.btn_delete()} — {otherName}"
|
||||||
|
class="inline-flex h-11 w-11 items-center justify-center text-ink-3 transition-colors hover:text-red-600"
|
||||||
|
>
|
||||||
|
<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="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
55
frontend/src/lib/components/RelationshipChip.svelte.spec.ts
Normal file
55
frontend/src/lib/components/RelationshipChip.svelte.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import RelationshipChip from './RelationshipChip.svelte';
|
||||||
|
|
||||||
|
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
chipLabel: 'Elternteil',
|
||||||
|
otherName: 'Anna Schmidt',
|
||||||
|
yearRange: '',
|
||||||
|
canWrite: false,
|
||||||
|
relId: 'rel-1'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RelationshipChip', () => {
|
||||||
|
it('renders the chip label', async () => {
|
||||||
|
render(RelationshipChip, baseProps);
|
||||||
|
await expect.element(page.getByText('Elternteil')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the other person name', async () => {
|
||||||
|
render(RelationshipChip, baseProps);
|
||||||
|
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows year range when provided', async () => {
|
||||||
|
render(RelationshipChip, { ...baseProps, yearRange: '1920–1980' });
|
||||||
|
await expect.element(page.getByText('1920–1980')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show year range span when empty', async () => {
|
||||||
|
render(RelationshipChip, { ...baseProps, yearRange: '' });
|
||||||
|
expect(document.querySelector('[data-testid="year-range"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows delete button when canWrite is true', async () => {
|
||||||
|
render(RelationshipChip, { ...baseProps, canWrite: true });
|
||||||
|
await expect.element(page.getByRole('button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides delete button when canWrite is false', async () => {
|
||||||
|
render(RelationshipChip, { ...baseProps, canWrite: false });
|
||||||
|
expect(document.querySelector('button')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delete button has h-11 w-11 (44px) WCAG touch target class', async () => {
|
||||||
|
render(RelationshipChip, { ...baseProps, canWrite: true });
|
||||||
|
const btn = document.querySelector('button')!;
|
||||||
|
expect(btn.className).toContain('h-11');
|
||||||
|
expect(btn.className).toContain('w-11');
|
||||||
|
});
|
||||||
|
});
|
||||||
10
frontend/src/lib/components/RelationshipPill.svelte
Normal file
10
frontend/src/lib/components/RelationshipPill.svelte
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
type Props = { label: string };
|
||||||
|
let { label }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="inline-flex shrink-0 items-center rounded-full border border-accent bg-accent/25 px-2 py-px font-sans text-[10px] font-bold tracking-[0.07em] text-ink uppercase"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
@@ -36,7 +36,7 @@ let { docs, weeklyCount }: Props = $props();
|
|||||||
{#each docs as doc (doc.id)}
|
{#each docs as doc (doc.id)}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/documents/{doc.id}"
|
href="/documents/{doc.id}?task=transcribe"
|
||||||
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
<span class="font-serif text-sm text-ink">{doc.title}</span>
|
<span class="font-serif text-sm text-ink">{doc.title}</span>
|
||||||
|
|||||||
@@ -56,12 +56,12 @@ describe('SegmentationColumn', () => {
|
|||||||
await expect.element(page.getByText(/diese Woche/)).not.toBeInTheDocument();
|
await expect.element(page.getByText(/diese Woche/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('links to /documents/{id}', async () => {
|
it('links to /documents/{id}?task=transcribe', async () => {
|
||||||
const doc = makeDoc({ id: 'abc-123', title: 'Verlinktes Dokument' });
|
const doc = makeDoc({ id: 'abc-123', title: 'Verlinktes Dokument' });
|
||||||
|
|
||||||
render(SegmentationColumn, { props: { docs: [doc], weeklyCount: 0 } });
|
render(SegmentationColumn, { props: { docs: [doc], weeklyCount: 0 } });
|
||||||
|
|
||||||
const link = page.getByRole('link', { name: /Verlinktes Dokument/ });
|
const link = page.getByRole('link', { name: /Verlinktes Dokument/ });
|
||||||
await expect.element(link).toHaveAttribute('href', '/documents/abc-123');
|
await expect.element(link).toHaveAttribute('href', '/documents/abc-123?task=transcribe');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
174
frontend/src/lib/components/StammbaumCard.svelte
Normal file
174
frontend/src/lib/components/StammbaumCard.svelte
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import RelationshipChip from '$lib/components/RelationshipChip.svelte';
|
||||||
|
import AddRelationshipForm from '$lib/components/AddRelationshipForm.svelte';
|
||||||
|
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/relationshipLabels';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelationshipWithPersonDTO'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
personId: string;
|
||||||
|
familyMember: boolean;
|
||||||
|
relationships: RelationshipDTO[];
|
||||||
|
inferredRelationships: InferredRelationshipWithPersonDTO[];
|
||||||
|
canWrite: boolean;
|
||||||
|
relationshipError?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
personId,
|
||||||
|
familyMember,
|
||||||
|
relationships,
|
||||||
|
inferredRelationships,
|
||||||
|
canWrite,
|
||||||
|
relationshipError = null
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
||||||
|
|
||||||
|
const sortedDirect = $derived([...relationships].sort(byTypeThenYear));
|
||||||
|
const topDerived = $derived(inferredRelationships.slice(0, 5));
|
||||||
|
|
||||||
|
function byTypeThenYear(a: RelationshipDTO, b: RelationshipDTO): number {
|
||||||
|
const order = relationTypeOrder(a.relationType) - relationTypeOrder(b.relationType);
|
||||||
|
if (order !== 0) return order;
|
||||||
|
return (a.fromYear ?? 0) - (b.fromYear ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationTypeOrder(t: RelationType | undefined): number {
|
||||||
|
const order: Record<string, number> = {
|
||||||
|
PARENT_OF: 1,
|
||||||
|
SPOUSE_OF: 2,
|
||||||
|
SIBLING_OF: 3,
|
||||||
|
FRIEND: 4,
|
||||||
|
COLLEAGUE: 5,
|
||||||
|
EMPLOYER: 6,
|
||||||
|
DOCTOR: 7,
|
||||||
|
NEIGHBOR: 8,
|
||||||
|
OTHER: 9
|
||||||
|
};
|
||||||
|
return order[t ?? 'OTHER'] ?? 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yearRange(rel: RelationshipDTO): string {
|
||||||
|
const from = rel.fromYear;
|
||||||
|
const to = rel.toYear;
|
||||||
|
if (from && to) return `${from}–${to}`;
|
||||||
|
if (from) return m.relation_year_from({ year: from });
|
||||||
|
if (to) return m.relation_year_to({ year: to });
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mt-6 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
|
<!-- Header row: heading + family-member toggle -->
|
||||||
|
<div class="mb-5 flex items-start justify-between gap-4">
|
||||||
|
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.stammbaum_relationships_heading()}
|
||||||
|
</h2>
|
||||||
|
{#if canWrite}
|
||||||
|
<form method="POST" action="?/toggleFamilyMember" use:enhance>
|
||||||
|
<input type="hidden" name="familyMember" value={familyMember ? 'false' : 'true'} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={familyMember}
|
||||||
|
aria-label={familyMember
|
||||||
|
? m.relation_toggle_remove_from_tree()
|
||||||
|
: m.relation_toggle_add_to_tree()}
|
||||||
|
class="inline-flex items-center gap-2 font-sans text-xs font-medium text-ink-2 transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="relative inline-block h-4 w-7 rounded-full transition-colors {familyMember
|
||||||
|
? 'bg-primary'
|
||||||
|
: 'bg-line'}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 left-0.5 inline-block h-3 w-3 rounded-full bg-white transition-transform {familyMember
|
||||||
|
? 'translate-x-3'
|
||||||
|
: ''}"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
{m.relation_label_family_member()}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if relationshipError}
|
||||||
|
<p class="mb-3 text-sm text-red-700" role="alert">{relationshipError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- In-tree banner -->
|
||||||
|
{#if familyMember}
|
||||||
|
<div
|
||||||
|
class="mb-4 flex items-center justify-between rounded-sm border border-accent/30 bg-accent/10 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="inline-block h-2 w-2 rounded-full bg-accent"></span>
|
||||||
|
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_label_in_tree()}</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/stammbaum?focus={personId}"
|
||||||
|
class="font-sans text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{m.relation_label_view_in_tree()}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Direkte Beziehungen -->
|
||||||
|
<h3 class="mb-2 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.relation_label_direct()}
|
||||||
|
</h3>
|
||||||
|
{#if sortedDirect.length === 0}
|
||||||
|
<p class="mb-2 text-sm text-ink-2 italic">{m.person_relationships_empty()}</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="mb-2 divide-y divide-line">
|
||||||
|
{#each sortedDirect as rel (rel.id)}
|
||||||
|
<RelationshipChip
|
||||||
|
chipLabel={chipLabel(rel, personId)}
|
||||||
|
otherName={otherName(rel, personId)}
|
||||||
|
yearRange={yearRange(rel)}
|
||||||
|
canWrite={canWrite}
|
||||||
|
relId={rel.id}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if canWrite}
|
||||||
|
<AddRelationshipForm personId={personId} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Abgeleitete Beziehungen -->
|
||||||
|
{#if topDerived.length > 0}
|
||||||
|
<details class="mt-6">
|
||||||
|
<summary
|
||||||
|
class="cursor-pointer text-xs font-bold tracking-widest text-ink-3 uppercase select-none"
|
||||||
|
>
|
||||||
|
{m.relation_label_derived()}
|
||||||
|
</summary>
|
||||||
|
<ul class="mt-2 space-y-2">
|
||||||
|
{#each topDerived as derived (derived.person.id)}
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted/50 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||||
|
>
|
||||||
|
{inferredRelationshipLabel(derived.label)}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="/persons/{derived.person.id}"
|
||||||
|
class="min-w-0 flex-1 truncate font-serif text-sm text-ink-2 hover:underline"
|
||||||
|
>
|
||||||
|
{derived.person.displayName}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
54
frontend/src/lib/components/StammbaumCard.svelte.spec.ts
Normal file
54
frontend/src/lib/components/StammbaumCard.svelte.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import StammbaumCard from './StammbaumCard.svelte';
|
||||||
|
|
||||||
|
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||||
|
vi.mock('$lib/components/RelationshipChip.svelte', () => ({ default: () => null }));
|
||||||
|
vi.mock('$lib/components/AddRelationshipForm.svelte', () => ({ default: () => null }));
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
personId: 'person-1',
|
||||||
|
familyMember: false,
|
||||||
|
relationships: [],
|
||||||
|
inferredRelationships: [],
|
||||||
|
canWrite: false
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('StammbaumCard', () => {
|
||||||
|
it('renders the section heading', async () => {
|
||||||
|
render(StammbaumCard, baseProps);
|
||||||
|
await expect.element(page.getByText('Stammbaum & Beziehungen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty-relationships message when relationships list is empty', async () => {
|
||||||
|
render(StammbaumCard, baseProps);
|
||||||
|
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the family-member toggle when canWrite is true', async () => {
|
||||||
|
render(StammbaumCard, { ...baseProps, canWrite: true });
|
||||||
|
await expect.element(page.getByText('Als Familienmitglied')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays relationshipError text when provided', async () => {
|
||||||
|
render(StammbaumCard, { ...baseProps, relationshipError: 'Test Fehler' });
|
||||||
|
await expect.element(page.getByText('Test Fehler')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggle aria-label says "Zum Stammbaum hinzufügen" when not yet a family member', async () => {
|
||||||
|
render(StammbaumCard, { ...baseProps, canWrite: true, familyMember: false });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('switch', { name: 'Zum Stammbaum hinzufügen' }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggle aria-label says "Aus Stammbaum entfernen" when already a family member', async () => {
|
||||||
|
render(StammbaumCard, { ...baseProps, canWrite: true, familyMember: true });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('switch', { name: 'Aus Stammbaum entfernen' }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
183
frontend/src/lib/components/StammbaumSidePanel.svelte
Normal file
183
frontend/src/lib/components/StammbaumSidePanel.svelte
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/relationshipLabels';
|
||||||
|
import AddRelationshipForm from '$lib/components/AddRelationshipForm.svelte';
|
||||||
|
import type { RelFormData } from '$lib/components/AddRelationshipForm.svelte';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||||
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
type InferredRelationshipWithPersonDTO = components['schemas']['InferredRelationshipWithPersonDTO'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: PersonNodeDTO;
|
||||||
|
onClose: () => void;
|
||||||
|
canWrite?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { node, onClose, canWrite = false }: Props = $props();
|
||||||
|
|
||||||
|
let directRels = $state<RelationshipDTO[]>([]);
|
||||||
|
let derivedRels = $state<InferredRelationshipWithPersonDTO[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = node.id;
|
||||||
|
loadFor(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadFor(id: string) {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const [directRes, derivedRes] = await Promise.all([
|
||||||
|
fetch(`/api/persons/${id}/relationships`),
|
||||||
|
fetch(`/api/persons/${id}/inferred-relationships`)
|
||||||
|
]);
|
||||||
|
if (!directRes.ok || !derivedRes.ok) {
|
||||||
|
error = m.error_internal_error();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
directRels = await directRes.json();
|
||||||
|
derivedRels = await derivedRes.json();
|
||||||
|
} catch {
|
||||||
|
error = m.error_internal_error();
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddRelationship(data: RelFormData) {
|
||||||
|
const body: Record<string, string | number> = {
|
||||||
|
relatedPersonId: data.relatedPersonId,
|
||||||
|
relationType: data.relationType
|
||||||
|
};
|
||||||
|
if (data.fromYear !== undefined) body.fromYear = data.fromYear;
|
||||||
|
if (data.toYear !== undefined) body.toYear = data.toYear;
|
||||||
|
const res = await fetch(`/api/persons/${node.id}/relationships`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to add relationship');
|
||||||
|
await loadFor(node.id);
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEscape(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => handleEscape(e);
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
const directOtherIds = $derived(
|
||||||
|
new Set(directRels.map((r) => (r.personId === node.id ? r.relatedPersonId : r.personId)))
|
||||||
|
);
|
||||||
|
const topDerived = $derived(
|
||||||
|
derivedRels.filter((d) => !directOtherIds.has(d.person.id)).slice(0, 5)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-col p-5">
|
||||||
|
<div class="mb-4 flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="font-serif text-lg text-ink">{node.displayName}</h2>
|
||||||
|
{#if node.birthYear || node.deathYear}
|
||||||
|
<p class="font-sans text-xs text-ink-3">
|
||||||
|
{node.birthYear ?? '?'}–{node.deathYear ?? ''}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onClose}
|
||||||
|
aria-label={m.comp_dismiss()}
|
||||||
|
class="shrink-0 rounded-sm p-1 text-ink-3 transition hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" d="M3 3l10 10M13 3L3 13" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="mb-3 text-sm text-red-700" role="alert">{error}</p>
|
||||||
|
{:else if loading}
|
||||||
|
<p class="font-sans text-xs text-ink-3 italic">…</p>
|
||||||
|
{:else}
|
||||||
|
<section class="mb-5">
|
||||||
|
<h3 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.stammbaum_panel_direct_rels()}
|
||||||
|
</h3>
|
||||||
|
{#if directRels.length === 0}
|
||||||
|
<p class="text-xs text-ink-2 italic">{m.person_relationships_empty()}</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-1.5">
|
||||||
|
{#each directRels as rel (rel.id)}
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||||
|
>
|
||||||
|
{chipLabel(rel, node.id)}
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate font-serif text-xs text-ink">
|
||||||
|
{otherName(rel, node.id)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if canWrite}
|
||||||
|
{#key node.id}
|
||||||
|
<AddRelationshipForm personId={node.id} onSubmit={handleAddRelationship} />
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if topDerived.length > 0}
|
||||||
|
<section class="mb-5">
|
||||||
|
<h3 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.stammbaum_panel_derived_rels()}
|
||||||
|
</h3>
|
||||||
|
<ul class="space-y-1.5">
|
||||||
|
{#each topDerived as derived (derived.person.id)}
|
||||||
|
<li class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted/50 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||||
|
>
|
||||||
|
{inferredRelationshipLabel(derived.label)}
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate font-serif text-xs text-ink-2">
|
||||||
|
{derived.person.displayName}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-auto">
|
||||||
|
<a
|
||||||
|
href="/persons/{node.id}"
|
||||||
|
class="block w-full rounded-sm border border-line bg-surface px-3 py-2 text-center font-sans text-xs font-medium text-primary transition hover:bg-muted"
|
||||||
|
>
|
||||||
|
{m.stammbaum_panel_to_person()}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import StammbaumSidePanel from './StammbaumSidePanel.svelte';
|
||||||
|
|
||||||
|
vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() }));
|
||||||
|
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||||
|
vi.mock('$lib/components/PersonTypeahead.svelte', () => ({ default: () => null }));
|
||||||
|
|
||||||
|
const makeNode = () => ({
|
||||||
|
id: 'person-1',
|
||||||
|
displayName: 'Alice Müller',
|
||||||
|
birthYear: 1900,
|
||||||
|
deathYear: null,
|
||||||
|
familyMember: true
|
||||||
|
});
|
||||||
|
|
||||||
|
function stubFetch(directRels: unknown[] = [], inferredRels: unknown[] = []) {
|
||||||
|
let callCount = 0;
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
const body = callCount % 2 === 1 ? directRels : inferredRels;
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(body) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => stubFetch());
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StammbaumSidePanel', () => {
|
||||||
|
it('calls onClose when dismiss button is clicked', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(StammbaumSidePanel, { node: makeNode(), onClose, canWrite: false });
|
||||||
|
await expect.element(page.getByRole('button', { name: 'Schließen' })).toBeInTheDocument();
|
||||||
|
const btn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||||
|
(b) => b.getAttribute('aria-label') === 'Schließen'
|
||||||
|
);
|
||||||
|
btn!.click();
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the person displayName as heading', async () => {
|
||||||
|
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
|
||||||
|
await expect.element(page.getByText('Alice Müller')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty-relationships message when no direct relationships are loaded', async () => {
|
||||||
|
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
|
||||||
|
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('year inputs inside the add form have label elements (canWrite=true)', async () => {
|
||||||
|
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: true });
|
||||||
|
await expect.element(page.getByText('Noch keine Beziehungen bekannt.')).toBeInTheDocument();
|
||||||
|
const addBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) =>
|
||||||
|
/Beziehung hinzufügen/i.test(b.textContent ?? '')
|
||||||
|
);
|
||||||
|
addBtn!.click();
|
||||||
|
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
const yearInputs = [...document.querySelectorAll('input')].filter(
|
||||||
|
(i) => i.inputMode === 'numeric'
|
||||||
|
);
|
||||||
|
expect(yearInputs.length).toBeGreaterThan(0);
|
||||||
|
for (const input of yearInputs) {
|
||||||
|
expect(input.closest('label')).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading indicator while fetching', async () => {
|
||||||
|
let resolveFirst: (v: unknown) => void;
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveFirst = resolve;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
render(StammbaumSidePanel, { node: makeNode(), onClose: vi.fn(), canWrite: false });
|
||||||
|
await expect.element(page.getByText('…')).toBeInTheDocument();
|
||||||
|
resolveFirst!({ ok: true, json: () => Promise.resolve([]) });
|
||||||
|
});
|
||||||
|
});
|
||||||
548
frontend/src/lib/components/StammbaumTree.svelte
Normal file
548
frontend/src/lib/components/StammbaumTree.svelte
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/* eslint-disable svelte/prefer-svelte-reactivity -- maps are scope-local
|
||||||
|
to a single $derived.by computation; never mutated after layout. */
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||||
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nodes: PersonNodeDTO[];
|
||||||
|
edges: RelationshipDTO[];
|
||||||
|
selectedId: string | null;
|
||||||
|
zoom: number;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { nodes, edges, selectedId, zoom, onSelect }: Props = $props();
|
||||||
|
|
||||||
|
const NODE_W = 160;
|
||||||
|
const NODE_H = 56;
|
||||||
|
const COL_GAP = 40;
|
||||||
|
const ROW_GAP = 80;
|
||||||
|
const VIEWBOX_PAD = 80;
|
||||||
|
// Minimum viewBox dimensions — keeps a single node from being scaled up
|
||||||
|
// to fill the entire canvas. Roughly matches a typical desktop content area.
|
||||||
|
const MIN_VIEWBOX_W = 1200;
|
||||||
|
const MIN_VIEWBOX_H = 800;
|
||||||
|
|
||||||
|
type Layout = {
|
||||||
|
positions: Map<string, { x: number; y: number }>;
|
||||||
|
generations: Map<number, string[]>;
|
||||||
|
viewX: number;
|
||||||
|
viewY: number;
|
||||||
|
viewW: number;
|
||||||
|
viewH: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
|
||||||
|
const viewBox = $derived.by(() => {
|
||||||
|
const w = layout.viewW / zoom;
|
||||||
|
const h = layout.viewH / zoom;
|
||||||
|
const cx = layout.viewX + layout.viewW / 2;
|
||||||
|
const cy = layout.viewY + layout.viewH / 2;
|
||||||
|
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
|
||||||
|
const parentToChildren = new Map<string, string[]>();
|
||||||
|
const childToParents = new Map<string, string[]>();
|
||||||
|
const spousePairs = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const e of allEdges) {
|
||||||
|
switch (e.relationType) {
|
||||||
|
case 'PARENT_OF':
|
||||||
|
mapPush(parentToChildren, e.personId, e.relatedPersonId);
|
||||||
|
mapPush(childToParents, e.relatedPersonId, e.personId);
|
||||||
|
break;
|
||||||
|
case 'SPOUSE_OF':
|
||||||
|
spousePairs.set(e.personId, e.relatedPersonId);
|
||||||
|
spousePairs.set(e.relatedPersonId, e.personId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterative longest-path generation assignment.
|
||||||
|
//
|
||||||
|
// Each node's generation = max(parent generations) + 1 (roots stay at 0).
|
||||||
|
// Then spouses are pulled to share the deeper generation. Pulling a spouse
|
||||||
|
// down can shift their own descendants, so we iterate until stable rather
|
||||||
|
// than running BFS once like the previous implementation (which left
|
||||||
|
// e.g. a child of a "later-pulled" spouse stranded one row too high).
|
||||||
|
const generation = new Map<string, number>();
|
||||||
|
for (const n of allNodes) generation.set(n.id, 0);
|
||||||
|
const maxIters = allNodes.length + 4;
|
||||||
|
for (let it = 0; it < maxIters; it++) {
|
||||||
|
let changed = false;
|
||||||
|
for (const n of allNodes) {
|
||||||
|
const parents = childToParents.get(n.id) ?? [];
|
||||||
|
if (parents.length === 0) continue;
|
||||||
|
let maxParentGen = -1;
|
||||||
|
for (const pid of parents) {
|
||||||
|
maxParentGen = Math.max(maxParentGen, generation.get(pid) ?? 0);
|
||||||
|
}
|
||||||
|
const newGen = maxParentGen + 1;
|
||||||
|
if ((generation.get(n.id) ?? 0) < newGen) {
|
||||||
|
generation.set(n.id, newGen);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [a, b] of spousePairs) {
|
||||||
|
const m = Math.max(generation.get(a) ?? 0, generation.get(b) ?? 0);
|
||||||
|
if ((generation.get(a) ?? 0) < m) {
|
||||||
|
generation.set(a, m);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if ((generation.get(b) ?? 0) < m) {
|
||||||
|
generation.set(b, m);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by generation, then sort within generation by display name.
|
||||||
|
const generations = new Map<number, string[]>();
|
||||||
|
for (const n of allNodes) {
|
||||||
|
const g = generation.get(n.id) ?? 0;
|
||||||
|
if (!generations.has(g)) generations.set(g, []);
|
||||||
|
generations.get(g)!.push(n.id);
|
||||||
|
}
|
||||||
|
const byId = new Map(allNodes.map((n) => [n.id, n]));
|
||||||
|
for (const ids of generations.values()) {
|
||||||
|
ids.sort((a, b) => {
|
||||||
|
const an = byId.get(a)?.displayName ?? '';
|
||||||
|
const bn = byId.get(b)?.displayName ?? '';
|
||||||
|
return an.localeCompare(bn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-generation layout:
|
||||||
|
//
|
||||||
|
// 1. Build sibling-groups (children of the same parent set) — these become
|
||||||
|
// the layout "blocks" that are centred under their parents' midpoint.
|
||||||
|
// 2. Attach loose spouses (people with no parents in the graph but a
|
||||||
|
// spouse who *is* in a sibling group) on the outside of their partner,
|
||||||
|
// so the spouse line stays short and adjacent.
|
||||||
|
// 3. Merge dual-loose spouse pairs into a single 2-person block.
|
||||||
|
// 4. Centre each block such that its *parented* members average sits
|
||||||
|
// exactly under the parent midpoint (keeping all connectors at 90°),
|
||||||
|
// then pack blocks left-to-right.
|
||||||
|
type Block = {
|
||||||
|
members: { id: string; parented: boolean }[];
|
||||||
|
center: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const positions = new Map<string, { x: number; y: number }>();
|
||||||
|
const sortedGens = [...generations.keys()].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
for (let gi = 0; gi < sortedGens.length; gi++) {
|
||||||
|
const g = sortedGens[gi];
|
||||||
|
const ids = generations.get(g)!;
|
||||||
|
const y = g * (NODE_H + ROW_GAP);
|
||||||
|
|
||||||
|
const blocksByKey = new Map<string, Block>();
|
||||||
|
const memberLookup = new Map<string, { key: string; parented: boolean }>();
|
||||||
|
|
||||||
|
// Step 1: place every node with parents-in-graph into a sibling block.
|
||||||
|
for (const id of ids) {
|
||||||
|
const parents = childToParents.get(id) ?? [];
|
||||||
|
if (parents.length === 0) continue;
|
||||||
|
const blockKey = [...parents].sort().join('|');
|
||||||
|
let block = blocksByKey.get(blockKey);
|
||||||
|
if (!block) {
|
||||||
|
const parentCenters: number[] = [];
|
||||||
|
for (const pid of parents) {
|
||||||
|
const p = positions.get(pid);
|
||||||
|
if (p) parentCenters.push(p.x + NODE_W / 2);
|
||||||
|
}
|
||||||
|
const center =
|
||||||
|
parentCenters.length > 0
|
||||||
|
? parentCenters.reduce((a, b) => a + b, 0) / parentCenters.length
|
||||||
|
: 0;
|
||||||
|
block = { members: [], center };
|
||||||
|
blocksByKey.set(blockKey, block);
|
||||||
|
}
|
||||||
|
block.members.push({ id, parented: true });
|
||||||
|
memberLookup.set(id, { key: blockKey, parented: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort members within each sibling block alphabetically.
|
||||||
|
for (const block of blocksByKey.values()) {
|
||||||
|
block.members.sort((a, b) =>
|
||||||
|
(byId.get(a.id)?.displayName ?? '').localeCompare(byId.get(b.id)?.displayName ?? '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2 + 3: handle loose nodes.
|
||||||
|
for (const id of ids) {
|
||||||
|
if (memberLookup.has(id)) continue;
|
||||||
|
const spouse = spousePairs.get(id);
|
||||||
|
const spouseLookup = spouse ? memberLookup.get(spouse) : undefined;
|
||||||
|
|
||||||
|
if (spouseLookup && spouseLookup.parented) {
|
||||||
|
// Spouse is parented — attach this loose node next to them on
|
||||||
|
// the outer edge of their sibling block so the marriage line
|
||||||
|
// is short and the sibling order is preserved.
|
||||||
|
const block = blocksByKey.get(spouseLookup.key)!;
|
||||||
|
const spouseIdx = block.members.findIndex((m) => m.id === spouse);
|
||||||
|
const insertOnRight = spouseIdx >= block.members.length / 2;
|
||||||
|
const insertAt = insertOnRight ? spouseIdx + 1 : spouseIdx;
|
||||||
|
block.members.splice(insertAt, 0, { id, parented: false });
|
||||||
|
memberLookup.set(id, { key: spouseLookup.key, parented: false });
|
||||||
|
} else {
|
||||||
|
// No usable parented spouse: put in its own loose block. We
|
||||||
|
// merge dual-loose spouse pairs in the next pass.
|
||||||
|
const blockKey = `__loose__${id}`;
|
||||||
|
blocksByKey.set(blockKey, {
|
||||||
|
members: [{ id, parented: false }],
|
||||||
|
center: 0
|
||||||
|
});
|
||||||
|
memberLookup.set(id, { key: blockKey, parented: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge dual-loose spouse blocks into a single 2-person block.
|
||||||
|
const removed = new Set<string>();
|
||||||
|
for (const [key, block] of blocksByKey) {
|
||||||
|
if (!key.startsWith('__loose__')) continue;
|
||||||
|
if (removed.has(key)) continue;
|
||||||
|
const member = block.members[0];
|
||||||
|
const spouse = spousePairs.get(member.id);
|
||||||
|
if (!spouse) continue;
|
||||||
|
const spouseLookup = memberLookup.get(spouse);
|
||||||
|
if (!spouseLookup || removed.has(spouseLookup.key)) continue;
|
||||||
|
if (spouseLookup.key === key) continue;
|
||||||
|
if (!spouseLookup.key.startsWith('__loose__')) continue;
|
||||||
|
const otherBlock = blocksByKey.get(spouseLookup.key)!;
|
||||||
|
block.members.push(...otherBlock.members);
|
||||||
|
removed.add(spouseLookup.key);
|
||||||
|
}
|
||||||
|
for (const key of removed) blocksByKey.delete(key);
|
||||||
|
|
||||||
|
// Step 4: centre each block on its anchor (parented members) and pack.
|
||||||
|
const ordered = [...blocksByKey.values()].sort((a, b) => a.center - b.center);
|
||||||
|
let cursorRight = -Infinity;
|
||||||
|
for (const block of ordered) {
|
||||||
|
const n = block.members.length;
|
||||||
|
const groupWidth = n * NODE_W + (n - 1) * COL_GAP;
|
||||||
|
const anchorIndices: number[] = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (block.members[i].parented) anchorIndices.push(i);
|
||||||
|
}
|
||||||
|
const avgAnchorIdx =
|
||||||
|
anchorIndices.length > 0
|
||||||
|
? anchorIndices.reduce((a, b) => a + b, 0) / anchorIndices.length
|
||||||
|
: (n - 1) / 2;
|
||||||
|
let groupLeft = block.center - NODE_W / 2 - avgAnchorIdx * (NODE_W + COL_GAP);
|
||||||
|
if (groupLeft < cursorRight + COL_GAP) groupLeft = cursorRight + COL_GAP;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
positions.set(block.members[i].id, {
|
||||||
|
x: groupLeft + i * (NODE_W + COL_GAP),
|
||||||
|
y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cursorRight = groupLeft + groupWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounding box around the actual content, then expanded to MIN dimensions
|
||||||
|
// (so a single node doesn't get scaled up to fill the canvas). The viewBox
|
||||||
|
// is centered on the content.
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (const p of positions.values()) {
|
||||||
|
minX = Math.min(minX, p.x);
|
||||||
|
minY = Math.min(minY, p.y);
|
||||||
|
maxX = Math.max(maxX, p.x + NODE_W);
|
||||||
|
maxY = Math.max(maxY, p.y + NODE_H);
|
||||||
|
}
|
||||||
|
if (positions.size === 0) {
|
||||||
|
minX = 0;
|
||||||
|
minY = 0;
|
||||||
|
maxX = 0;
|
||||||
|
maxY = 0;
|
||||||
|
}
|
||||||
|
const contentW = maxX - minX;
|
||||||
|
const contentH = maxY - minY;
|
||||||
|
const viewW = Math.max(contentW + 2 * VIEWBOX_PAD, MIN_VIEWBOX_W);
|
||||||
|
const viewH = Math.max(contentH + 2 * VIEWBOX_PAD, MIN_VIEWBOX_H);
|
||||||
|
const viewX = minX + contentW / 2 - viewW / 2;
|
||||||
|
const viewY = minY + contentH / 2 - viewH / 2;
|
||||||
|
return { positions, generations, viewX, viewY, viewW, viewH };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPush<K, V>(map: Map<K, V[]>, key: K, value: V) {
|
||||||
|
const arr = map.get(key);
|
||||||
|
if (arr) arr.push(value);
|
||||||
|
else map.set(key, [value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeCenter(id: string): { x: number; y: number } | null {
|
||||||
|
const p = layout.positions.get(id);
|
||||||
|
if (!p) return null;
|
||||||
|
return { x: p.x + NODE_W / 2, y: p.y + NODE_H / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let focusedId = $state<string | null>(null);
|
||||||
|
|
||||||
|
function handleNodeKey(event: KeyboardEvent, id: string) {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
onSelect(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF'));
|
||||||
|
const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF'));
|
||||||
|
|
||||||
|
function pairKey(a: string, b: string): string {
|
||||||
|
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParentLinks = {
|
||||||
|
// One entry per spouse-pair-with-children: drives the drop + sibling-bar
|
||||||
|
// + per-child vertical pattern in the SVG.
|
||||||
|
shared: { key: string; parentA: string; parentB: string; childIds: string[] }[];
|
||||||
|
// One entry per remaining parent → child edge (single parents, or the
|
||||||
|
// "second" parent edge when only one parent is in the spouse pair).
|
||||||
|
single: { key: string; parentId: string; childId: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const parentLinks = $derived.by<ParentLinks>(() => {
|
||||||
|
const spousePairs = new Set<string>();
|
||||||
|
for (const e of spouseEdges) {
|
||||||
|
spousePairs.add(pairKey(e.personId, e.relatedPersonId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const childToParents = new Map<string, string[]>();
|
||||||
|
for (const e of parentEdges) {
|
||||||
|
const list = childToParents.get(e.relatedPersonId) ?? [];
|
||||||
|
list.push(e.personId);
|
||||||
|
childToParents.set(e.relatedPersonId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedMap = new Map<string, { parentA: string; parentB: string; childIds: string[] }>();
|
||||||
|
const single: ParentLinks['single'] = [];
|
||||||
|
for (const [childId, parents] of childToParents) {
|
||||||
|
const consumed = new Set<string>();
|
||||||
|
for (let i = 0; i < parents.length; i++) {
|
||||||
|
if (consumed.has(parents[i])) continue;
|
||||||
|
for (let j = i + 1; j < parents.length; j++) {
|
||||||
|
if (consumed.has(parents[j])) continue;
|
||||||
|
if (spousePairs.has(pairKey(parents[i], parents[j]))) {
|
||||||
|
const groupKey = pairKey(parents[i], parents[j]);
|
||||||
|
const existing = sharedMap.get(groupKey);
|
||||||
|
if (existing) {
|
||||||
|
existing.childIds.push(childId);
|
||||||
|
} else {
|
||||||
|
sharedMap.set(groupKey, {
|
||||||
|
parentA: parents[i],
|
||||||
|
parentB: parents[j],
|
||||||
|
childIds: [childId]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
consumed.add(parents[i]);
|
||||||
|
consumed.add(parents[j]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const parentId of parents) {
|
||||||
|
if (consumed.has(parentId)) continue;
|
||||||
|
single.push({ key: `${parentId}->${childId}`, parentId, childId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shared: ParentLinks['shared'] = [];
|
||||||
|
for (const [key, group] of sharedMap) shared.push({ key, ...group });
|
||||||
|
return { shared, single };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
viewBox={viewBox}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
role="img"
|
||||||
|
aria-label="Stammbaum"
|
||||||
|
class="block h-full w-full"
|
||||||
|
>
|
||||||
|
<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
|
||||||
|
bar, then short verticals from the bar to each child top. -->
|
||||||
|
{#each parentLinks.shared as group (group.key)}
|
||||||
|
{@const aCenter = nodeCenter(group.parentA)}
|
||||||
|
{@const bCenter = nodeCenter(group.parentB)}
|
||||||
|
{@const childCenters = group.childIds
|
||||||
|
.map((id) => nodeCenter(id))
|
||||||
|
.filter((c): c is { x: number; y: number } => c !== null)}
|
||||||
|
{#if aCenter && bCenter && childCenters.length > 0}
|
||||||
|
{@const midX = (aCenter.x + bCenter.x) / 2}
|
||||||
|
{@const parentBottomY = aCenter.y + NODE_H / 2}
|
||||||
|
{@const childTopY = childCenters[0].y - NODE_H / 2}
|
||||||
|
{@const barY = (parentBottomY + childTopY) / 2}
|
||||||
|
{@const xs = childCenters.map((c) => c.x)}
|
||||||
|
{@const minX = Math.min(midX, ...xs)}
|
||||||
|
{@const maxX = Math.max(midX, ...xs)}
|
||||||
|
<line
|
||||||
|
x1={midX}
|
||||||
|
y1={parentBottomY}
|
||||||
|
x2={midX}
|
||||||
|
y2={barY}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{#if minX !== maxX}
|
||||||
|
<line
|
||||||
|
x1={minX}
|
||||||
|
y1={barY}
|
||||||
|
x2={maxX}
|
||||||
|
y2={barY}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#each childCenters as cc, i (group.childIds[i])}
|
||||||
|
<line
|
||||||
|
x1={cc.x}
|
||||||
|
y1={barY}
|
||||||
|
x2={cc.x}
|
||||||
|
y2={childTopY}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Single-parent → child connectors: parent bottom → bar → child top. -->
|
||||||
|
{#each parentLinks.single as link (link.key)}
|
||||||
|
{@const parentCenter = nodeCenter(link.parentId)}
|
||||||
|
{@const childCenter = nodeCenter(link.childId)}
|
||||||
|
{#if parentCenter && childCenter}
|
||||||
|
{@const parentBottomY = parentCenter.y + NODE_H / 2}
|
||||||
|
{@const childTopY = childCenter.y - NODE_H / 2}
|
||||||
|
{@const barY = (parentBottomY + childTopY) / 2}
|
||||||
|
<line
|
||||||
|
x1={parentCenter.x}
|
||||||
|
y1={parentBottomY}
|
||||||
|
x2={parentCenter.x}
|
||||||
|
y2={barY}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{#if parentCenter.x !== childCenter.x}
|
||||||
|
<line
|
||||||
|
x1={parentCenter.x}
|
||||||
|
y1={barY}
|
||||||
|
x2={childCenter.x}
|
||||||
|
y2={barY}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<line
|
||||||
|
x1={childCenter.x}
|
||||||
|
y1={barY}
|
||||||
|
x2={childCenter.x}
|
||||||
|
y2={childTopY}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Spouse connectors -->
|
||||||
|
{#each spouseEdges as e (e.id)}
|
||||||
|
{@const aCenter = nodeCenter(e.personId)}
|
||||||
|
{@const bCenter = nodeCenter(e.relatedPersonId)}
|
||||||
|
{#if aCenter && bCenter}
|
||||||
|
<line
|
||||||
|
x1={aCenter.x}
|
||||||
|
y1={aCenter.y}
|
||||||
|
x2={bCenter.x}
|
||||||
|
y2={bCenter.y}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-dasharray={e.toYear ? '4 4' : undefined}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={(aCenter.x + bCenter.x) / 2}
|
||||||
|
cy={(aCenter.y + bCenter.y) / 2}
|
||||||
|
r="4.5"
|
||||||
|
fill="var(--c-primary)"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Nodes -->
|
||||||
|
{#each nodes as node (node.id)}
|
||||||
|
{@const pos = layout.positions.get(node.id)}
|
||||||
|
{#if pos}
|
||||||
|
{@const isSelected = selectedId === node.id}
|
||||||
|
{@const isFocused = focusedId === node.id}
|
||||||
|
<g
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="{node.displayName}{node.birthYear || node.deathYear
|
||||||
|
? `, ${node.birthYear ?? '?'}–${node.deathYear ?? ''}`
|
||||||
|
: ''}"
|
||||||
|
aria-expanded={isSelected}
|
||||||
|
transform="translate({pos.x}, {pos.y})"
|
||||||
|
onclick={() => onSelect(node.id)}
|
||||||
|
onkeydown={(e) => handleNodeKey(e, node.id)}
|
||||||
|
onfocus={() => (focusedId = node.id)}
|
||||||
|
onblur={() => (focusedId = null)}
|
||||||
|
class="cursor-pointer focus:outline-none"
|
||||||
|
>
|
||||||
|
{#if isFocused}
|
||||||
|
<rect
|
||||||
|
x="-3"
|
||||||
|
y="-3"
|
||||||
|
width={NODE_W + 6}
|
||||||
|
height={NODE_H + 6}
|
||||||
|
rx="6"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--c-focus-ring)"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<rect
|
||||||
|
width={NODE_W}
|
||||||
|
height={NODE_H}
|
||||||
|
rx="4"
|
||||||
|
fill={isSelected ? 'var(--c-primary)' : 'var(--c-surface)'}
|
||||||
|
stroke="var(--c-primary)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
{#if isSelected}
|
||||||
|
<rect width="4" height={NODE_H} rx="2" fill="var(--c-accent)" />
|
||||||
|
{/if}
|
||||||
|
<text
|
||||||
|
x={NODE_W / 2}
|
||||||
|
y={NODE_H / 2 - 6}
|
||||||
|
text-anchor="middle"
|
||||||
|
font-family="serif"
|
||||||
|
font-size="16"
|
||||||
|
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink)'}
|
||||||
|
>
|
||||||
|
{node.displayName}
|
||||||
|
</text>
|
||||||
|
{#if node.birthYear || node.deathYear}
|
||||||
|
<text
|
||||||
|
x={NODE_W / 2}
|
||||||
|
y={NODE_H / 2 + 12}
|
||||||
|
text-anchor="middle"
|
||||||
|
font-family="sans-serif"
|
||||||
|
font-size="12"
|
||||||
|
fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'}
|
||||||
|
opacity={isSelected ? 0.75 : 1}
|
||||||
|
>
|
||||||
|
{node.birthYear ?? '?'}–{node.deathYear ?? ''}
|
||||||
|
</text>
|
||||||
|
{/if}
|
||||||
|
</g>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
349
frontend/src/lib/components/StammbaumTree.svelte.test.ts
Normal file
349
frontend/src/lib/components/StammbaumTree.svelte.test.ts
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import StammbaumTree from './StammbaumTree.svelte';
|
||||||
|
|
||||||
|
const ID_A = '00000000-0000-0000-0000-000000000001';
|
||||||
|
const ID_B = '00000000-0000-0000-0000-000000000002';
|
||||||
|
|
||||||
|
function parseViewBox(svg: SVGElement): [number, number, number, number] {
|
||||||
|
const parts = svg.getAttribute('viewBox')!.split(/\s+/).map(Number);
|
||||||
|
return [parts[0], parts[1], parts[2], parts[3]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectsCentroid(svg: SVGElement): { x: number; y: number } {
|
||||||
|
const rects = Array.from(svg.querySelectorAll('rect'));
|
||||||
|
let sx = 0;
|
||||||
|
let sy = 0;
|
||||||
|
let n = 0;
|
||||||
|
for (const r of rects) {
|
||||||
|
const x = parseFloat(r.getAttribute('x') ?? '0');
|
||||||
|
const y = parseFloat(r.getAttribute('y') ?? '0');
|
||||||
|
const w = parseFloat(r.getAttribute('width') ?? '0');
|
||||||
|
const h = parseFloat(r.getAttribute('height') ?? '0');
|
||||||
|
// Skip the narrow accent stripe.
|
||||||
|
if (w < 10) continue;
|
||||||
|
// Each node rect lives inside <g transform="translate(...)">.
|
||||||
|
const g = r.closest('g[transform]');
|
||||||
|
const transform = g?.getAttribute('transform') ?? '';
|
||||||
|
const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
|
||||||
|
const tx = match ? parseFloat(match[1]) : 0;
|
||||||
|
const ty = match ? parseFloat(match[2]) : 0;
|
||||||
|
sx += tx + x + w / 2;
|
||||||
|
sy += ty + y + h / 2;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return { x: sx / n, y: sy / n };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('StammbaumTree viewBox', () => {
|
||||||
|
it('uses the minimum size and centers a single node', async () => {
|
||||||
|
render(StammbaumTree, {
|
||||||
|
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||||||
|
edges: [],
|
||||||
|
selectedId: null,
|
||||||
|
zoom: 1,
|
||||||
|
onSelect: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const svg = document.querySelector('svg')!;
|
||||||
|
const [x, y, w, h] = parseViewBox(svg);
|
||||||
|
|
||||||
|
// Single 160x56 node fits inside the 1200x800 minimum viewBox.
|
||||||
|
expect(w).toBe(1200);
|
||||||
|
expect(h).toBe(800);
|
||||||
|
|
||||||
|
// Whatever absolute coordinates the layout uses, the viewBox must
|
||||||
|
// centre on the rendered content.
|
||||||
|
const c = rectsCentroid(svg);
|
||||||
|
expect(x + w / 2).toBeCloseTo(c.x, 1);
|
||||||
|
expect(y + h / 2).toBeCloseTo(c.y, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders only orthogonal segments when two parents share two children', async () => {
|
||||||
|
const PARENT_A = '00000000-0000-0000-0000-00000000000a';
|
||||||
|
const PARENT_B = '00000000-0000-0000-0000-00000000000b';
|
||||||
|
const CHILD_1 = '00000000-0000-0000-0000-00000000000c';
|
||||||
|
const CHILD_2 = '00000000-0000-0000-0000-00000000000d';
|
||||||
|
render(StammbaumTree, {
|
||||||
|
nodes: [
|
||||||
|
{ id: PARENT_A, displayName: 'Walter', familyMember: true },
|
||||||
|
{ id: PARENT_B, displayName: 'Eugenie', familyMember: true },
|
||||||
|
{ id: CHILD_1, displayName: 'Clara', familyMember: true },
|
||||||
|
{ id: CHILD_2, displayName: 'Hans', familyMember: true }
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: 'sp',
|
||||||
|
personId: PARENT_A,
|
||||||
|
relatedPersonId: PARENT_B,
|
||||||
|
personDisplayName: 'Walter',
|
||||||
|
relatedPersonDisplayName: 'Eugenie',
|
||||||
|
relationType: 'SPOUSE_OF'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p1a',
|
||||||
|
personId: PARENT_A,
|
||||||
|
relatedPersonId: CHILD_1,
|
||||||
|
personDisplayName: 'Walter',
|
||||||
|
relatedPersonDisplayName: 'Clara',
|
||||||
|
relationType: 'PARENT_OF'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p1b',
|
||||||
|
personId: PARENT_B,
|
||||||
|
relatedPersonId: CHILD_1,
|
||||||
|
personDisplayName: 'Eugenie',
|
||||||
|
relatedPersonDisplayName: 'Clara',
|
||||||
|
relationType: 'PARENT_OF'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p2a',
|
||||||
|
personId: PARENT_A,
|
||||||
|
relatedPersonId: CHILD_2,
|
||||||
|
personDisplayName: 'Walter',
|
||||||
|
relatedPersonDisplayName: 'Hans',
|
||||||
|
relationType: 'PARENT_OF'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p2b',
|
||||||
|
personId: PARENT_B,
|
||||||
|
relatedPersonId: CHILD_2,
|
||||||
|
personDisplayName: 'Eugenie',
|
||||||
|
relatedPersonDisplayName: 'Hans',
|
||||||
|
relationType: 'PARENT_OF'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
selectedId: null,
|
||||||
|
zoom: 1,
|
||||||
|
onSelect: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = Array.from(document.querySelectorAll('svg line'));
|
||||||
|
// Every parent-child segment must be either vertical (x1==x2) or
|
||||||
|
// horizontal (y1==y2) — no slanted segments allowed.
|
||||||
|
const slanted = lines.filter(
|
||||||
|
(l) =>
|
||||||
|
l.getAttribute('x1') !== l.getAttribute('x2') &&
|
||||||
|
l.getAttribute('y1') !== l.getAttribute('y2')
|
||||||
|
);
|
||||||
|
expect(slanted).toHaveLength(0);
|
||||||
|
// Sibling bar must exist and span the children: at least one
|
||||||
|
// horizontal line whose x1 != x2 and y1 == y2.
|
||||||
|
const horizontalBars = lines.filter(
|
||||||
|
(l) =>
|
||||||
|
l.getAttribute('y1') === l.getAttribute('y2') &&
|
||||||
|
l.getAttribute('x1') !== l.getAttribute('x2')
|
||||||
|
);
|
||||||
|
expect(horizontalBars.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('positions a single child at the midpoint of its two parents (vertical drop)', async () => {
|
||||||
|
const PARENT_A = '00000000-0000-0000-0000-00000000000a';
|
||||||
|
const PARENT_B = '00000000-0000-0000-0000-00000000000b';
|
||||||
|
const CHILD = '00000000-0000-0000-0000-00000000000c';
|
||||||
|
render(StammbaumTree, {
|
||||||
|
nodes: [
|
||||||
|
{ id: PARENT_A, displayName: 'Walter', familyMember: true },
|
||||||
|
{ id: PARENT_B, displayName: 'Eugenie', familyMember: true },
|
||||||
|
{ id: CHILD, displayName: 'Hans', familyMember: true }
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: 'sp',
|
||||||
|
personId: PARENT_A,
|
||||||
|
relatedPersonId: PARENT_B,
|
||||||
|
personDisplayName: 'Walter',
|
||||||
|
relatedPersonDisplayName: 'Eugenie',
|
||||||
|
relationType: 'SPOUSE_OF'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
personId: PARENT_A,
|
||||||
|
relatedPersonId: CHILD,
|
||||||
|
personDisplayName: 'Walter',
|
||||||
|
relatedPersonDisplayName: 'Hans',
|
||||||
|
relationType: 'PARENT_OF'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p2',
|
||||||
|
personId: PARENT_B,
|
||||||
|
relatedPersonId: CHILD,
|
||||||
|
personDisplayName: 'Eugenie',
|
||||||
|
relatedPersonDisplayName: 'Hans',
|
||||||
|
relationType: 'PARENT_OF'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
selectedId: null,
|
||||||
|
zoom: 1,
|
||||||
|
onSelect: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = Array.from(document.querySelectorAll('svg line'));
|
||||||
|
// No slanted segments. With one child, no horizontal sibling bar
|
||||||
|
// is needed because midX == child.center.x.
|
||||||
|
const slanted = lines.filter(
|
||||||
|
(l) =>
|
||||||
|
l.getAttribute('x1') !== l.getAttribute('x2') &&
|
||||||
|
l.getAttribute('y1') !== l.getAttribute('y2')
|
||||||
|
);
|
||||||
|
expect(slanted).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places a loose spouse adjacent to their partner and demotes their child a generation', async () => {
|
||||||
|
// Walter ↔ Eugenie (gen 0); their children Hans + Clara (gen 1).
|
||||||
|
// Hans ↔ Hilde (Hilde has no parents in graph). Hans + Hilde have
|
||||||
|
// child Lili. Hilde must sit next to Hans, and Lili must be on a
|
||||||
|
// row below Hans/Hilde — not on the same row.
|
||||||
|
const WALTER = '00000000-0000-0000-0000-000000000001';
|
||||||
|
const EUGENIE = '00000000-0000-0000-0000-000000000002';
|
||||||
|
const HANS = '00000000-0000-0000-0000-000000000003';
|
||||||
|
const CLARA = '00000000-0000-0000-0000-000000000004';
|
||||||
|
const HILDE = '00000000-0000-0000-0000-000000000005';
|
||||||
|
const LILI = '00000000-0000-0000-0000-000000000006';
|
||||||
|
|
||||||
|
render(StammbaumTree, {
|
||||||
|
nodes: [
|
||||||
|
{ id: WALTER, displayName: 'Walter', familyMember: true },
|
||||||
|
{ id: EUGENIE, displayName: 'Eugenie', familyMember: true },
|
||||||
|
{ id: HANS, displayName: 'Hans', familyMember: true },
|
||||||
|
{ id: CLARA, displayName: 'Clara', familyMember: true },
|
||||||
|
{ id: HILDE, displayName: 'Hilde', familyMember: true },
|
||||||
|
{ id: LILI, displayName: 'Lili', familyMember: true }
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: 's1',
|
||||||
|
personId: WALTER,
|
||||||
|
relatedPersonId: EUGENIE,
|
||||||
|
personDisplayName: 'Walter',
|
||||||
|
relatedPersonDisplayName: 'Eugenie',
|
||||||
|
relationType: 'SPOUSE_OF'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
personId: WALTER,
|
||||||
|
relatedPersonId: HANS,
|
||||||
|
personDisplayName: 'Walter',
|
||||||
|
relatedPersonDisplayName: 'Hans',
|
||||||
|
relationType: 'PARENT_OF'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p2',
|
||||||
|
personId: EUGENIE,
|
||||||
|
relatedPersonId: HANS,
|
||||||
|
personDisplayName: 'Eugenie',
|
||||||
|
relatedPersonDisplayName: 'Hans',
|
||||||
|
relationType: 'PARENT_OF'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p3',
|
||||||
|
personId: WALTER,
|
||||||
|
relatedPersonId: CLARA,
|
||||||
|
personDisplayName: 'Walter',
|
||||||
|
relatedPersonDisplayName: 'Clara',
|
||||||
|
relationType: 'PARENT_OF'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p4',
|
||||||
|
personId: EUGENIE,
|
||||||
|
relatedPersonId: CLARA,
|
||||||
|
personDisplayName: 'Eugenie',
|
||||||
|
relatedPersonDisplayName: 'Clara',
|
||||||
|
relationType: 'PARENT_OF'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 's2',
|
||||||
|
personId: HANS,
|
||||||
|
relatedPersonId: HILDE,
|
||||||
|
personDisplayName: 'Hans',
|
||||||
|
relatedPersonDisplayName: 'Hilde',
|
||||||
|
relationType: 'SPOUSE_OF'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p5',
|
||||||
|
personId: HANS,
|
||||||
|
relatedPersonId: LILI,
|
||||||
|
personDisplayName: 'Hans',
|
||||||
|
relatedPersonDisplayName: 'Lili',
|
||||||
|
relationType: 'PARENT_OF'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p6',
|
||||||
|
personId: HILDE,
|
||||||
|
relatedPersonId: LILI,
|
||||||
|
personDisplayName: 'Hilde',
|
||||||
|
relatedPersonDisplayName: 'Lili',
|
||||||
|
relationType: 'PARENT_OF'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
selectedId: null,
|
||||||
|
zoom: 1,
|
||||||
|
onSelect: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ys = new Map<string, number>();
|
||||||
|
for (const g of Array.from(document.querySelectorAll('g[transform]'))) {
|
||||||
|
const aria = g.getAttribute('aria-label') ?? '';
|
||||||
|
const transform = g.getAttribute('transform') ?? '';
|
||||||
|
const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
|
||||||
|
if (!match) continue;
|
||||||
|
ys.set(aria.split(',')[0], parseFloat(match[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lili must be on a deeper row than Hans / Hilde.
|
||||||
|
expect(ys.get('Lili')).toBeGreaterThan(ys.get('Hans')!);
|
||||||
|
expect(ys.get('Hans')).toEqual(ys.get('Hilde'));
|
||||||
|
|
||||||
|
// Hans and Hilde must be horizontally adjacent (|Δx| == NODE_W + COL_GAP).
|
||||||
|
const xs = new Map<string, number>();
|
||||||
|
for (const g of Array.from(document.querySelectorAll('g[transform]'))) {
|
||||||
|
const aria = g.getAttribute('aria-label') ?? '';
|
||||||
|
const transform = g.getAttribute('transform') ?? '';
|
||||||
|
const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
|
||||||
|
if (!match) continue;
|
||||||
|
xs.set(aria.split(',')[0], parseFloat(match[1]));
|
||||||
|
}
|
||||||
|
expect(Math.abs(xs.get('Hans')! - xs.get('Hilde')!)).toBe(160 + 40);
|
||||||
|
|
||||||
|
// All parent-child segments must be orthogonal.
|
||||||
|
const lines = Array.from(document.querySelectorAll('svg line'));
|
||||||
|
const slanted = lines.filter(
|
||||||
|
(l) =>
|
||||||
|
l.getAttribute('x1') !== l.getAttribute('x2') &&
|
||||||
|
l.getAttribute('y1') !== l.getAttribute('y2')
|
||||||
|
);
|
||||||
|
expect(slanted).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('centers two spouse nodes within the minimum viewBox', async () => {
|
||||||
|
render(StammbaumTree, {
|
||||||
|
nodes: [
|
||||||
|
{ id: ID_A, displayName: 'Anna', familyMember: true },
|
||||||
|
{ id: ID_B, displayName: 'Bertha', familyMember: true }
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: 'e1',
|
||||||
|
personId: ID_A,
|
||||||
|
relatedPersonId: ID_B,
|
||||||
|
personDisplayName: 'Anna',
|
||||||
|
relatedPersonDisplayName: 'Bertha',
|
||||||
|
relationType: 'SPOUSE_OF'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
selectedId: null,
|
||||||
|
zoom: 1,
|
||||||
|
onSelect: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const svg = document.querySelector('svg')!;
|
||||||
|
const [x, y, w, h] = parseViewBox(svg);
|
||||||
|
|
||||||
|
expect(w).toBe(1200);
|
||||||
|
expect(h).toBe(800);
|
||||||
|
|
||||||
|
const c = rectsCentroid(svg);
|
||||||
|
expect(x + w / 2).toBeCloseTo(c.x, 1);
|
||||||
|
expect(y + h / 2).toBeCloseTo(c.y, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
import CommentThread from './CommentThread.svelte';
|
import CommentThread from './CommentThread.svelte';
|
||||||
|
import PersonMentionEditor from './PersonMentionEditor.svelte';
|
||||||
|
import type { PersonMention } from '$lib/types';
|
||||||
|
|
||||||
const { confirm } = getConfirmService();
|
const { confirm } = getConfirmService();
|
||||||
|
|
||||||
@@ -12,13 +14,14 @@ type Props = {
|
|||||||
documentId: string;
|
documentId: string;
|
||||||
blockNumber: number;
|
blockNumber: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
mentionedPersons: PersonMention[];
|
||||||
label: string | null;
|
label: string | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
reviewed: boolean;
|
reviewed: boolean;
|
||||||
saveState: SaveState;
|
saveState: SaveState;
|
||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
onTextChange: (text: string) => void;
|
onTextChange: (text: string, mentionedPersons: PersonMention[]) => void;
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
onDeleteClick: () => void;
|
onDeleteClick: () => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
@@ -35,6 +38,7 @@ let {
|
|||||||
documentId,
|
documentId,
|
||||||
blockNumber,
|
blockNumber,
|
||||||
text,
|
text,
|
||||||
|
mentionedPersons,
|
||||||
label = null,
|
label = null,
|
||||||
active,
|
active,
|
||||||
reviewed,
|
reviewed,
|
||||||
@@ -54,10 +58,10 @@ let {
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let localText = $state(text);
|
let localText = $state(text);
|
||||||
|
let localMentions = $state<PersonMention[]>([...mentionedPersons]);
|
||||||
let commentOpen = $state(false);
|
let commentOpen = $state(false);
|
||||||
let commentCount = $state(0);
|
let commentCount = $state(0);
|
||||||
let selectedQuote = $state<string | null>(null);
|
let selectedQuote = $state<string | null>(null);
|
||||||
let textareaEl = $state<HTMLTextAreaElement | null>(null);
|
|
||||||
|
|
||||||
const hasComments = $derived(commentCount > 0);
|
const hasComments = $derived(commentCount > 0);
|
||||||
|
|
||||||
@@ -66,6 +70,7 @@ let prevBlockId = $state(blockId);
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (blockId !== prevBlockId) {
|
if (blockId !== prevBlockId) {
|
||||||
localText = text;
|
localText = text;
|
||||||
|
localMentions = [...mentionedPersons];
|
||||||
prevBlockId = blockId;
|
prevBlockId = blockId;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -74,29 +79,8 @@ let leftBorderClass = $derived(
|
|||||||
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : ''
|
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : ''
|
||||||
);
|
);
|
||||||
|
|
||||||
function autoresize(node: HTMLTextAreaElement) {
|
function emitChange() {
|
||||||
textareaEl = node;
|
onTextChange(localText, localMentions);
|
||||||
function resize() {
|
|
||||||
node.style.height = 'auto';
|
|
||||||
node.style.height = `${node.scrollHeight}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
resize();
|
|
||||||
|
|
||||||
return {
|
|
||||||
update() {
|
|
||||||
resize();
|
|
||||||
},
|
|
||||||
destroy() {
|
|
||||||
textareaEl = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInput(event: Event) {
|
|
||||||
const target = event.target as HTMLTextAreaElement;
|
|
||||||
localText = target.value;
|
|
||||||
onTextChange(target.value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
@@ -106,17 +90,6 @@ async function handleDelete() {
|
|||||||
});
|
});
|
||||||
if (confirmed) onDeleteClick();
|
if (confirmed) onDeleteClick();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTextareaMouseUp() {
|
|
||||||
if (!textareaEl) return;
|
|
||||||
const start = textareaEl.selectionStart;
|
|
||||||
const end = textareaEl.selectionEnd;
|
|
||||||
if (start !== end) {
|
|
||||||
selectedQuote = localText.substring(start, end);
|
|
||||||
} else {
|
|
||||||
selectedQuote = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -181,17 +154,22 @@ function handleTextareaMouseUp() {
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Textarea -->
|
<!-- Editor powered by PersonMentionEditor (Tiptap) for @-mention typeahead -->
|
||||||
<textarea
|
<PersonMentionEditor
|
||||||
use:autoresize={localText}
|
bind:value={() => localText,
|
||||||
class="w-full resize-none border-none bg-transparent font-serif text-base leading-relaxed text-ink outline-none placeholder:text-ink-3"
|
(v) => {
|
||||||
|
localText = v;
|
||||||
|
emitChange();
|
||||||
|
}}
|
||||||
|
bind:mentionedPersons={() => localMentions,
|
||||||
|
(next) => {
|
||||||
|
localMentions = next;
|
||||||
|
emitChange();
|
||||||
|
}}
|
||||||
placeholder={m.transcription_block_placeholder()}
|
placeholder={m.transcription_block_placeholder()}
|
||||||
rows={1}
|
|
||||||
value={localText}
|
|
||||||
oninput={handleInput}
|
|
||||||
onfocus={onFocus}
|
onfocus={onFocus}
|
||||||
onmouseup={handleTextareaMouseUp}
|
onSelectionChange={(text) => (selectedQuote = text)}
|
||||||
></textarea>
|
/>
|
||||||
|
|
||||||
{#if selectedQuote}
|
{#if selectedQuote}
|
||||||
<p class="mt-1 text-xs text-ink-3">{m.transcription_block_quote_hint()}</p>
|
<p class="mt-1 text-xs text-ink-3">{m.transcription_block_quote_hint()}</p>
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ describe('TranscriptionBlock — rendering', () => {
|
|||||||
|
|
||||||
it('renders text in textarea', async () => {
|
it('renders text in textarea', async () => {
|
||||||
renderBlock();
|
renderBlock();
|
||||||
const textarea = page.getByRole('textbox');
|
await expect.element(page.getByText('Liebe Mutter,')).toBeInTheDocument();
|
||||||
await expect.element(textarea).toHaveValue('Liebe Mutter,');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders optional label when provided', async () => {
|
it('renders optional label when provided', async () => {
|
||||||
@@ -226,14 +225,18 @@ describe('TranscriptionBlock — delete confirmation', () => {
|
|||||||
// ─── Quote selection ─────────────────────────────────────────────────────────
|
// ─── Quote selection ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('TranscriptionBlock — quote selection', () => {
|
describe('TranscriptionBlock — quote selection', () => {
|
||||||
it('shows quote hint after text is selected in textarea', async () => {
|
it('shows quote hint after text is selected in the editor', async () => {
|
||||||
renderBlock({ text: 'Breslau, den 12. August' });
|
renderBlock({ text: 'Breslau, den 12. August' });
|
||||||
await page.getByRole('textbox').click();
|
await page.getByRole('textbox').click();
|
||||||
// Select text and fire mouseup via native DOM — locator.selectText/dispatchEvent not available
|
// Select all text in the contenteditable via the native Selection API.
|
||||||
const el = document.querySelector('textarea') as HTMLTextAreaElement;
|
// Tiptap fires selectionUpdate which the block forwards as onSelectionChange.
|
||||||
el.focus();
|
const editorEl = document.querySelector('[role="textbox"]') as HTMLElement;
|
||||||
el.setSelectionRange(0, el.value.length);
|
const range = document.createRange();
|
||||||
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
range.selectNodeContents(editorEl);
|
||||||
|
const selection = window.getSelection()!;
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
editorEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
await expect.element(page.getByText(/Zitat/)).toBeInTheDocument();
|
await expect.element(page.getByText(/Zitat/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { provideConfirmService, type ConfirmService } from '$lib/services/confirm.svelte.js';
|
import { provideConfirmService, type ConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||||
|
import type { PersonMention } from '$lib/types';
|
||||||
|
|
||||||
type BlockProps = {
|
type BlockProps = {
|
||||||
blockId: string;
|
blockId: string;
|
||||||
documentId: string;
|
documentId: string;
|
||||||
blockNumber: number;
|
blockNumber: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
mentionedPersons?: PersonMention[];
|
||||||
label: string | null;
|
label: string | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
saveState: 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
saveState: 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
onTextChange: (text: string) => void;
|
onTextChange: (text: string, mentionedPersons: PersonMention[]) => void;
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
onDeleteClick: () => void;
|
onDeleteClick: () => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
|
onReviewToggle?: () => void;
|
||||||
onMoveUp?: () => void;
|
onMoveUp?: () => void;
|
||||||
onMoveDown?: () => void;
|
onMoveDown?: () => void;
|
||||||
isFirst?: boolean;
|
isFirst?: boolean;
|
||||||
@@ -24,13 +27,22 @@ type BlockProps = {
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
onServiceReady,
|
onServiceReady,
|
||||||
|
mentionedPersons = [],
|
||||||
|
reviewed = false,
|
||||||
|
onReviewToggle = () => {},
|
||||||
...blockProps
|
...blockProps
|
||||||
}: BlockProps & {
|
}: BlockProps & {
|
||||||
onServiceReady: (s: ConfirmService) => void;
|
onServiceReady: (s: ConfirmService) => void;
|
||||||
|
reviewed?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const service = provideConfirmService();
|
const service = provideConfirmService();
|
||||||
onServiceReady(service);
|
onServiceReady(service);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<TranscriptionBlock {...blockProps} />
|
<TranscriptionBlock
|
||||||
|
{...blockProps}
|
||||||
|
mentionedPersons={mentionedPersons}
|
||||||
|
reviewed={reviewed}
|
||||||
|
onReviewToggle={onReviewToggle}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function blockProgress(doc: TranscriptionQueueItemDTO): number {
|
|||||||
{#each docs as doc (doc.id)}
|
{#each docs as doc (doc.id)}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/documents/{doc.id}"
|
href="/documents/{doc.id}?task=transcribe"
|
||||||
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
<span class="font-serif text-sm text-ink">{doc.title}</span>
|
<span class="font-serif text-sm text-ink">{doc.title}</span>
|
||||||
|
|||||||
@@ -74,12 +74,12 @@ describe('TranscriptionColumn', () => {
|
|||||||
expect(dashEl?.textContent?.trim()).toBe('—');
|
expect(dashEl?.textContent?.trim()).toBe('—');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('links to /documents/{id}', async () => {
|
it('links to /documents/{id}?task=transcribe', async () => {
|
||||||
const doc = makeDoc({ id: 'xyz-456', title: 'Transkriptions Dokument' });
|
const doc = makeDoc({ id: 'xyz-456', title: 'Transkriptions Dokument' });
|
||||||
|
|
||||||
render(TranscriptionColumn, { props: { docs: [doc], weeklyCount: 0 } });
|
render(TranscriptionColumn, { props: { docs: [doc], weeklyCount: 0 } });
|
||||||
|
|
||||||
const link = page.getByRole('link', { name: /Transkriptions Dokument/ });
|
const link = page.getByRole('link', { name: /Transkriptions Dokument/ });
|
||||||
await expect.element(link).toHaveAttribute('href', '/documents/xyz-456');
|
await expect.element(link).toHaveAttribute('href', '/documents/xyz-456?task=transcribe');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||||
import OcrTrigger from './OcrTrigger.svelte';
|
import OcrTrigger from './OcrTrigger.svelte';
|
||||||
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
|
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
|
||||||
import type { TranscriptionBlockData } from '$lib/types';
|
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
|
||||||
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
|
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
|
||||||
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
|
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ type Props = {
|
|||||||
storedScriptType?: string;
|
storedScriptType?: string;
|
||||||
canRunOcr?: boolean;
|
canRunOcr?: boolean;
|
||||||
onBlockFocus: (blockId: string) => void;
|
onBlockFocus: (blockId: string) => void;
|
||||||
onSaveBlock: (blockId: string, text: string) => Promise<void>;
|
onSaveBlock: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>;
|
||||||
onDeleteBlock: (blockId: string) => Promise<void>;
|
onDeleteBlock: (blockId: string) => Promise<void>;
|
||||||
onReviewToggle: (blockId: string) => Promise<void>;
|
onReviewToggle: (blockId: string) => Promise<void>;
|
||||||
onMarkAllReviewed?: () => Promise<void>;
|
onMarkAllReviewed?: () => Promise<void>;
|
||||||
@@ -245,16 +245,19 @@ async function handleLabelToggle(label: string) {
|
|||||||
documentId={documentId}
|
documentId={documentId}
|
||||||
blockNumber={i + 1}
|
blockNumber={i + 1}
|
||||||
text={block.text}
|
text={block.text}
|
||||||
|
mentionedPersons={block.mentionedPersons ?? []}
|
||||||
label={block.label}
|
label={block.label}
|
||||||
active={activeBlockId === block.id}
|
active={activeBlockId === block.id}
|
||||||
reviewed={block.reviewed ?? false}
|
reviewed={block.reviewed ?? false}
|
||||||
saveState={autoSave.getSaveState(block.id)}
|
saveState={autoSave.getSaveState(block.id)}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
onTextChange={(text) => autoSave.handleTextChange(block.id, text)}
|
onTextChange={(text, mentions) =>
|
||||||
|
autoSave.handleTextChange(block.id, text, mentions)}
|
||||||
onFocus={() => handleFocus(block.id)}
|
onFocus={() => handleFocus(block.id)}
|
||||||
onDeleteClick={() => handleDelete(block.id)}
|
onDeleteClick={() => handleDelete(block.id)}
|
||||||
onRetry={() => autoSave.handleRetry(block.id, block.text)}
|
onRetry={() =>
|
||||||
|
autoSave.handleRetry(block.id, block.text, block.mentionedPersons ?? [])}
|
||||||
onReviewToggle={() => onReviewToggle(block.id)}
|
onReviewToggle={() => onReviewToggle(block.id)}
|
||||||
onMoveUp={() => handleMoveUp(block.id)}
|
onMoveUp={() => handleMoveUp(block.id)}
|
||||||
onMoveDown={() => handleMoveDown(block.id)}
|
onMoveDown={() => handleMoveDown(block.id)}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ const block1 = {
|
|||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
version: 0,
|
version: 0,
|
||||||
source: 'MANUAL' as const,
|
source: 'MANUAL' as const,
|
||||||
reviewed: false
|
reviewed: false,
|
||||||
|
mentionedPersons: []
|
||||||
};
|
};
|
||||||
const block2 = {
|
const block2 = {
|
||||||
id: 'b2',
|
id: 'b2',
|
||||||
@@ -26,7 +27,8 @@ const block2 = {
|
|||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
version: 0,
|
version: 0,
|
||||||
source: 'OCR' as const,
|
source: 'OCR' as const,
|
||||||
reviewed: true
|
reviewed: true,
|
||||||
|
mentionedPersons: []
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderView(overrides: Record<string, unknown> = {}, service = createConfirmService()) {
|
function renderView(overrides: Record<string, unknown> = {}, service = createConfirmService()) {
|
||||||
@@ -141,7 +143,28 @@ describe('TranscriptionEditView — auto-save debounce', () => {
|
|||||||
vi.advanceTimersByTime(1500);
|
vi.advanceTimersByTime(1500);
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile');
|
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile', []);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the block mentionedPersons array as the 3rd save argument', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const blockWithMention = {
|
||||||
|
...block1,
|
||||||
|
mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
|
||||||
|
};
|
||||||
|
renderView({ blocks: [blockWithMention], onSaveBlock });
|
||||||
|
|
||||||
|
const textarea = page.getByRole('textbox').first();
|
||||||
|
await textarea.fill('Hallo @Auguste Raddatz');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [
|
||||||
|
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
|
||||||
|
]);
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,7 +188,7 @@ describe('TranscriptionEditView — auto-save debounce', () => {
|
|||||||
|
|
||||||
// Only one save with the final value
|
// Only one save with the final value
|
||||||
expect(onSaveBlock).toHaveBeenCalledTimes(1);
|
expect(onSaveBlock).toHaveBeenCalledTimes(1);
|
||||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second');
|
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second', []);
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -220,7 +243,7 @@ describe('TranscriptionEditView — flush on blur', () => {
|
|||||||
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
|
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
|
||||||
|
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text');
|
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text', []);
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { TranscriptionBlockData } from '$lib/types';
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
import { splitByMarkers } from '$lib/utils/transcriptionMarkers';
|
import { splitByMarkers } from '$lib/utils/transcriptionMarkers';
|
||||||
|
import {
|
||||||
|
renderTranscriptionBody,
|
||||||
|
type SafeHtml,
|
||||||
|
PERSON_MENTION_SELECTOR
|
||||||
|
} from '$lib/utils/mention';
|
||||||
|
import { computeHoverCardPosition } from '$lib/utils/hoverCardPosition';
|
||||||
|
import PersonHoverCard from './PersonHoverCard.svelte';
|
||||||
|
import type { HoverData, LoadState } from '$lib/types/personHoverCard';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
type Person = components['schemas']['Person'];
|
||||||
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
blocks: TranscriptionBlockData[];
|
blocks: TranscriptionBlockData[];
|
||||||
@@ -11,9 +25,205 @@ interface Props {
|
|||||||
let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();
|
let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();
|
||||||
|
|
||||||
let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||||
|
|
||||||
|
// Per-component (per-mount) in-memory cache: a sweep across 20 mentions of the
|
||||||
|
// same person must not fire 20 backend calls (B15.5). The Promise<HoverData | null>
|
||||||
|
// shape lets simultaneous hovers share the same in-flight fetch.
|
||||||
|
//
|
||||||
|
// Trade-off: closing and re-opening the transcription panel rebuilds this cache
|
||||||
|
// (Elicit OQ-372-02). That's intentional — staleness from another tab deleting
|
||||||
|
// a person is rare in this read-only view, and a per-document/global cache would
|
||||||
|
// complicate invalidation. If user reports on stale cards accumulate, revisit.
|
||||||
|
const hoverCache = new SvelteMap<string, Promise<HoverData | null>>();
|
||||||
|
const deletedPersonIds = new SvelteSet<string>();
|
||||||
|
|
||||||
|
let activeCard: {
|
||||||
|
personId: string;
|
||||||
|
cardId: string;
|
||||||
|
state: LoadState;
|
||||||
|
position: { top: number; left: number };
|
||||||
|
} | null = $state(null);
|
||||||
|
|
||||||
|
// Compose splitByMarkers with renderTranscriptionBody. Markers are pre-rendered
|
||||||
|
// as <em data-marker> tags; text segments run through HTML-escaping + mention
|
||||||
|
// substitution. The two are concatenated to preserve marker boundaries — markers
|
||||||
|
// never end up nested inside an anchor (Felix #5324 B19b).
|
||||||
|
function renderBlockHtml(block: TranscriptionBlockData): SafeHtml {
|
||||||
|
return splitByMarkers(block.text)
|
||||||
|
.map((segment) => {
|
||||||
|
if (segment.type === 'marker') {
|
||||||
|
// splitByMarkers only emits the literal markers [unleserlich] and [...],
|
||||||
|
// no user input — safe to embed directly. Wrap in SafeHtml to satisfy
|
||||||
|
// the brand contract.
|
||||||
|
return `<em data-marker class="text-ink-2 italic">${segment.text}</em>` as SafeHtml;
|
||||||
|
}
|
||||||
|
return renderTranscriptionBody(segment.text, block.mentionedPersons ?? []);
|
||||||
|
})
|
||||||
|
.join('') as SafeHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches person + relationships from the backend. 404 returns null
|
||||||
|
* (deleted person — caller marks the link as tombstoned). Any other
|
||||||
|
* non-OK response throws so the caller can render the error state.
|
||||||
|
*/
|
||||||
|
async function loadHoverData(personId: string): Promise<HoverData | null> {
|
||||||
|
const personRes = await fetch(`/api/persons/${personId}`);
|
||||||
|
if (personRes.status === 404) return null;
|
||||||
|
if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`);
|
||||||
|
const person = (await personRes.json()) as Person;
|
||||||
|
|
||||||
|
const relRes = await fetch(`/api/persons/${personId}/relationships`);
|
||||||
|
const relationships: RelationshipDTO[] = relRes.ok
|
||||||
|
? ((await relRes.json()) as RelationshipDTO[])
|
||||||
|
: [];
|
||||||
|
return { person, relationships };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache wrapper around `loadHoverData` — first hover fires the fetch, all
|
||||||
|
* subsequent hovers (and concurrent in-flight ones) share the same Promise. */
|
||||||
|
function getOrFetchHoverData(personId: string): Promise<HoverData | null> {
|
||||||
|
const cached = hoverCache.get(personId);
|
||||||
|
if (cached) return cached;
|
||||||
|
const promise = loadHoverData(personId);
|
||||||
|
hoverCache.set(personId, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentViewport() {
|
||||||
|
return {
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
viewportHeight: window.innerHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let closeTimer = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
function scheduleCardClose() {
|
||||||
|
// 150ms: long enough for pointer movement from mention to card, short enough
|
||||||
|
// to feel responsive. Matches the Radix/shadcn hover card delay.
|
||||||
|
closeTimer = setTimeout(() => {
|
||||||
|
activeCard = null;
|
||||||
|
closeTimer = null;
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelCardClose() {
|
||||||
|
if (closeTimer !== null) {
|
||||||
|
clearTimeout(closeTimer);
|
||||||
|
closeTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMentionEnter(event: Event) {
|
||||||
|
cancelCardClose();
|
||||||
|
const link = event.target as HTMLAnchorElement;
|
||||||
|
const personId = link.dataset.personId;
|
||||||
|
if (!personId) return;
|
||||||
|
if (deletedPersonIds.has(personId)) return;
|
||||||
|
|
||||||
|
const cardId = `person-hover-card-${personId}`;
|
||||||
|
link.setAttribute('aria-describedby', cardId);
|
||||||
|
|
||||||
|
const rect = link.getBoundingClientRect();
|
||||||
|
const position = computeHoverCardPosition(rect, currentViewport());
|
||||||
|
|
||||||
|
activeCard = { personId, cardId, position, state: { status: 'loading' } };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getOrFetchHoverData(personId);
|
||||||
|
// Bail if a different mention is now active
|
||||||
|
if (!activeCard || activeCard.personId !== personId) return;
|
||||||
|
|
||||||
|
if (data === null) {
|
||||||
|
deletedPersonIds.add(personId);
|
||||||
|
link.setAttribute('data-person-deleted', 'true');
|
||||||
|
activeCard = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeCard = {
|
||||||
|
personId,
|
||||||
|
cardId,
|
||||||
|
position,
|
||||||
|
state: { status: 'loaded', person: data.person, relationships: data.relationships }
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
if (!activeCard || activeCard.personId !== personId) return;
|
||||||
|
activeCard = { personId, cardId, position, state: { status: 'error' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleMentionLeave(event: Event) {
|
||||||
|
const link = event.target as HTMLAnchorElement;
|
||||||
|
link.removeAttribute('aria-describedby');
|
||||||
|
scheduleCardClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modified clicks (ctrl/meta/shift/alt) and middle-clicks must fall through to
|
||||||
|
* the browser's default anchor behaviour so users can open the person page in
|
||||||
|
* a new tab/window. Felix #7. Only the plain primary-button click navigates
|
||||||
|
* via SPA goto().
|
||||||
|
*/
|
||||||
|
function isPlainPrimaryClick(event: MouseEvent): boolean {
|
||||||
|
return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMentionClick(event: MouseEvent) {
|
||||||
|
if (!isPlainPrimaryClick(event)) return;
|
||||||
|
const link = event.target as HTMLAnchorElement;
|
||||||
|
const personId = link.dataset.personId;
|
||||||
|
if (!personId) return;
|
||||||
|
if (deletedPersonIds.has(personId)) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
await goto(`/persons/${personId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach delegated event listeners on each rendered block. Using {@html ...}
|
||||||
|
// for the body means we cannot bind events declaratively to the injected
|
||||||
|
// anchors, so we hook up listeners via a Svelte action when the wrapper mounts.
|
||||||
|
//
|
||||||
|
// Keyboard parity (Leonie FINDING-01, WCAG 2.1.1): focusin/focusout mirror
|
||||||
|
// mouseenter/mouseleave so users tabbing through transcribed text get the
|
||||||
|
// same preview affordance.
|
||||||
|
function attachMentionHandlers(node: HTMLElement) {
|
||||||
|
function onEnter(e: Event) {
|
||||||
|
const t = e.target as HTMLElement;
|
||||||
|
if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionEnter(e);
|
||||||
|
}
|
||||||
|
function onLeave(e: Event) {
|
||||||
|
const t = e.target as HTMLElement;
|
||||||
|
if (t.matches?.(PERSON_MENTION_SELECTOR)) scheduleMentionLeave(e);
|
||||||
|
}
|
||||||
|
function onClick(e: MouseEvent) {
|
||||||
|
const t = e.target as HTMLElement;
|
||||||
|
if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionClick(e);
|
||||||
|
}
|
||||||
|
// mouseenter does not bubble — capture it.
|
||||||
|
node.addEventListener('mouseenter', onEnter, true);
|
||||||
|
node.addEventListener('mouseleave', onLeave, true);
|
||||||
|
// focusin/focusout do bubble — no capture phase needed.
|
||||||
|
node.addEventListener('focusin', onEnter);
|
||||||
|
node.addEventListener('focusout', onLeave);
|
||||||
|
node.addEventListener('click', onClick);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener('mouseenter', onEnter, true);
|
||||||
|
node.removeEventListener('mouseleave', onLeave, true);
|
||||||
|
node.removeEventListener('focusin', onEnter);
|
||||||
|
node.removeEventListener('focusout', onLeave);
|
||||||
|
node.removeEventListener('click', onClick);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article class="px-6 py-8">
|
<article class="px-6 py-8" use:attachMentionHandlers>
|
||||||
{#each sorted as block (block.id)}
|
{#each sorted as block (block.id)}
|
||||||
<div
|
<div
|
||||||
class="-mx-2 mb-6 cursor-pointer rounded-sm px-2 py-1 font-serif text-[16px] leading-[1.85] text-ink transition-colors hover:bg-turquoise/10"
|
class="-mx-2 mb-6 cursor-pointer rounded-sm px-2 py-1 font-serif text-[16px] leading-[1.85] text-ink transition-colors hover:bg-turquoise/10"
|
||||||
@@ -22,19 +232,27 @@ let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
|||||||
onclick={() => onParagraphClick(block.annotationId)}
|
onclick={() => onParagraphClick(block.annotationId)}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId); }}
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#each splitByMarkers(block.text) as segment, i (i)}
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderTranscriptionBody escapes all HTML before injecting mention links; mirrors CommentMessage.svelte -->
|
||||||
{#if segment.type === 'marker'}
|
{@html renderBlockHtml(block)}
|
||||||
<em data-marker class="text-ink-2 italic">{segment.text}</em>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
{#if activeCard}
|
||||||
|
<PersonHoverCard
|
||||||
|
personId={activeCard.personId}
|
||||||
|
cardId={activeCard.cardId}
|
||||||
|
position={activeCard.position}
|
||||||
|
state={activeCard.state}
|
||||||
|
onmouseenter={cancelCardClose}
|
||||||
|
onmouseleave={() => { activeCard = null; }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@keyframes flash {
|
@keyframes flash {
|
||||||
0% {
|
0% {
|
||||||
|
|||||||
170
frontend/src/lib/components/TranscriptionReadView.svelte.spec.ts
Normal file
170
frontend/src/lib/components/TranscriptionReadView.svelte.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import TranscriptionReadView from './TranscriptionReadView.svelte';
|
||||||
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
|
|
||||||
|
const PERSON_ID = '11111111-0000-0000-0000-000000000001';
|
||||||
|
|
||||||
|
const block: TranscriptionBlockData = {
|
||||||
|
id: 'b1',
|
||||||
|
annotationId: 'a1',
|
||||||
|
documentId: 'd1',
|
||||||
|
text: '@Auguste',
|
||||||
|
label: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
version: 1,
|
||||||
|
source: 'MANUAL',
|
||||||
|
reviewed: false,
|
||||||
|
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockPersonFetch() {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockImplementation((url: string) => {
|
||||||
|
if (url.includes('/relationships')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: PERSON_ID,
|
||||||
|
firstName: 'Auguste',
|
||||||
|
lastName: 'Raddatz',
|
||||||
|
displayName: 'Auguste Raddatz'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMentionLink(): HTMLAnchorElement {
|
||||||
|
return document.querySelector(
|
||||||
|
`a.person-mention[data-person-id="${PERSON_ID}"]`
|
||||||
|
) as HTMLAnchorElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHoverCard(): HTMLElement | null {
|
||||||
|
return document.querySelector('[data-testid="person-hover-card"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hover a mention and wait until the loaded card content is in the DOM. */
|
||||||
|
async function showCard(): Promise<void> {
|
||||||
|
getMentionLink().dispatchEvent(new MouseEvent('mouseenter', { bubbles: false }));
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Mouse timer behavior ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('TranscriptionReadView — hover card mouse timer', () => {
|
||||||
|
it('keeps the card open when mouse moves from mention to card within 150ms', async () => {
|
||||||
|
mockPersonFetch();
|
||||||
|
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
|
||||||
|
|
||||||
|
await showCard();
|
||||||
|
|
||||||
|
// Leave mention — starts 150ms close timer
|
||||||
|
getMentionLink().dispatchEvent(new MouseEvent('mouseleave', { bubbles: false }));
|
||||||
|
|
||||||
|
// Enter card before 150ms — cancels timer
|
||||||
|
getHoverCard()!.dispatchEvent(new MouseEvent('mouseenter'));
|
||||||
|
|
||||||
|
// Wait past the original 150ms window
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
|
||||||
|
expect(getHoverCard()).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the card immediately when mouse leaves the card (no timer)', async () => {
|
||||||
|
mockPersonFetch();
|
||||||
|
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
|
||||||
|
|
||||||
|
await showCard();
|
||||||
|
|
||||||
|
// Leave card — activeCard = null immediately, no timer
|
||||||
|
getHoverCard()!.dispatchEvent(new MouseEvent('mouseleave'));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getHoverCard()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels a pending close when mouse re-enters a mention', async () => {
|
||||||
|
mockPersonFetch();
|
||||||
|
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
|
||||||
|
|
||||||
|
await showCard();
|
||||||
|
|
||||||
|
// Leave mention — starts 150ms close timer
|
||||||
|
getMentionLink().dispatchEvent(new MouseEvent('mouseleave', { bubbles: false }));
|
||||||
|
|
||||||
|
// Re-enter same mention before 150ms — cancels timer
|
||||||
|
getMentionLink().dispatchEvent(new MouseEvent('mouseenter', { bubbles: false }));
|
||||||
|
|
||||||
|
// Wait past the original 150ms window
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
|
||||||
|
expect(getHoverCard()).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Keyboard focus behavior ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('TranscriptionReadView — hover card keyboard focus', () => {
|
||||||
|
it('keeps the card open when keyboard focus moves from mention into card', async () => {
|
||||||
|
mockPersonFetch();
|
||||||
|
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
|
||||||
|
|
||||||
|
// Show card via keyboard focusin on mention
|
||||||
|
getMentionLink().dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus leaves mention — starts 150ms close timer
|
||||||
|
getMentionLink().dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
||||||
|
|
||||||
|
// Focus enters card — should cancel the close timer
|
||||||
|
getHoverCard()!.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
|
||||||
|
// Wait past the 150ms window
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
|
||||||
|
expect(getHoverCard()).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the card when keyboard focus leaves the card entirely', async () => {
|
||||||
|
mockPersonFetch();
|
||||||
|
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
|
||||||
|
|
||||||
|
// Show card via keyboard focusin
|
||||||
|
getMentionLink().dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus leaves mention — 150ms timer starts
|
||||||
|
getMentionLink().dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
||||||
|
|
||||||
|
// Focus enters card — cancels timer
|
||||||
|
getHoverCard()!.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
|
||||||
|
// Focus leaves card entirely (relatedTarget = null means focus left the page)
|
||||||
|
getHoverCard()!.dispatchEvent(
|
||||||
|
new FocusEvent('focusout', { bubbles: true, relatedTarget: null })
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getHoverCard()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import TranscriptionReadView from './TranscriptionReadView.svelte';
|
import TranscriptionReadView from './TranscriptionReadView.svelte';
|
||||||
import type { TranscriptionBlockData } from '$lib/types';
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
@@ -12,7 +12,10 @@ const blocks: TranscriptionBlockData[] = [
|
|||||||
text: 'First paragraph text.',
|
text: 'First paragraph text.',
|
||||||
label: null,
|
label: null,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
version: 1
|
version: 1,
|
||||||
|
source: 'MANUAL',
|
||||||
|
reviewed: false,
|
||||||
|
mentionedPersons: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'b2',
|
id: 'b2',
|
||||||
@@ -21,7 +24,10 @@ const blocks: TranscriptionBlockData[] = [
|
|||||||
text: 'Second paragraph text.',
|
text: 'Second paragraph text.',
|
||||||
label: null,
|
label: null,
|
||||||
sortOrder: 2,
|
sortOrder: 2,
|
||||||
version: 1
|
version: 1,
|
||||||
|
source: 'MANUAL',
|
||||||
|
reviewed: false,
|
||||||
|
mentionedPersons: []
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -49,7 +55,10 @@ describe('TranscriptionReadView', () => {
|
|||||||
text: 'Text before [unleserlich] text after',
|
text: 'Text before [unleserlich] text after',
|
||||||
label: null,
|
label: null,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
version: 1
|
version: 1,
|
||||||
|
source: 'MANUAL',
|
||||||
|
reviewed: false,
|
||||||
|
mentionedPersons: []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
onParagraphClick: () => {}
|
onParagraphClick: () => {}
|
||||||
@@ -71,7 +80,10 @@ describe('TranscriptionReadView', () => {
|
|||||||
text: 'Some [...] text',
|
text: 'Some [...] text',
|
||||||
label: null,
|
label: null,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
version: 1
|
version: 1,
|
||||||
|
source: 'MANUAL',
|
||||||
|
reviewed: false,
|
||||||
|
mentionedPersons: []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
onParagraphClick: () => {}
|
onParagraphClick: () => {}
|
||||||
@@ -140,3 +152,333 @@ describe('TranscriptionReadView', () => {
|
|||||||
expect(paragraphs.length).toBe(0);
|
expect(paragraphs.length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TranscriptionReadView — person-mention rendering', () => {
|
||||||
|
const PERSON_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
|
||||||
|
const mentionBlock: TranscriptionBlockData = {
|
||||||
|
id: 'b1',
|
||||||
|
annotationId: 'ann-1',
|
||||||
|
documentId: 'doc-1',
|
||||||
|
text: 'Brief an @Auguste Raddatz vom Mai',
|
||||||
|
label: null,
|
||||||
|
sortOrder: 1,
|
||||||
|
version: 1,
|
||||||
|
source: 'MANUAL',
|
||||||
|
reviewed: false,
|
||||||
|
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste Raddatz' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Default: any /api/persons/{id} call returns 404 unless a test overrides it.
|
||||||
|
// Tests that need loaded data stub fetch themselves.
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a person mention as an anchor link with the person URL', async () => {
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [mentionBlock],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector(`a.person-mention[data-person-id="${PERSON_ID}"]`)!;
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link.getAttribute('href')).toBe(`/persons/${PERSON_ID}`);
|
||||||
|
expect(link.textContent).toBe('Auguste Raddatz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips the @ trigger from the rendered link text (read mode)', async () => {
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [mentionBlock],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const block = document.querySelector('[data-block-id="b1"]')!;
|
||||||
|
expect(block.textContent).not.toContain('@Auguste Raddatz');
|
||||||
|
expect(block.textContent).toContain('Auguste Raddatz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders mention link AND [unleserlich] marker correctly when both occur in the same block (B19b)', async () => {
|
||||||
|
const block: TranscriptionBlockData = {
|
||||||
|
...mentionBlock,
|
||||||
|
text: 'Hallo @Auguste Raddatz [unleserlich] Marie'
|
||||||
|
};
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [block],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mention rendered as an anchor
|
||||||
|
const link = document.querySelector('a.person-mention')!;
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link.textContent).toBe('Auguste Raddatz');
|
||||||
|
|
||||||
|
// Marker rendered as <em data-marker>
|
||||||
|
const marker = document.querySelector('[data-marker]')!;
|
||||||
|
expect(marker).not.toBeNull();
|
||||||
|
expect(marker.textContent).toBe('[unleserlich]');
|
||||||
|
|
||||||
|
// Marker text is NOT inside the anchor — they are siblings, not nested
|
||||||
|
expect(link.contains(marker)).toBe(false);
|
||||||
|
|
||||||
|
// No double-escape — text content reads cleanly
|
||||||
|
const blockEl = document.querySelector('[data-block-id="b1"]')!;
|
||||||
|
expect(blockEl.textContent).not.toContain('&');
|
||||||
|
expect(blockEl.textContent).not.toContain('<');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render mention link for plain text without the @ trigger', async () => {
|
||||||
|
const plain: TranscriptionBlockData = {
|
||||||
|
...mentionBlock,
|
||||||
|
text: 'Auguste Raddatz war hier',
|
||||||
|
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste Raddatz' }]
|
||||||
|
};
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [plain],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a.person-mention');
|
||||||
|
expect(link).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes HTML in the block text — no stored XSS via raw text', async () => {
|
||||||
|
const xss: TranscriptionBlockData = {
|
||||||
|
...mentionBlock,
|
||||||
|
text: '<img src=x onerror=alert(1)>',
|
||||||
|
mentionedPersons: []
|
||||||
|
};
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [xss],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// No raw <img> tag in DOM
|
||||||
|
expect(document.querySelector('[data-block-id="b1"] img')).toBeNull();
|
||||||
|
// The escaped text is visible
|
||||||
|
const block = document.querySelector('[data-block-id="b1"]')!;
|
||||||
|
expect(block.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers fetch for the person on mention mouseenter (B15.5 cache, single call)', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
status: 404,
|
||||||
|
ok: false,
|
||||||
|
json: vi.fn()
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [mentionBlock],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a.person-mention')!;
|
||||||
|
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const personFetches = fetchMock.mock.calls.filter((c) =>
|
||||||
|
String(c[0]).includes(`/api/persons/${PERSON_ID}`)
|
||||||
|
);
|
||||||
|
expect(personFetches.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates fetches for the same personId across multiple mouseenter events (B15.5)', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
status: 404,
|
||||||
|
ok: false,
|
||||||
|
json: vi.fn()
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
// Two blocks both mention the same person
|
||||||
|
const block2: TranscriptionBlockData = { ...mentionBlock, id: 'b2', annotationId: 'ann-2' };
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [mentionBlock, block2],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const links = document.querySelectorAll('a.person-mention');
|
||||||
|
links.forEach((link) => link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })));
|
||||||
|
// Plus a re-hover on the first
|
||||||
|
links[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const personFetches = fetchMock.mock.calls.filter(
|
||||||
|
(c) => String(c[0]) === `/api/persons/${PERSON_ID}`
|
||||||
|
);
|
||||||
|
expect(personFetches.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mounts the hover card on mouseenter when the fetch loads', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockImplementation((url: string) => {
|
||||||
|
if (url.endsWith('/relationships')) {
|
||||||
|
return Promise.resolve({ status: 200, ok: true, json: () => Promise.resolve([]) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: PERSON_ID,
|
||||||
|
firstName: 'Auguste',
|
||||||
|
lastName: 'Raddatz',
|
||||||
|
displayName: 'Auguste Raddatz',
|
||||||
|
personType: 'PERSON',
|
||||||
|
familyMember: true,
|
||||||
|
birthYear: 1882,
|
||||||
|
deathYear: 1944
|
||||||
|
})
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [mentionBlock],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a.person-mention')!;
|
||||||
|
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||||
|
expect(card).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unmounts the hover card on mouseleave', async () => {
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [mentionBlock],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a.person-mention')!;
|
||||||
|
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||||
|
link.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||||
|
expect(card).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mounts the hover card on focusin so keyboard users see the preview (WCAG 2.1.1)', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockImplementation((url: string) => {
|
||||||
|
if (String(url).endsWith('/relationships')) {
|
||||||
|
return Promise.resolve({ status: 200, ok: true, json: () => Promise.resolve([]) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: PERSON_ID,
|
||||||
|
firstName: 'Auguste',
|
||||||
|
lastName: 'Raddatz',
|
||||||
|
displayName: 'Auguste Raddatz',
|
||||||
|
personType: 'PERSON',
|
||||||
|
familyMember: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [mentionBlock],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a.person-mention')!;
|
||||||
|
link.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||||
|
expect(card).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unmounts the hover card on focusout', async () => {
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [mentionBlock],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a.person-mention')!;
|
||||||
|
link.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
// the card mounts even in 404 → loading → null path; assert it cleans up on blur
|
||||||
|
});
|
||||||
|
link.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||||
|
expect(card).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets ctrl-click and meta-click fall through so users can open in a new tab', async () => {
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [mentionBlock],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a.person-mention')! as HTMLAnchorElement;
|
||||||
|
|
||||||
|
// ctrl-click (Linux/Win "open in new tab")
|
||||||
|
const ctrlClick = new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true });
|
||||||
|
const ctrlPrevented = !link.dispatchEvent(ctrlClick);
|
||||||
|
expect(ctrlPrevented).toBe(false);
|
||||||
|
|
||||||
|
// meta-click (macOS "open in new tab")
|
||||||
|
const metaClick = new MouseEvent('click', { bubbles: true, cancelable: true, metaKey: true });
|
||||||
|
const metaPrevented = !link.dispatchEvent(metaClick);
|
||||||
|
expect(metaPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets middle-click fall through so users can open in a background tab', async () => {
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [mentionBlock],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a.person-mention')! as HTMLAnchorElement;
|
||||||
|
// button === 1 is middle mouse button
|
||||||
|
const middleClick = new MouseEvent('click', { bubbles: true, cancelable: true, button: 1 });
|
||||||
|
const prevented = !link.dispatchEvent(middleClick);
|
||||||
|
expect(prevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades to plain unlinked text when the person fetch returns 404', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
|
||||||
|
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [mentionBlock],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = document.querySelector('a.person-mention')!;
|
||||||
|
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
// Anchor is marked as deleted so subsequent hovers/clicks treat it as plain text
|
||||||
|
const stillLink = document.querySelector('a.person-mention')!;
|
||||||
|
expect(stillLink.getAttribute('data-person-deleted')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 404 → no card mounted
|
||||||
|
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||||
|
expect(card).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export type ErrorCode =
|
|||||||
| 'TAG_NOT_FOUND'
|
| 'TAG_NOT_FOUND'
|
||||||
| 'TAG_MERGE_SELF'
|
| 'TAG_MERGE_SELF'
|
||||||
| 'TAG_MERGE_INVALID_TARGET'
|
| 'TAG_MERGE_INVALID_TARGET'
|
||||||
|
| 'RELATIONSHIP_NOT_FOUND'
|
||||||
|
| 'CIRCULAR_RELATIONSHIP'
|
||||||
|
| 'DUPLICATE_RELATIONSHIP'
|
||||||
| 'MISSING_CREDENTIALS'
|
| 'MISSING_CREDENTIALS'
|
||||||
| 'UNAUTHORIZED'
|
| 'UNAUTHORIZED'
|
||||||
| 'FORBIDDEN'
|
| 'FORBIDDEN'
|
||||||
@@ -136,6 +139,12 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_tag_merge_self();
|
return m.error_tag_merge_self();
|
||||||
case 'TAG_MERGE_INVALID_TARGET':
|
case 'TAG_MERGE_INVALID_TARGET':
|
||||||
return m.error_tag_merge_invalid_target();
|
return m.error_tag_merge_invalid_target();
|
||||||
|
case 'RELATIONSHIP_NOT_FOUND':
|
||||||
|
return m.error_relationship_not_found();
|
||||||
|
case 'CIRCULAR_RELATIONSHIP':
|
||||||
|
return m.error_circular_relationship();
|
||||||
|
case 'DUPLICATE_RELATIONSHIP':
|
||||||
|
return m.error_duplicate_relationship();
|
||||||
case 'MISSING_CREDENTIALS':
|
case 'MISSING_CREDENTIALS':
|
||||||
return m.login_error_missing_credentials();
|
return m.login_error_missing_credentials();
|
||||||
case 'UNAUTHORIZED':
|
case 'UNAUTHORIZED':
|
||||||
|
|||||||
@@ -132,6 +132,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/documents/{documentId}/transcription-blocks/review-all": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put: operations["markAllBlocksReviewed"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/documents/{documentId}/transcription-blocks/reorder": {
|
"/api/documents/{documentId}/transcription-blocks/reorder": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -212,6 +228,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/persons/{id}/relationships": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getRelationships"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["addRelationship"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/persons/{id}/merge": {
|
"/api/persons/{id}/merge": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -612,6 +644,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/persons/{id}/family-member": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch: operations["patchFamilyMember"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/notifications/{id}/read": {
|
"/api/notifications/{id}/read": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -852,6 +900,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/persons/{id}/inferred-relationships": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getInferredRelationships"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/persons/{id}/documents": {
|
"/api/persons/{id}/documents": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -884,6 +948,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/persons/{aId}/relationship-to/{bId}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getRelationshipBetween"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/ocr/training-info": {
|
"/api/ocr/training-info": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1044,6 +1124,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/network": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getNetwork"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/documents/{id}/versions": {
|
"/api/documents/{id}/versions": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1332,6 +1428,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?: 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;
|
||||||
@@ -1430,6 +1542,8 @@ export interface components {
|
|||||||
color?: string;
|
color?: string;
|
||||||
};
|
};
|
||||||
PersonUpdateDTO: {
|
PersonUpdateDTO: {
|
||||||
|
/** @enum {string} */
|
||||||
|
personType: "PERSON" | "INSTITUTION" | "GROUP" | "UNKNOWN" | "SKIP";
|
||||||
title?: string;
|
title?: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
@@ -1454,6 +1568,7 @@ export interface components {
|
|||||||
birthYear?: number;
|
birthYear?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
deathYear?: number;
|
deathYear?: number;
|
||||||
|
familyMember: boolean;
|
||||||
readonly displayName: string;
|
readonly displayName: string;
|
||||||
};
|
};
|
||||||
DocumentUpdateDTO: {
|
DocumentUpdateDTO: {
|
||||||
@@ -1462,6 +1577,8 @@ export interface components {
|
|||||||
documentDate?: string;
|
documentDate?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
documentLocation?: string;
|
documentLocation?: string;
|
||||||
|
archiveBox?: string;
|
||||||
|
archiveFolder?: string;
|
||||||
transcription?: string;
|
transcription?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1510,9 +1627,15 @@ export interface components {
|
|||||||
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
};
|
};
|
||||||
|
PersonMention: {
|
||||||
|
/** Format: uuid */
|
||||||
|
personId: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
UpdateTranscriptionBlockDTO: {
|
UpdateTranscriptionBlockDTO: {
|
||||||
text?: string;
|
text?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
mentionedPersons?: components["schemas"]["PersonMention"][];
|
||||||
};
|
};
|
||||||
TranscriptionBlock: {
|
TranscriptionBlock: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1522,6 +1645,7 @@ export interface components {
|
|||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
documentId: string;
|
documentId: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
mentionedPersons: components["schemas"]["PersonMention"][];
|
||||||
label?: string;
|
label?: string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
@@ -1561,6 +1685,42 @@ 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;
|
||||||
@@ -1660,6 +1820,7 @@ export interface components {
|
|||||||
height?: number;
|
height?: number;
|
||||||
text?: string;
|
text?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
mentionedPersons?: components["schemas"]["PersonMention"][];
|
||||||
};
|
};
|
||||||
CreateCommentDTO: {
|
CreateCommentDTO: {
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -1808,6 +1969,9 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
|
FamilyMemberPatchDTO: {
|
||||||
|
familyMember?: boolean;
|
||||||
|
};
|
||||||
NotificationDTO: {
|
NotificationDTO: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1912,15 +2076,38 @@ export interface components {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
/** Format: int64 */
|
personType?: string;
|
||||||
documentCount?: number;
|
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
birthYear?: number;
|
birthYear?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
deathYear?: number;
|
deathYear?: number;
|
||||||
alias?: string;
|
familyMember?: boolean;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
personType?: string;
|
/** Format: int64 */
|
||||||
|
documentCount?: number;
|
||||||
|
alias?: string;
|
||||||
|
};
|
||||||
|
InferredRelationshipWithPersonDTO: {
|
||||||
|
person: components["schemas"]["PersonNodeDTO"];
|
||||||
|
label: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
hops: number;
|
||||||
|
};
|
||||||
|
PersonNodeDTO: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
birthYear?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
deathYear?: number;
|
||||||
|
familyMember: boolean;
|
||||||
|
};
|
||||||
|
InferredRelationshipDTO: {
|
||||||
|
labelFromA: string;
|
||||||
|
labelFromB: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
hops: number;
|
||||||
};
|
};
|
||||||
SenderModel: {
|
SenderModel: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -2021,6 +2208,10 @@ export interface components {
|
|||||||
empty?: boolean;
|
empty?: boolean;
|
||||||
unsorted?: boolean;
|
unsorted?: boolean;
|
||||||
};
|
};
|
||||||
|
NetworkDTO: {
|
||||||
|
nodes: components["schemas"]["PersonNodeDTO"][];
|
||||||
|
edges: components["schemas"]["RelationshipDTO"][];
|
||||||
|
};
|
||||||
DocumentVersionSummary: {
|
DocumentVersionSummary: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -2131,7 +2322,7 @@ export interface components {
|
|||||||
};
|
};
|
||||||
ActivityFeedItemDTO: {
|
ActivityFeedItemDTO: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED";
|
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED";
|
||||||
actor?: components["schemas"]["ActivityActorDTO"];
|
actor?: components["schemas"]["ActivityActorDTO"];
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -2581,6 +2772,28 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
markAllBlocksReviewed: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["TranscriptionBlock"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
reorderBlocks: {
|
reorderBlocks: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2745,6 +2958,54 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getRelationships: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["RelationshipDTO"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
addRelationship: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateRelationshipRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["RelationshipDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
mergePerson: {
|
mergePerson: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3491,6 +3752,32 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
patchFamilyMember: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["FamilyMemberPatchDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["Person"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
markOneRead: {
|
markOneRead: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3889,6 +4176,28 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getInferredRelationships: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["InferredRelationshipWithPersonDTO"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
getPersonDocuments: {
|
getPersonDocuments: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3935,6 +4244,29 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getRelationshipBetween: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
aId: string;
|
||||||
|
bId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["InferredRelationshipDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
getTrainingInfo: {
|
getTrainingInfo: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4150,6 +4482,26 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getNetwork: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["NetworkDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
getVersions: {
|
getVersions: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4470,7 +4822,7 @@ export interface operations {
|
|||||||
query?: {
|
query?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
|
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
|
||||||
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED")[];
|
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED")[];
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -4571,6 +4923,27 @@ 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;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||||
|
import type { PersonMention } from '$lib/types';
|
||||||
|
|
||||||
const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise<void>>();
|
const mockSaveFn =
|
||||||
|
vi.fn<(blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>>();
|
||||||
|
|
||||||
|
const NO_MENTIONS: PersonMention[] = [];
|
||||||
|
|
||||||
const { createBlockAutoSave } = await import('../useBlockAutoSave.svelte');
|
const { createBlockAutoSave } = await import('../useBlockAutoSave.svelte');
|
||||||
|
|
||||||
@@ -22,25 +26,25 @@ describe('createBlockAutoSave', () => {
|
|||||||
|
|
||||||
it('debounce coalesces multiple changes — saves once after 1500ms', async () => {
|
it('debounce coalesces multiple changes — saves once after 1500ms', async () => {
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'text 1');
|
as.handleTextChange('block-1', 'text 1', NO_MENTIONS);
|
||||||
as.handleTextChange('block-1', 'text 2');
|
as.handleTextChange('block-1', 'text 2', NO_MENTIONS);
|
||||||
as.handleTextChange('block-1', 'text 3');
|
as.handleTextChange('block-1', 'text 3', NO_MENTIONS);
|
||||||
await vi.advanceTimersByTimeAsync(1500);
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
expect(mockSaveFn).toHaveBeenCalledTimes(1);
|
expect(mockSaveFn).toHaveBeenCalledTimes(1);
|
||||||
expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3');
|
expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3', NO_MENTIONS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles concurrent blocks independently', async () => {
|
it('handles concurrent blocks independently', async () => {
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'hello');
|
as.handleTextChange('block-1', 'hello', NO_MENTIONS);
|
||||||
as.handleTextChange('block-2', 'world');
|
as.handleTextChange('block-2', 'world', NO_MENTIONS);
|
||||||
await vi.advanceTimersByTimeAsync(1500);
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
expect(mockSaveFn).toHaveBeenCalledTimes(2);
|
expect(mockSaveFn).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets save state to saving then saved on success', async () => {
|
it('sets save state to saving then saved on success', async () => {
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'text');
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||||
vi.advanceTimersByTime(1500);
|
vi.advanceTimersByTime(1500);
|
||||||
expect(as.getSaveState('block-1')).toBe('saving');
|
expect(as.getSaveState('block-1')).toBe('saving');
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
@@ -50,7 +54,7 @@ describe('createBlockAutoSave', () => {
|
|||||||
it('sets save state to error on save failure', async () => {
|
it('sets save state to error on save failure', async () => {
|
||||||
mockSaveFn.mockRejectedValue(new Error('save failed'));
|
mockSaveFn.mockRejectedValue(new Error('save failed'));
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'text');
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||||
await vi.advanceTimersByTimeAsync(1500);
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
expect(as.getSaveState('block-1')).toBe('error');
|
expect(as.getSaveState('block-1')).toBe('error');
|
||||||
});
|
});
|
||||||
@@ -59,24 +63,49 @@ describe('createBlockAutoSave', () => {
|
|||||||
mockSaveFn.mockRejectedValueOnce(new Error('first fails'));
|
mockSaveFn.mockRejectedValueOnce(new Error('first fails'));
|
||||||
mockSaveFn.mockResolvedValueOnce(undefined);
|
mockSaveFn.mockResolvedValueOnce(undefined);
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'original');
|
as.handleTextChange('block-1', 'original', NO_MENTIONS);
|
||||||
await vi.advanceTimersByTimeAsync(1500);
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
expect(as.getSaveState('block-1')).toBe('error');
|
expect(as.getSaveState('block-1')).toBe('error');
|
||||||
await as.handleRetry('block-1', 'original');
|
await as.handleRetry('block-1', 'original', NO_MENTIONS);
|
||||||
expect(mockSaveFn).toHaveBeenCalledTimes(2);
|
expect(mockSaveFn).toHaveBeenCalledTimes(2);
|
||||||
expect(as.getSaveState('block-1')).toBe('saved');
|
expect(as.getSaveState('block-1')).toBe('saved');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves the in-flight text + mentionedPersons across a save failure (B12)', async () => {
|
||||||
|
// Hold the second saveFn so we can observe the saving→saved transition
|
||||||
|
// (Tester #5506 §5).
|
||||||
|
let resolveSecond!: () => void;
|
||||||
|
mockSaveFn.mockRejectedValueOnce(new Error('boom'));
|
||||||
|
mockSaveFn.mockReturnValueOnce(new Promise<void>((r) => (resolveSecond = r)));
|
||||||
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
|
|
||||||
|
const mentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }];
|
||||||
|
as.handleTextChange('block-1', '@Auguste Raddatz hi', mentions);
|
||||||
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
|
expect(as.getSaveState('block-1')).toBe('error');
|
||||||
|
|
||||||
|
// Retry without re-passing the data — the hook resends the preserved payload.
|
||||||
|
const retryPromise = as.handleRetry('block-1', 'should-not-be-used', []);
|
||||||
|
// Yield once so executeSave runs synchronously up to the saveFn await.
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(as.getSaveState('block-1')).toBe('saving');
|
||||||
|
expect(mockSaveFn).toHaveBeenLastCalledWith('block-1', '@Auguste Raddatz hi', mentions);
|
||||||
|
|
||||||
|
resolveSecond();
|
||||||
|
await retryPromise;
|
||||||
|
expect(as.getSaveState('block-1')).toBe('saved');
|
||||||
|
});
|
||||||
|
|
||||||
it('clearBlock removes all state for a block', () => {
|
it('clearBlock removes all state for a block', () => {
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'text');
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||||
as.clearBlock('block-1');
|
as.clearBlock('block-1');
|
||||||
expect(as.getSaveState('block-1')).toBe('idle');
|
expect(as.getSaveState('block-1')).toBe('idle');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('destroy clears all pending timers so no save occurs', async () => {
|
it('destroy clears all pending timers so no save occurs', async () => {
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'text');
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||||
as.destroy();
|
as.destroy();
|
||||||
await vi.advanceTimersByTimeAsync(2000);
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
expect(mockSaveFn).not.toHaveBeenCalled();
|
expect(mockSaveFn).not.toHaveBeenCalled();
|
||||||
@@ -101,8 +130,8 @@ describe('flushOnUnload', () => {
|
|||||||
|
|
||||||
it('sends a PUT request with keepalive:true for each pending block', () => {
|
it('sends a PUT request with keepalive:true for each pending block', () => {
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'hello');
|
as.handleTextChange('block-1', 'hello', NO_MENTIONS);
|
||||||
as.handleTextChange('block-2', 'world');
|
as.handleTextChange('block-2', 'world', NO_MENTIONS);
|
||||||
as.flushOnUnload();
|
as.flushOnUnload();
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
@@ -111,7 +140,7 @@ describe('flushOnUnload', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
body: JSON.stringify({ text: 'hello' })
|
body: JSON.stringify({ text: 'hello', mentionedPersons: [] })
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
@@ -119,7 +148,7 @@ describe('flushOnUnload', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
body: JSON.stringify({ text: 'world' })
|
body: JSON.stringify({ text: 'world', mentionedPersons: [] })
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -127,7 +156,7 @@ describe('flushOnUnload', () => {
|
|||||||
it('does not call navigator.sendBeacon', () => {
|
it('does not call navigator.sendBeacon', () => {
|
||||||
const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
|
const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'text');
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||||
as.flushOnUnload();
|
as.flushOnUnload();
|
||||||
|
|
||||||
expect(sendBeaconSpy).not.toHaveBeenCalled();
|
expect(sendBeaconSpy).not.toHaveBeenCalled();
|
||||||
@@ -142,7 +171,7 @@ describe('flushOnUnload', () => {
|
|||||||
|
|
||||||
it('cancels the debounce timer so saveFn is not also called', async () => {
|
it('cancels the debounce timer so saveFn is not also called', async () => {
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'text');
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||||
as.flushOnUnload();
|
as.flushOnUnload();
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(2000);
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
@@ -151,13 +180,26 @@ describe('flushOnUnload', () => {
|
|||||||
|
|
||||||
it('does not send fetch if debounce already fired and pendingTexts is empty', async () => {
|
it('does not send fetch if debounce already fired and pendingTexts is empty', async () => {
|
||||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
as.handleTextChange('block-1', 'text');
|
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||||
await vi.advanceTimersByTimeAsync(1500);
|
await vi.advanceTimersByTimeAsync(1500);
|
||||||
// debounce has fired; pendingTexts should be empty now
|
|
||||||
mockFetch.mockClear();
|
mockFetch.mockClear();
|
||||||
|
|
||||||
as.flushOnUnload();
|
as.flushOnUnload();
|
||||||
|
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('flushes the pending mentionedPersons sidecar alongside text', () => {
|
||||||
|
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||||
|
const mentions: PersonMention[] = [{ personId: 'p-1', displayName: 'Auguste Raddatz' }];
|
||||||
|
as.handleTextChange('block-1', '@Auguste Raddatz', mentions);
|
||||||
|
as.flushOnUnload();
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'/api/documents/doc-1/transcription-blocks/block-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
body: JSON.stringify({ text: '@Auguste Raddatz', mentionedPersons: mentions })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
version: 1,
|
version: 1,
|
||||||
source: 'MANUAL',
|
source: 'MANUAL',
|
||||||
reviewed: false
|
reviewed: false,
|
||||||
|
mentionedPersons: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
import type { PersonMention } from '$lib/types';
|
||||||
|
|
||||||
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
saveFn: (blockId: string, text: string) => Promise<void>;
|
saveFn: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>;
|
||||||
documentId: string;
|
documentId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
|||||||
const saveStates = new SvelteMap<string, SaveState>();
|
const saveStates = new SvelteMap<string, SaveState>();
|
||||||
const debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
|
const debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
|
||||||
const pendingTexts = new SvelteMap<string, string>();
|
const pendingTexts = new SvelteMap<string, string>();
|
||||||
|
const pendingMentions = new SvelteMap<string, PersonMention[]>();
|
||||||
const fadeTimers: ReturnType<typeof setTimeout>[] = [];
|
const fadeTimers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
|
||||||
function getSaveState(blockId: string): SaveState {
|
function getSaveState(blockId: string): SaveState {
|
||||||
@@ -25,14 +27,19 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
|||||||
const text = pendingTexts.get(blockId);
|
const text = pendingTexts.get(blockId);
|
||||||
if (text === undefined) return;
|
if (text === undefined) return;
|
||||||
|
|
||||||
|
const mentions = pendingMentions.get(blockId) ?? [];
|
||||||
pendingTexts.delete(blockId);
|
pendingTexts.delete(blockId);
|
||||||
|
pendingMentions.delete(blockId);
|
||||||
setSaveState(blockId, 'saving');
|
setSaveState(blockId, 'saving');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await saveFn(blockId, text);
|
await saveFn(blockId, text, mentions);
|
||||||
setSaveState(blockId, 'saved');
|
setSaveState(blockId, 'saved');
|
||||||
scheduleSavedFade(blockId);
|
scheduleSavedFade(blockId);
|
||||||
} catch {
|
} catch {
|
||||||
|
// Preserve in-flight payload so the user can retry without re-typing.
|
||||||
|
pendingTexts.set(blockId, text);
|
||||||
|
pendingMentions.set(blockId, mentions);
|
||||||
setSaveState(blockId, 'error');
|
setSaveState(blockId, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,8 +76,13 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTextChange(blockId: string, text: string): void {
|
function handleTextChange(
|
||||||
|
blockId: string,
|
||||||
|
text: string,
|
||||||
|
mentionedPersons: PersonMention[]
|
||||||
|
): void {
|
||||||
pendingTexts.set(blockId, text);
|
pendingTexts.set(blockId, text);
|
||||||
|
pendingMentions.set(blockId, mentionedPersons);
|
||||||
scheduleDebounce(blockId);
|
scheduleDebounce(blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,29 +93,37 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRetry(blockId: string, currentText: string): Promise<void> {
|
async function handleRetry(
|
||||||
const pending = pendingTexts.get(blockId);
|
blockId: string,
|
||||||
const text = pending ?? currentText;
|
currentText: string,
|
||||||
|
currentMentions: PersonMention[]
|
||||||
|
): Promise<void> {
|
||||||
|
const text = pendingTexts.get(blockId) ?? currentText;
|
||||||
|
const mentions = pendingMentions.get(blockId) ?? currentMentions;
|
||||||
pendingTexts.set(blockId, text);
|
pendingTexts.set(blockId, text);
|
||||||
|
pendingMentions.set(blockId, mentions);
|
||||||
await executeSave(blockId);
|
await executeSave(blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearBlock(blockId: string): void {
|
function clearBlock(blockId: string): void {
|
||||||
clearDebounce(blockId);
|
clearDebounce(blockId);
|
||||||
pendingTexts.delete(blockId);
|
pendingTexts.delete(blockId);
|
||||||
|
pendingMentions.delete(blockId);
|
||||||
saveStates.delete(blockId);
|
saveStates.delete(blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushOnUnload(): void {
|
function flushOnUnload(): void {
|
||||||
for (const [blockId, text] of pendingTexts) {
|
for (const [blockId, text] of pendingTexts) {
|
||||||
|
const mentions = pendingMentions.get(blockId) ?? [];
|
||||||
clearDebounce(blockId);
|
clearDebounce(blockId);
|
||||||
void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
|
void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text }),
|
body: JSON.stringify({ text, mentionedPersons: mentions }),
|
||||||
keepalive: true
|
keepalive: true
|
||||||
});
|
});
|
||||||
pendingTexts.delete(blockId);
|
pendingTexts.delete(blockId);
|
||||||
|
pendingMentions.delete(blockId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
66
frontend/src/lib/relationshipLabels.test.ts
Normal file
66
frontend/src/lib/relationshipLabels.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { chipLabel, otherName } from './relationshipLabels';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
|
||||||
|
const ALICE_ID = 'alice-uuid';
|
||||||
|
const BOB_ID = 'bob-uuid';
|
||||||
|
|
||||||
|
function makeRel(
|
||||||
|
relationType: RelationshipDTO['relationType'],
|
||||||
|
override: Partial<RelationshipDTO> = {}
|
||||||
|
): RelationshipDTO {
|
||||||
|
return {
|
||||||
|
id: 'rel-1',
|
||||||
|
personId: ALICE_ID,
|
||||||
|
relatedPersonId: BOB_ID,
|
||||||
|
personDisplayName: 'Alice',
|
||||||
|
relatedPersonDisplayName: 'Bob',
|
||||||
|
relationType,
|
||||||
|
...override
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('chipLabel', () => {
|
||||||
|
it('returns parent_of when perspective is the subject of PARENT_OF', () => {
|
||||||
|
const rel = makeRel('PARENT_OF');
|
||||||
|
expect(chipLabel(rel, ALICE_ID)).toBe(m.relation_parent_of());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns child_of when perspective is the object of PARENT_OF', () => {
|
||||||
|
const rel = makeRel('PARENT_OF');
|
||||||
|
expect(chipLabel(rel, BOB_ID)).toBe(m.relation_child_of());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns spouse_of for SPOUSE_OF regardless of perspective', () => {
|
||||||
|
const rel = makeRel('SPOUSE_OF');
|
||||||
|
expect(chipLabel(rel, ALICE_ID)).toBe(m.relation_spouse_of());
|
||||||
|
expect(chipLabel(rel, BOB_ID)).toBe(m.relation_spouse_of());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns sibling_of for SIBLING_OF', () => {
|
||||||
|
expect(chipLabel(makeRel('SIBLING_OF'), ALICE_ID)).toBe(m.relation_sibling_of());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns friend for FRIEND', () => {
|
||||||
|
expect(chipLabel(makeRel('FRIEND'), ALICE_ID)).toBe(m.relation_friend());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns other for OTHER', () => {
|
||||||
|
expect(chipLabel(makeRel('OTHER'), ALICE_ID)).toBe(m.relation_other());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('otherName', () => {
|
||||||
|
it('returns relatedPersonDisplayName when perspective is the subject', () => {
|
||||||
|
const rel = makeRel('PARENT_OF');
|
||||||
|
expect(otherName(rel, ALICE_ID)).toBe('Bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns personDisplayName when perspective is the object', () => {
|
||||||
|
const rel = makeRel('PARENT_OF');
|
||||||
|
expect(otherName(rel, BOB_ID)).toBe('Alice');
|
||||||
|
});
|
||||||
|
});
|
||||||
77
frontend/src/lib/relationshipLabels.ts
Normal file
77
frontend/src/lib/relationshipLabels.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
|
|
||||||
|
export function chipLabel(rel: RelationshipDTO, perspectivePersonId: string): string {
|
||||||
|
const viewpointIsSubject = rel.personId === perspectivePersonId;
|
||||||
|
switch (rel.relationType) {
|
||||||
|
case 'PARENT_OF':
|
||||||
|
return viewpointIsSubject ? m.relation_parent_of() : m.relation_child_of();
|
||||||
|
case 'SPOUSE_OF':
|
||||||
|
return m.relation_spouse_of();
|
||||||
|
case 'SIBLING_OF':
|
||||||
|
return m.relation_sibling_of();
|
||||||
|
case 'FRIEND':
|
||||||
|
return m.relation_friend();
|
||||||
|
case 'COLLEAGUE':
|
||||||
|
return m.relation_colleague();
|
||||||
|
case 'EMPLOYER':
|
||||||
|
return m.relation_employer();
|
||||||
|
case 'DOCTOR':
|
||||||
|
return m.relation_doctor();
|
||||||
|
case 'NEIGHBOR':
|
||||||
|
return m.relation_neighbor();
|
||||||
|
default:
|
||||||
|
return m.relation_other();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function otherName(rel: RelationshipDTO, perspectivePersonId: string): string {
|
||||||
|
return rel.personId === perspectivePersonId
|
||||||
|
? rel.relatedPersonDisplayName
|
||||||
|
: rel.personDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a backend inferred-label key (parent, uncle_aunt, ...) to its
|
||||||
|
* localised string. Unknown keys fall back to "distant".
|
||||||
|
*/
|
||||||
|
export function inferredRelationshipLabel(key: string): string {
|
||||||
|
switch (key) {
|
||||||
|
case 'parent':
|
||||||
|
return m.relation_inferred_parent();
|
||||||
|
case 'child':
|
||||||
|
return m.relation_inferred_child();
|
||||||
|
case 'spouse':
|
||||||
|
return m.relation_inferred_spouse();
|
||||||
|
case 'sibling':
|
||||||
|
return m.relation_inferred_sibling();
|
||||||
|
case 'grandparent':
|
||||||
|
return m.relation_inferred_grandparent();
|
||||||
|
case 'grandchild':
|
||||||
|
return m.relation_inferred_grandchild();
|
||||||
|
case 'great_grandparent':
|
||||||
|
return m.relation_inferred_great_grandparent();
|
||||||
|
case 'great_grandchild':
|
||||||
|
return m.relation_inferred_great_grandchild();
|
||||||
|
case 'uncle_aunt':
|
||||||
|
return m.relation_inferred_uncle_aunt();
|
||||||
|
case 'niece_nephew':
|
||||||
|
return m.relation_inferred_niece_nephew();
|
||||||
|
case 'great_uncle_aunt':
|
||||||
|
return m.relation_inferred_great_uncle_aunt();
|
||||||
|
case 'great_niece_nephew':
|
||||||
|
return m.relation_inferred_great_niece_nephew();
|
||||||
|
case 'inlaw_parent':
|
||||||
|
return m.relation_inferred_inlaw_parent();
|
||||||
|
case 'inlaw_child':
|
||||||
|
return m.relation_inferred_inlaw_child();
|
||||||
|
case 'sibling_inlaw':
|
||||||
|
return m.relation_inferred_sibling_inlaw();
|
||||||
|
case 'cousin_1':
|
||||||
|
return m.relation_inferred_cousin_1();
|
||||||
|
default:
|
||||||
|
return m.relation_inferred_distant();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user