Compare commits
100 Commits
feat/issue
...
8ca3f37817
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
3
backend/api_tests/Transcription.http
Normal file
3
backend/api_tests/Transcription.http
Normal file
@@ -0,0 +1,3 @@
|
||||
### Mark all blocks as reviewed
|
||||
PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all
|
||||
Authorization: Basic admin admin123
|
||||
@@ -6,6 +6,7 @@ import jakarta.validation.ConstraintViolationException;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
@@ -47,6 +48,12 @@ public class GlobalExceptionHandler {
|
||||
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)
|
||||
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
||||
return ResponseEntity.status(ex.getStatusCode())
|
||||
|
||||
@@ -34,11 +34,13 @@ public class PersonController {
|
||||
private final DocumentService documentService;
|
||||
|
||||
@GetMapping
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
|
||||
return ResponseEntity.ok(personService.findAll(q));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public Person getPerson(@PathVariable UUID id) {
|
||||
return personService.getById(id);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
|
||||
@@ -45,7 +46,7 @@ public class TranscriptionBlockController {
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public TranscriptionBlock createBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@RequestBody CreateTranscriptionBlockDTO dto,
|
||||
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
|
||||
Authentication authentication) {
|
||||
UUID userId = requireUserId(authentication);
|
||||
return transcriptionService.createBlock(documentId, dto, userId);
|
||||
@@ -56,7 +57,7 @@ public class TranscriptionBlockController {
|
||||
public TranscriptionBlock updateBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID blockId,
|
||||
@RequestBody UpdateTranscriptionBlockDTO dto,
|
||||
@Valid @RequestBody UpdateTranscriptionBlockDTO dto,
|
||||
Authentication authentication) {
|
||||
UUID userId = requireUserId(authentication);
|
||||
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
|
||||
@@ -90,6 +91,15 @@ public class TranscriptionBlockController {
|
||||
return transcriptionService.reviewBlock(documentId, blockId, userId);
|
||||
}
|
||||
|
||||
@PutMapping("/review-all")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public List<TranscriptionBlock> markAllBlocksReviewed(
|
||||
@PathVariable UUID documentId,
|
||||
Authentication authentication) {
|
||||
UUID userId = requireUserId(authentication);
|
||||
return transcriptionService.markAllBlocksReviewed(documentId, userId);
|
||||
}
|
||||
|
||||
@GetMapping("/{blockId}/history")
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public List<TranscriptionBlockVersion> getBlockHistory(
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.raddatz.familienarchiv.model.PersonMention;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class CreateTranscriptionBlockDTO {
|
||||
@Min(0)
|
||||
private int pageNumber;
|
||||
@@ -22,4 +29,8 @@ public class CreateTranscriptionBlockDTO {
|
||||
private double height;
|
||||
private String text;
|
||||
private String label;
|
||||
|
||||
@Valid
|
||||
@Builder.Default
|
||||
private List<PersonMention> mentionedPersons = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ public interface PersonSummaryDTO {
|
||||
Integer getBirthYear();
|
||||
Integer getDeathYear();
|
||||
String getNotes();
|
||||
boolean isFamilyMember();
|
||||
long getDocumentCount();
|
||||
|
||||
default String getDisplayName() {
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.raddatz.familienarchiv.model.PersonMention;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class UpdateTranscriptionBlockDTO {
|
||||
private String text;
|
||||
private String label;
|
||||
|
||||
@Valid
|
||||
@Builder.Default
|
||||
private List<PersonMention> mentionedPersons = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ public enum ErrorCode {
|
||||
ALIAS_NOT_FOUND,
|
||||
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
||||
INVALID_PERSON_TYPE,
|
||||
/** A concurrent edit on a referenced transcription block prevented the rename
|
||||
* from committing (optimistic-lock conflict). The whole rename rolls back; the
|
||||
* client should refetch and retry. 409 */
|
||||
PERSON_RENAME_CONFLICT,
|
||||
|
||||
// --- Documents ---
|
||||
/** A document with the given ID does not exist. 404 */
|
||||
@@ -96,6 +100,14 @@ public enum ErrorCode {
|
||||
/** Internal inconsistency: expected training run row was not found after creation. 500 */
|
||||
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 ---
|
||||
/** A tag with the given ID does not exist. 404 */
|
||||
TAG_NOT_FOUND,
|
||||
|
||||
@@ -47,6 +47,11 @@ public class Person {
|
||||
private Integer birthYear;
|
||||
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).
|
||||
// Uses entity relationship rather than cross-domain repository access, avoiding a
|
||||
// separate DB roundtrip while respecting domain boundaries.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Published by PersonService when a save changes Person.getDisplayName() — i.e.
|
||||
* any mutation to the fields that DisplayNameFormatter consumes (title,
|
||||
* firstName, lastName). Listeners on the transcription side rewrite block text
|
||||
* and sidecar entries that reference the old name.
|
||||
*
|
||||
* <p>This is the first custom application event in the codebase. The previous
|
||||
* only listener (OcrTrainingService.recoverOrphanedRuns) listens to Spring's
|
||||
* built-in ApplicationReadyEvent. Future cross-domain decoupling should follow
|
||||
* the same shape: record-typed event in model/, listener in the consuming
|
||||
* domain's service/ package, synchronous @EventListener inside the publisher's
|
||||
* transaction unless the workload genuinely needs to defer.
|
||||
*/
|
||||
public record PersonDisplayNameChangedEvent(
|
||||
UUID personId,
|
||||
String oldDisplayName,
|
||||
String newDisplayName
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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)
|
||||
private String displayName;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@@ -33,6 +35,14 @@ public class TranscriptionBlock {
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String text;
|
||||
|
||||
@ElementCollection(fetch = FetchType.LAZY)
|
||||
@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)
|
||||
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)
|
||||
List<Person> findAllByOrderByLastNameAscFirstNameAsc();
|
||||
|
||||
// Stammbaum-Knoten: alle Personen mit family_member = true.
|
||||
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||
|
||||
// Lookup by full alias string, used during ODS mass import
|
||||
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,
|
||||
p.person_type AS personType,
|
||||
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 document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||
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,
|
||||
p.person_type AS personType,
|
||||
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 document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||
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(p.alias) 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
|
||||
""",
|
||||
nativeQuery = true)
|
||||
|
||||
@@ -29,6 +29,17 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
|
||||
|
||||
Optional<TranscriptionBlock> findByAnnotationId(UUID annotationId);
|
||||
|
||||
List<TranscriptionBlock> findByMentionedPersons_PersonId(UUID personId);
|
||||
|
||||
@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);
|
||||
|
||||
int countByDocumentId(UUID documentId);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
|
||||
import org.raddatz.familienarchiv.model.PersonMention;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Transcription-domain consumer of {@link PersonDisplayNameChangedEvent}. When
|
||||
* Person.getDisplayName() flips during a rename, this listener rewrites every
|
||||
* transcription block whose sidecar references the renamed person — both the
|
||||
* literal "@OldName" inside block.text and the displayName carried in the
|
||||
* {@link PersonMention} entries.
|
||||
*
|
||||
* <p>Synchronous on purpose: the rename and the propagation must commit as one
|
||||
* transaction so a half-applied rewrite never reaches the archive. If the
|
||||
* archive grows past tens of thousands of blocks, switch to
|
||||
* {@code @TransactionalEventListener(AFTER_COMMIT) + @Async} — one annotation
|
||||
* change.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PersonMentionPropagationListener {
|
||||
|
||||
private final TranscriptionBlockRepository blockRepository;
|
||||
|
||||
@EventListener
|
||||
@Transactional // Joins publisher's transaction — async switch requires @TransactionalEventListener(AFTER_COMMIT)
|
||||
public void onPersonDisplayNameChanged(PersonDisplayNameChangedEvent event) {
|
||||
List<TranscriptionBlock> blocks =
|
||||
blockRepository.findByPersonIdWithMentionsFetched(event.personId());
|
||||
if (blocks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String oldNeedle = "@" + event.oldDisplayName();
|
||||
String newNeedle = "@" + event.newDisplayName();
|
||||
// Match @OldName only at a token boundary: not followed by a letter/digit/hyphen
|
||||
// (catches @Hans-Peter when renaming Hans) AND not followed by " <Uppercase>"
|
||||
// (catches @Hans Müller when renaming the single-name @Hans). False negatives —
|
||||
// e.g. "@Hans Bekam" where Bekam is sentence-initial — are accepted as the
|
||||
// conservative trade-off; the alternative (corruption) is irrecoverable.
|
||||
Pattern boundary = Pattern.compile(
|
||||
Pattern.quote(oldNeedle) + "(?![\\p{L}0-9\\-]| (?=\\p{Lu}))");
|
||||
String replacement = Matcher.quoteReplacement(newNeedle);
|
||||
|
||||
for (TranscriptionBlock block : blocks) {
|
||||
if (block.getText() != null) {
|
||||
block.setText(boundary.matcher(block.getText()).replaceAll(replacement));
|
||||
}
|
||||
for (PersonMention mention : block.getMentionedPersons()) {
|
||||
if (mention.getPersonId().equals(event.personId())) {
|
||||
mention.setDisplayName(event.newDisplayName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blockRepository.saveAllAndFlush(blocks);
|
||||
|
||||
log.info("Propagated rename {} → {} across {} block(s) for person {}",
|
||||
event.oldDisplayName(), event.newDisplayName(), blocks.size(), event.personId());
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,14 @@ import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
||||
import org.raddatz.familienarchiv.model.PersonType;
|
||||
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -31,6 +34,7 @@ public class PersonService {
|
||||
|
||||
private final PersonRepository personRepository;
|
||||
private final PersonNameAliasRepository aliasRepository;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public List<PersonSummaryDTO> findAll(String q) {
|
||||
if (q == null) {
|
||||
@@ -58,6 +62,17 @@ public class PersonService {
|
||||
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) {
|
||||
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||
}
|
||||
@@ -146,6 +161,7 @@ public class PersonService {
|
||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||
Person person = personRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||
String oldDisplayName = person.getDisplayName();
|
||||
person.setPersonType(dto.getPersonType());
|
||||
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
|
||||
person.setFirstName(dto.getFirstName());
|
||||
@@ -154,7 +170,17 @@ public class PersonService {
|
||||
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
||||
person.setBirthYear(dto.getBirthYear());
|
||||
person.setDeathYear(dto.getDeathYear());
|
||||
return personRepository.save(person);
|
||||
Person saved = personRepository.save(person);
|
||||
String newDisplayName = saved.getDisplayName();
|
||||
if (!Objects.equals(oldDisplayName, newDisplayName)) {
|
||||
try {
|
||||
eventPublisher.publishEvent(new PersonDisplayNameChangedEvent(id, oldDisplayName, newDisplayName));
|
||||
} catch (OptimisticLockingFailureException e) {
|
||||
throw DomainException.conflict(ErrorCode.PERSON_RENAME_CONFLICT,
|
||||
"A referenced transcription block was modified concurrently — rename rolled back");
|
||||
}
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -205,6 +205,18 @@ public class TranscriptionService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<TranscriptionBlock> markAllBlocksReviewed(UUID documentId, UUID userId) {
|
||||
List<TranscriptionBlock> blocks = blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
|
||||
for (TranscriptionBlock block : blocks) {
|
||||
if (!block.isReviewed()) {
|
||||
block.setReviewed(true);
|
||||
auditService.logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, documentId, null);
|
||||
}
|
||||
}
|
||||
return blockRepository.saveAll(blocks);
|
||||
}
|
||||
|
||||
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
|
||||
getBlock(documentId, blockId);
|
||||
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);
|
||||
|
||||
@@ -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);
|
||||
@@ -57,6 +57,13 @@ class PersonControllerTest {
|
||||
|
||||
@Test
|
||||
@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 {
|
||||
when(personService.findAll(null)).thenReturn(Collections.emptyList());
|
||||
mockMvc.perform(get("/api/persons"))
|
||||
@@ -64,7 +71,7 @@ class PersonControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getPersons_delegatesQueryParam_toService() throws Exception {
|
||||
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
|
||||
when(personService.findAll("Hans")).thenReturn(List.of(dto));
|
||||
@@ -85,6 +92,7 @@ class PersonControllerTest {
|
||||
public Integer getBirthYear() { return null; }
|
||||
public Integer getDeathYear() { return null; }
|
||||
public String getNotes() { return null; }
|
||||
public boolean isFamilyMember() { return false; }
|
||||
public long getDocumentCount() { return 0; }
|
||||
};
|
||||
}
|
||||
@@ -99,6 +107,13 @@ class PersonControllerTest {
|
||||
|
||||
@Test
|
||||
@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 {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build();
|
||||
@@ -318,6 +333,21 @@ class PersonControllerTest {
|
||||
.andExpect(jsonPath("$.lastName").value("Müller"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns409_whenRenameConflict() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(personService.updatePerson(eq(id), any()))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.PERSON_RENAME_CONFLICT,
|
||||
"Concurrent block edit during rename"));
|
||||
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Augusta\",\"lastName\":\"Raddatz\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("PERSON_RENAME_CONFLICT"));
|
||||
}
|
||||
|
||||
// ─── POST /api/persons/{id}/merge ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -183,6 +183,36 @@ class TranscriptionBlockControllerTest {
|
||||
.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} ─────────────
|
||||
|
||||
@Test
|
||||
@@ -221,6 +251,21 @@ class TranscriptionBlockControllerTest {
|
||||
.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_returns404_whenBlockDoesNotExist() throws Exception {
|
||||
@@ -380,4 +425,63 @@ class TranscriptionBlockControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.reviewed").value(true));
|
||||
}
|
||||
|
||||
// ─── PUT .../review-all ───────────────────────────────────────────────────
|
||||
|
||||
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void markAllBlocksReviewed_returns200_withAllReviewedBlocks_whenAuthorised() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||
TranscriptionBlock b1 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
|
||||
.text("Block 1").sortOrder(0).reviewed(true).build();
|
||||
TranscriptionBlock b2 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
|
||||
.text("Block 2").sortOrder(1).reviewed(true).build();
|
||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||
.thenReturn(List.of(b1, b2));
|
||||
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$[0].reviewed").value(true))
|
||||
.andExpect(jsonPath("$[1].reviewed").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void markAllBlocksReviewed_returns200_withEmptyList_whenNoBlocksExist() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(null);
|
||||
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
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.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
|
||||
import org.raddatz.familienarchiv.model.PersonMention;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
||||
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.ArrayList;
|
||||
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 PersonMentionPropagationListenerTest {
|
||||
|
||||
@Autowired TranscriptionBlockRepository blockRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired AnnotationRepository annotationRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired EntityManager em;
|
||||
|
||||
private PersonMentionPropagationListener listener;
|
||||
|
||||
private UUID documentId;
|
||||
private UUID annotationId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
listener = new PersonMentionPropagationListener(blockRepository);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private TranscriptionBlock saveBlock(String text, List<PersonMention> mentions) {
|
||||
return blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||
.annotationId(annotationId).documentId(documentId)
|
||||
.text(text).sortOrder(0)
|
||||
.mentionedPersons(mentions).build());
|
||||
}
|
||||
|
||||
private UUID savedPersonId(String firstName, String lastName) {
|
||||
Person p = personRepository.save(Person.builder()
|
||||
.firstName(firstName).lastName(lastName).build());
|
||||
return p.getId();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rewritesTextAndSidecar_whenSingleBlockReferencesRenamedPerson() {
|
||||
UUID personId = savedPersonId("Auguste", "Raddatz");
|
||||
TranscriptionBlock saved = saveBlock(
|
||||
"Liebe Tante @Auguste Raddatz, danke für den Brief.",
|
||||
List.of(new PersonMention(personId, "Auguste Raddatz")));
|
||||
em.clear();
|
||||
|
||||
listener.onPersonDisplayNameChanged(
|
||||
new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"));
|
||||
blockRepository.flush();
|
||||
em.clear();
|
||||
|
||||
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
|
||||
assertThat(reloaded.getText()).isEqualTo("Liebe Tante @Augusta Raddatz, danke für den Brief.");
|
||||
assertThat(reloaded.getMentionedPersons())
|
||||
.extracting(PersonMention::getDisplayName)
|
||||
.containsExactly("Augusta Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMatchPartialName_whenAnotherMentionShares_a_substring_with_renamed_person() {
|
||||
UUID hansPeterId = savedPersonId("Hans-Peter", "Müller");
|
||||
UUID hansId = savedPersonId("Hans", "Müller");
|
||||
TranscriptionBlock saved = saveBlock(
|
||||
"Heute hat @Hans-Peter Müller wieder mit @Hans Müller gesprochen.",
|
||||
List.of(
|
||||
new PersonMention(hansPeterId, "Hans-Peter Müller"),
|
||||
new PersonMention(hansId, "Hans Müller")));
|
||||
em.clear();
|
||||
|
||||
listener.onPersonDisplayNameChanged(
|
||||
new PersonDisplayNameChangedEvent(hansId, "Hans Müller", "Hans Schmidt"));
|
||||
blockRepository.flush();
|
||||
em.clear();
|
||||
|
||||
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
|
||||
assertThat(reloaded.getText())
|
||||
.isEqualTo("Heute hat @Hans-Peter Müller wieder mit @Hans Schmidt gesprochen.");
|
||||
assertThat(reloaded.getMentionedPersons())
|
||||
.extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
|
||||
.containsExactlyInAnyOrder(
|
||||
org.assertj.core.groups.Tuple.tuple(hansPeterId, "Hans-Peter Müller"),
|
||||
org.assertj.core.groups.Tuple.tuple(hansId, "Hans Schmidt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotCorruptCompositeMention_whenRenamingSingleWordPerson() {
|
||||
UUID hansMüllerId = savedPersonId("Hans", "Müller");
|
||||
UUID hansId = savedPersonId(null, "Hans");
|
||||
TranscriptionBlock saved = saveBlock(
|
||||
"@Hans Müller schrieb. Auch @Hans hat geschrieben.",
|
||||
List.of(
|
||||
new PersonMention(hansMüllerId, "Hans Müller"),
|
||||
new PersonMention(hansId, "Hans")));
|
||||
em.clear();
|
||||
|
||||
listener.onPersonDisplayNameChanged(
|
||||
new PersonDisplayNameChangedEvent(hansId, "Hans", "Henry"));
|
||||
blockRepository.flush();
|
||||
em.clear();
|
||||
|
||||
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
|
||||
assertThat(reloaded.getText())
|
||||
.isEqualTo("@Hans Müller schrieb. Auch @Henry hat geschrieben.");
|
||||
assertThat(reloaded.getMentionedPersons())
|
||||
.extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
|
||||
.containsExactlyInAnyOrder(
|
||||
org.assertj.core.groups.Tuple.tuple(hansMüllerId, "Hans Müller"),
|
||||
org.assertj.core.groups.Tuple.tuple(hansId, "Henry"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rewritesAllOccurrences_whenSameMentionAppearsTwiceInBlock() {
|
||||
UUID personId = savedPersonId("Auguste", "Raddatz");
|
||||
TranscriptionBlock saved = saveBlock(
|
||||
"Heute hat @Auguste Raddatz geschrieben, dann hat @Auguste Raddatz nochmal geschrieben.",
|
||||
List.of(new PersonMention(personId, "Auguste Raddatz")));
|
||||
em.clear();
|
||||
|
||||
listener.onPersonDisplayNameChanged(
|
||||
new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"));
|
||||
blockRepository.flush();
|
||||
em.clear();
|
||||
|
||||
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
|
||||
assertThat(reloaded.getText())
|
||||
.isEqualTo("Heute hat @Augusta Raddatz geschrieben, dann hat @Augusta Raddatz nochmal geschrieben.");
|
||||
assertThat(reloaded.getMentionedPersons())
|
||||
.extracting(PersonMention::getDisplayName)
|
||||
.containsExactly("Augusta Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesAcross200Blocks_inUnderTwoSeconds_latencyFloor() {
|
||||
UUID personId = savedPersonId("Auguste", "Raddatz");
|
||||
List<UUID> blockIds = new ArrayList<>();
|
||||
for (int i = 0; i < 200; i++) {
|
||||
TranscriptionBlock saved = blockRepository.save(TranscriptionBlock.builder()
|
||||
.annotationId(annotationId).documentId(documentId)
|
||||
.text("Block " + i + " mentions @Auguste Raddatz here.")
|
||||
.sortOrder(i)
|
||||
.mentionedPersons(List.of(new PersonMention(personId, "Auguste Raddatz")))
|
||||
.build());
|
||||
blockIds.add(saved.getId());
|
||||
}
|
||||
blockRepository.flush();
|
||||
em.clear();
|
||||
|
||||
long start = System.nanoTime();
|
||||
listener.onPersonDisplayNameChanged(
|
||||
new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"));
|
||||
blockRepository.flush();
|
||||
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||
|
||||
assertThat(elapsedMs)
|
||||
.as("Propagation across 200 blocks must stay under 5s — merge-blocking regression floor")
|
||||
.isLessThan(5000L);
|
||||
|
||||
em.clear();
|
||||
TranscriptionBlock first = blockRepository.findById(blockIds.get(0)).orElseThrow();
|
||||
assertThat(first.getText()).contains("@Augusta Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesUnrelatedBlockUntouched_whenNoSidecarReferencesPerson() {
|
||||
UUID personId = savedPersonId("Auguste", "Raddatz");
|
||||
TranscriptionBlock saved = saveBlock(
|
||||
"Plain text without any mentions.",
|
||||
List.of());
|
||||
em.clear();
|
||||
|
||||
listener.onPersonDisplayNameChanged(
|
||||
new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"));
|
||||
blockRepository.flush();
|
||||
em.clear();
|
||||
|
||||
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
|
||||
assertThat(reloaded.getText()).isEqualTo("Plain text without any mentions.");
|
||||
assertThat(reloaded.getMentionedPersons()).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
@@ -9,14 +10,22 @@ import org.raddatz.familienarchiv.dto.PersonNameAliasDTO;
|
||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
|
||||
import org.raddatz.familienarchiv.model.PersonMention;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
||||
import org.raddatz.familienarchiv.model.PersonType;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.orm.ObjectOptimisticLockingFailureException;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -31,6 +40,7 @@ class PersonServiceTest {
|
||||
|
||||
@Mock PersonRepository personRepository;
|
||||
@Mock PersonNameAliasRepository aliasRepository;
|
||||
@Mock ApplicationEventPublisher eventPublisher;
|
||||
@InjectMocks PersonService personService;
|
||||
|
||||
// ─── getById ─────────────────────────────────────────────────────────────
|
||||
@@ -242,6 +252,121 @@ class PersonServiceTest {
|
||||
assertThat(result.getAlias()).isEqualTo("Anna Alt");
|
||||
}
|
||||
|
||||
// ─── updatePerson (display-name change event) ────────────────────────────
|
||||
|
||||
@Test
|
||||
void updatePerson_publishesEvent_whenTitleChanges() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person existing = Person.builder()
|
||||
.id(id).title("Herr").firstName("Auguste").lastName("Raddatz")
|
||||
.personType(PersonType.PERSON).build();
|
||||
String oldName = existing.getDisplayName();
|
||||
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setPersonType(PersonType.PERSON);
|
||||
dto.setTitle("Frau"); dto.setFirstName("Auguste"); dto.setLastName("Raddatz");
|
||||
|
||||
personService.updatePerson(id, dto);
|
||||
|
||||
ArgumentCaptor<PersonDisplayNameChangedEvent> captor =
|
||||
ArgumentCaptor.forClass(PersonDisplayNameChangedEvent.class);
|
||||
verify(eventPublisher).publishEvent(captor.capture());
|
||||
|
||||
PersonDisplayNameChangedEvent event = captor.getValue();
|
||||
assertThat(event.personId()).isEqualTo(id);
|
||||
assertThat(event.oldDisplayName()).isEqualTo(oldName);
|
||||
assertThat(event.newDisplayName())
|
||||
.isNotEqualTo(oldName)
|
||||
.contains("Frau");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_doesNotPublishEvent_whenDisplayNameFieldsUnchanged() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person existing = Person.builder()
|
||||
.id(id).firstName("Auguste").lastName("Raddatz")
|
||||
.personType(PersonType.PERSON).alias("old alias").build();
|
||||
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setPersonType(PersonType.PERSON);
|
||||
dto.setFirstName("Auguste"); dto.setLastName("Raddatz");
|
||||
dto.setAlias("new alias");
|
||||
|
||||
personService.updatePerson(id, dto);
|
||||
|
||||
verify(eventPublisher, never()).publishEvent(any(PersonDisplayNameChangedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsConflict_whenBlockSaveAllAndFlushHitsOptimisticLock() {
|
||||
// Wire a real PersonMentionPropagationListener with a mocked block repository
|
||||
// that throws on saveAllAndFlush. The publisher mock routes events to the
|
||||
// listener so the catch path traverses the same call chain as production:
|
||||
// PersonService → publishEvent → listener → saveAllAndFlush throws → catch.
|
||||
UUID id = UUID.randomUUID();
|
||||
Person existing = Person.builder()
|
||||
.id(id).firstName("Auguste").lastName("Raddatz")
|
||||
.personType(PersonType.PERSON).build();
|
||||
|
||||
TranscriptionBlock referencingBlock = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(UUID.randomUUID()).annotationId(UUID.randomUUID())
|
||||
.text("Brief von @Auguste Raddatz").sortOrder(0)
|
||||
.mentionedPersons(new ArrayList<>(List.of(new PersonMention(id, "Auguste Raddatz"))))
|
||||
.build();
|
||||
|
||||
TranscriptionBlockRepository blockRepo = mock(TranscriptionBlockRepository.class);
|
||||
when(blockRepo.findByPersonIdWithMentionsFetched(id))
|
||||
.thenReturn(List.of(referencingBlock));
|
||||
when(blockRepo.saveAllAndFlush(any()))
|
||||
.thenThrow(new ObjectOptimisticLockingFailureException(
|
||||
TranscriptionBlock.class, referencingBlock.getId()));
|
||||
|
||||
PersonMentionPropagationListener realListener =
|
||||
new PersonMentionPropagationListener(blockRepo);
|
||||
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
doAnswer(inv -> {
|
||||
realListener.onPersonDisplayNameChanged(inv.getArgument(0));
|
||||
return null;
|
||||
}).when(eventPublisher).publishEvent(any(PersonDisplayNameChangedEvent.class));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setPersonType(PersonType.PERSON);
|
||||
dto.setFirstName("Augusta"); dto.setLastName("Raddatz");
|
||||
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.matches(e -> ((DomainException) e).getCode() == ErrorCode.PERSON_RENAME_CONFLICT)
|
||||
.matches(e -> ((DomainException) e).getStatus().value() == 409);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_doesNotPublishEvent_whenOnlyNotesChanges() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person existing = Person.builder()
|
||||
.id(id).firstName("Auguste").lastName("Raddatz")
|
||||
.personType(PersonType.PERSON).notes("first note").build();
|
||||
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setPersonType(PersonType.PERSON);
|
||||
dto.setFirstName("Auguste"); dto.setLastName("Raddatz");
|
||||
dto.setNotes("revised note");
|
||||
|
||||
personService.updatePerson(id, dto);
|
||||
|
||||
verify(eventPublisher, never()).publishEvent(any(PersonDisplayNameChangedEvent.class));
|
||||
}
|
||||
|
||||
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -98,7 +98,9 @@ class TranscriptionServiceTest {
|
||||
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);
|
||||
|
||||
@@ -168,7 +170,7 @@ class TranscriptionServiceTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(
|
||||
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);
|
||||
|
||||
@@ -189,7 +191,7 @@ class TranscriptionServiceTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(
|
||||
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());
|
||||
|
||||
@@ -208,7 +210,7 @@ class TranscriptionServiceTest {
|
||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -226,7 +228,7 @@ class TranscriptionServiceTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(
|
||||
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);
|
||||
}
|
||||
@@ -242,7 +244,7 @@ class TranscriptionServiceTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(
|
||||
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());
|
||||
}
|
||||
@@ -477,7 +479,7 @@ class TranscriptionServiceTest {
|
||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||
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")
|
||||
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
|
||||
@@ -502,8 +504,90 @@ class TranscriptionServiceTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(
|
||||
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());
|
||||
}
|
||||
|
||||
// ─── markAllBlocksReviewed ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_setsAllUnreviewedBlocksToReviewed() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
TranscriptionBlock block1 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||
TranscriptionBlock block2 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||
.thenReturn(List.of(block1, block2));
|
||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||
|
||||
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
|
||||
verify(blockRepository).saveAll(List.of(block1, block2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_isIdempotent_whenAllBlocksAlreadyReviewed() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
|
||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||
.thenReturn(List.of(block));
|
||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||
|
||||
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
|
||||
verify(blockRepository).saveAll(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_emitsBlockReviewedAuditEvent_forEachUnreviewedBlock() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
TranscriptionBlock block1 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||
TranscriptionBlock block2 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||
.thenReturn(List.of(block1, block2));
|
||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||
|
||||
verify(auditService, times(2)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_doesNotEmitAuditEvent_forAlreadyReviewedBlocks() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
TranscriptionBlock alreadyReviewed = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
|
||||
TranscriptionBlock unreviewed = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||
.thenReturn(List.of(alreadyReviewed, unreviewed));
|
||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||
|
||||
verify(auditService, times(1)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_returnsEmptyList_whenNoBlocksExist() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of());
|
||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
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
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.
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -542,6 +542,7 @@
|
||||
"person_alias_btn_delete": "Entfernen",
|
||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
||||
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
|
||||
"error_person_rename_conflict": "Eine andere Bearbeitung hat einen verknüpften Transkriptionsblock gleichzeitig geändert. Bitte erneut versuchen.",
|
||||
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
||||
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||
@@ -907,5 +908,80 @@
|
||||
"bulk_edit_loading": "Dokumente werden geladen…",
|
||||
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -542,6 +542,7 @@
|
||||
"person_alias_btn_delete": "Remove",
|
||||
"error_alias_not_found": "The name alias was not found.",
|
||||
"error_invalid_person_type": "The specified person type is not valid.",
|
||||
"error_person_rename_conflict": "Another edit modified a referenced transcription block at the same time. Please try again.",
|
||||
"validation_last_name_required": "Last name is required.",
|
||||
"validation_first_name_required": "First name is required.",
|
||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||
@@ -907,5 +908,80 @@
|
||||
"bulk_edit_loading": "Loading documents…",
|
||||
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -542,6 +542,7 @@
|
||||
"person_alias_btn_delete": "Eliminar",
|
||||
"error_alias_not_found": "No se encontro el alias de nombre.",
|
||||
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
|
||||
"error_person_rename_conflict": "Otra edición modificó un bloque de transcripción referenciado al mismo tiempo. Por favor, inténtalo de nuevo.",
|
||||
"validation_last_name_required": "El apellido es obligatorio.",
|
||||
"validation_first_name_required": "El nombre es obligatorio.",
|
||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
||||
@@ -907,5 +908,80 @@
|
||||
"bulk_edit_loading": "Cargando documentos…",
|
||||
"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_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."
|
||||
}
|
||||
|
||||
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 { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
||||
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 Tag = { id: string; name: string };
|
||||
@@ -14,9 +15,18 @@ type Props = {
|
||||
sender: Person | null;
|
||||
receivers: Person[];
|
||||
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;
|
||||
|
||||
@@ -37,7 +47,7 @@ function getFullName(person: Person): string {
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet personCard(person: Person)}
|
||||
{#snippet personCard(person: Person, relationLabel: string | null = null)}
|
||||
<a
|
||||
href="/persons/{person.id}"
|
||||
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)}
|
||||
</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>
|
||||
{/snippet}
|
||||
|
||||
@@ -88,7 +101,7 @@ function getFullName(person: Person): string {
|
||||
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
|
||||
{m.doc_details_field_sender()}
|
||||
</p>
|
||||
{@render personCard(sender)}
|
||||
{@render personCard(sender, inferredRelationship?.labelFromA ?? null)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if receivers.length > 0}
|
||||
@@ -97,8 +110,16 @@ function getFullName(person: Person): string {
|
||||
{m.doc_details_field_receivers()}
|
||||
</p>
|
||||
<div class="space-y-0.5">
|
||||
{#each displayedReceivers as receiver (receiver.id)}
|
||||
{@render personCard(receiver)}
|
||||
{#each displayedReceivers as receiver, i (receiver.id)}
|
||||
{@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}
|
||||
</div>
|
||||
{#if hiddenReceiverCount > 0 && !showAllReceivers}
|
||||
|
||||
@@ -81,6 +81,25 @@ describe('DocumentMetadataDrawer — persons column', () => {
|
||||
renderDrawer({ sender: null, receivers: [] });
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -30,9 +30,16 @@ type Props = {
|
||||
canWrite: boolean;
|
||||
fileUrl: string;
|
||||
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);
|
||||
|
||||
@@ -275,6 +282,7 @@ let mobileMenuOpen = $state(false);
|
||||
sender={doc.sender ?? null}
|
||||
receivers={doc.receivers ? [...doc.receivers] : []}
|
||||
tags={doc.tags ? [...doc.tags] : []}
|
||||
inferredRelationship={inferredRelationship}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -12,16 +12,25 @@ const PERSONS = [
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: '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) {
|
||||
@@ -62,14 +71,16 @@ describe('PersonMultiSelect – rendering', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -86,14 +97,16 @@ describe('PersonMultiSelect – rendering', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -112,7 +125,8 @@ describe('PersonMultiSelect – rendering', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -166,7 +180,8 @@ describe('PersonMultiSelect – selecting persons', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -187,7 +202,8 @@ describe('PersonMultiSelect – selecting persons', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]);
|
||||
render(PersonMultiSelect, { selectedPersons: [] });
|
||||
@@ -210,14 +226,16 @@ describe('PersonMultiSelect – removing persons', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -236,14 +254,16 @@ describe('PersonMultiSelect – removing persons', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ interface Props {
|
||||
autofocus?: boolean;
|
||||
required?: boolean;
|
||||
restrictToCorrespondentsOf?: string;
|
||||
excludePersonId?: string;
|
||||
badge?: 'additive' | 'replace';
|
||||
onchange?: (value: string) => void;
|
||||
onfocused?: () => void;
|
||||
@@ -36,6 +37,7 @@ let {
|
||||
autofocus = false,
|
||||
required = false,
|
||||
restrictToCorrespondentsOf,
|
||||
excludePersonId,
|
||||
badge,
|
||||
onchange,
|
||||
onfocused
|
||||
@@ -61,21 +63,40 @@ $effect(() => {
|
||||
const typeahead = createTypeahead<Person>({
|
||||
fetchUrl: async (term) => {
|
||||
const personId = restrictToCorrespondentsOf;
|
||||
const excludeId = excludePersonId;
|
||||
const filter = (results: Person[]) =>
|
||||
excludeId ? results.filter((p) => p.id !== excludeId) : results;
|
||||
if (personId) {
|
||||
const url =
|
||||
term.length >= 1
|
||||
? `/api/persons/${personId}/correspondents?q=${encodeURIComponent(term)}`
|
||||
: `/api/persons/${personId}/correspondents`;
|
||||
const res = await fetch(url);
|
||||
return res.ok ? await res.json() : [];
|
||||
return res.ok ? filter(await res.json()) : [];
|
||||
}
|
||||
if (term.length < 1) return [];
|
||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`);
|
||||
return res.ok ? await res.json() : [];
|
||||
return res.ok ? filter(await res.json()) : [];
|
||||
},
|
||||
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() {
|
||||
if (value && searchTerm !== initialName) {
|
||||
value = '';
|
||||
@@ -88,6 +109,7 @@ function handleInput() {
|
||||
|
||||
function handleFocus() {
|
||||
onfocused?.();
|
||||
updateDropdownPosition();
|
||||
if (restrictToCorrespondentsOf) {
|
||||
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
||||
(async () => {
|
||||
@@ -109,13 +131,47 @@ function selectPerson(person: Person) {
|
||||
value = person.id!;
|
||||
searchTerm = person.displayName;
|
||||
typeahead.close();
|
||||
activeIndex = -1;
|
||||
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>
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={closeDropdown}>
|
||||
<label
|
||||
for={name}
|
||||
for="{name}-search"
|
||||
class={compact
|
||||
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||
: '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
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
id="{name}-search"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
autofocus={autofocus}
|
||||
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}
|
||||
onfocus={handleFocus}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
||||
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'
|
||||
@@ -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'}
|
||||
/>
|
||||
|
||||
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
||||
<div
|
||||
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"
|
||||
{#if isOpen}
|
||||
<ul
|
||||
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}
|
||||
<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}
|
||||
{#each typeahead.results as person (person.id)}
|
||||
<div
|
||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
|
||||
{#each typeahead.results as person, i (person.id)}
|
||||
<li
|
||||
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)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="block truncate font-medium">
|
||||
{person.displayName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import PersonTypeahead from './PersonTypeahead.svelte';
|
||||
|
||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
@@ -130,11 +130,11 @@ describe('PersonTypeahead – selection', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Max Mustermann' }))
|
||||
.element(page.getByRole('option', { name: 'Max Mustermann' }))
|
||||
.not.toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
||||
});
|
||||
@@ -145,7 +145,7 @@ describe('PersonTypeahead – selection', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
await tick();
|
||||
expect(hiddenInput('senderId')?.value).toBe('1');
|
||||
@@ -158,7 +158,7 @@ describe('PersonTypeahead – selection', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
expect(onchange).toHaveBeenCalledWith('1');
|
||||
});
|
||||
@@ -177,7 +177,7 @@ describe('PersonTypeahead – selection', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
});
|
||||
@@ -194,7 +194,7 @@ describe('PersonTypeahead – clearing a selection', () => {
|
||||
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
expect(onchange).toHaveBeenCalledWith('1');
|
||||
onchange.mockClear();
|
||||
@@ -285,3 +285,194 @@ describe('PersonTypeahead – click outside', () => {
|
||||
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>
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ type Props = {
|
||||
onSaveBlock: (blockId: string, text: string) => Promise<void>;
|
||||
onDeleteBlock: (blockId: string) => Promise<void>;
|
||||
onReviewToggle: (blockId: string) => Promise<void>;
|
||||
onMarkAllReviewed?: () => Promise<void>;
|
||||
onTriggerOcr?: (scriptType: string, useExistingAnnotations: boolean) => void;
|
||||
canWrite?: boolean;
|
||||
trainingLabels?: string[];
|
||||
@@ -37,6 +38,7 @@ let {
|
||||
onSaveBlock,
|
||||
onDeleteBlock,
|
||||
onReviewToggle,
|
||||
onMarkAllReviewed,
|
||||
onTriggerOcr,
|
||||
canWrite = false,
|
||||
trainingLabels = [],
|
||||
@@ -46,12 +48,14 @@ let {
|
||||
let activeBlockId: string | null = $state(null);
|
||||
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
|
||||
let listEl: HTMLElement | null = $state(null);
|
||||
let markingAllReviewed = $state(false);
|
||||
|
||||
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
const hasBlocks = $derived(blocks.length > 0);
|
||||
const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
|
||||
const totalCount = $derived(blocks.length);
|
||||
const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
|
||||
const allReviewed = $derived(totalCount > 0 && reviewedCount === totalCount);
|
||||
|
||||
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
|
||||
$effect(() => {
|
||||
@@ -60,6 +64,16 @@ $effect(() => {
|
||||
if (block) activeBlockId = block.id;
|
||||
});
|
||||
|
||||
async function handleMarkAllReviewed() {
|
||||
if (!onMarkAllReviewed) return;
|
||||
markingAllReviewed = true;
|
||||
try {
|
||||
await onMarkAllReviewed();
|
||||
} finally {
|
||||
markingAllReviewed = false;
|
||||
}
|
||||
}
|
||||
|
||||
const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
|
||||
|
||||
const dragDrop = createBlockDragDrop({
|
||||
@@ -147,9 +161,56 @@ async function handleLabelToggle(label: string) {
|
||||
{#if hasBlocks}
|
||||
<!-- Sticky review progress header -->
|
||||
<div class="sticky top-0 z-10 border-b border-line bg-surface px-4 pt-3 pb-2">
|
||||
<p class="font-sans text-xs text-ink-2">
|
||||
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
|
||||
</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-sans text-xs text-ink-2">
|
||||
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
|
||||
</p>
|
||||
{#if onMarkAllReviewed}
|
||||
<button
|
||||
onclick={handleMarkAllReviewed}
|
||||
disabled={allReviewed || markingAllReviewed}
|
||||
title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined}
|
||||
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
|
||||
>
|
||||
{#if markingAllReviewed}
|
||||
<svg
|
||||
class="h-3.5 w-3.5 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{/if}
|
||||
Alle als fertig markieren
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="bg-brand-sand mt-1.5 h-0.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
class="h-full rounded-full bg-brand-mint transition-all duration-300"
|
||||
|
||||
@@ -49,6 +49,11 @@ function renderView(overrides: Record<string, unknown> = {}, service = createCon
|
||||
};
|
||||
}
|
||||
|
||||
const unreviewedBlock1 = { ...block1, reviewed: false };
|
||||
const unreviewedBlock2 = { ...block2, reviewed: false };
|
||||
const reviewedBlock1 = { ...block1, reviewed: true };
|
||||
const reviewedBlock2 = { ...block2, reviewed: true };
|
||||
|
||||
describe('TranscriptionEditView — rendering', () => {
|
||||
it('renders blocks in sort order', async () => {
|
||||
renderView();
|
||||
@@ -269,3 +274,61 @@ describe('TranscriptionEditView — review progress counter', () => {
|
||||
await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bulk mark all as reviewed ────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionEditView — mark all reviewed', () => {
|
||||
it('shows "Alle als fertig markieren" button when onMarkAllReviewed is provided and blocks are unreviewed', async () => {
|
||||
renderView({
|
||||
blocks: [unreviewedBlock1, unreviewedBlock2],
|
||||
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
|
||||
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables button when all blocks are already reviewed', async () => {
|
||||
renderView({
|
||||
blocks: [reviewedBlock1, reviewedBlock2],
|
||||
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||
.toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls onMarkAllReviewed exactly once when button is clicked', async () => {
|
||||
const onMarkAllReviewed = vi.fn().mockResolvedValue(undefined);
|
||||
renderView({
|
||||
blocks: [unreviewedBlock1, unreviewedBlock2],
|
||||
onMarkAllReviewed
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /Alle als fertig markieren/ }).click();
|
||||
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('disables button while operation is in-flight', async () => {
|
||||
let resolveMarkAll!: () => void;
|
||||
const onMarkAllReviewed = vi
|
||||
.fn()
|
||||
.mockReturnValue(new Promise<void>((r) => (resolveMarkAll = r)));
|
||||
renderView({
|
||||
blocks: [unreviewedBlock1, unreviewedBlock2],
|
||||
onMarkAllReviewed
|
||||
});
|
||||
|
||||
const btn = page.getByRole('button', { name: /Alle als fertig markieren/ });
|
||||
await btn.click();
|
||||
await expect.element(btn).toBeDisabled();
|
||||
resolveMarkAll();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ErrorCode =
|
||||
| 'PERSON_NOT_FOUND'
|
||||
| 'ALIAS_NOT_FOUND'
|
||||
| 'INVALID_PERSON_TYPE'
|
||||
| 'PERSON_RENAME_CONFLICT'
|
||||
| 'DOCUMENT_NOT_FOUND'
|
||||
| 'DOCUMENT_NO_FILE'
|
||||
| 'FILE_NOT_FOUND'
|
||||
@@ -38,6 +39,9 @@ export type ErrorCode =
|
||||
| 'TAG_NOT_FOUND'
|
||||
| 'TAG_MERGE_SELF'
|
||||
| 'TAG_MERGE_INVALID_TARGET'
|
||||
| 'RELATIONSHIP_NOT_FOUND'
|
||||
| 'CIRCULAR_RELATIONSHIP'
|
||||
| 'DUPLICATE_RELATIONSHIP'
|
||||
| 'MISSING_CREDENTIALS'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
@@ -76,6 +80,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_alias_not_found();
|
||||
case 'INVALID_PERSON_TYPE':
|
||||
return m.error_invalid_person_type();
|
||||
case 'PERSON_RENAME_CONFLICT':
|
||||
return m.error_person_rename_conflict();
|
||||
case 'DOCUMENT_NOT_FOUND':
|
||||
return m.error_document_not_found();
|
||||
case 'DOCUMENT_NO_FILE':
|
||||
@@ -136,6 +142,12 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_tag_merge_self();
|
||||
case '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':
|
||||
return m.login_error_missing_credentials();
|
||||
case 'UNAUTHORIZED':
|
||||
|
||||
@@ -132,6 +132,22 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -212,6 +228,22 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -612,6 +644,22 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -852,6 +900,22 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -884,6 +948,22 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1044,6 +1124,22 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1332,6 +1428,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons/{id}/relationships/{relId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["deleteRelationship"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons/{id}/aliases/{aliasId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1430,6 +1542,8 @@ export interface components {
|
||||
color?: string;
|
||||
};
|
||||
PersonUpdateDTO: {
|
||||
/** @enum {string} */
|
||||
personType: "PERSON" | "INSTITUTION" | "GROUP" | "UNKNOWN" | "SKIP";
|
||||
title?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
@@ -1454,6 +1568,7 @@ export interface components {
|
||||
birthYear?: number;
|
||||
/** Format: int32 */
|
||||
deathYear?: number;
|
||||
familyMember: boolean;
|
||||
readonly displayName: string;
|
||||
};
|
||||
DocumentUpdateDTO: {
|
||||
@@ -1462,6 +1577,8 @@ export interface components {
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
documentLocation?: string;
|
||||
archiveBox?: string;
|
||||
archiveFolder?: string;
|
||||
transcription?: string;
|
||||
summary?: string;
|
||||
/** Format: uuid */
|
||||
@@ -1510,9 +1627,15 @@ export interface components {
|
||||
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
||||
thumbnailUrl?: string;
|
||||
};
|
||||
PersonMention: {
|
||||
/** Format: uuid */
|
||||
personId: string;
|
||||
displayName: string;
|
||||
};
|
||||
UpdateTranscriptionBlockDTO: {
|
||||
text?: string;
|
||||
label?: string;
|
||||
mentionedPersons?: components["schemas"]["PersonMention"][];
|
||||
};
|
||||
TranscriptionBlock: {
|
||||
/** Format: uuid */
|
||||
@@ -1522,6 +1645,7 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
text?: string;
|
||||
mentionedPersons: components["schemas"]["PersonMention"][];
|
||||
label?: string;
|
||||
/** Format: int32 */
|
||||
sortOrder: number;
|
||||
@@ -1561,6 +1685,42 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
targetId: string;
|
||||
};
|
||||
CreateRelationshipRequest: {
|
||||
/** Format: uuid */
|
||||
relatedPersonId: string;
|
||||
/** @enum {string} */
|
||||
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
|
||||
/** Format: int32 */
|
||||
fromYear?: number;
|
||||
/** Format: int32 */
|
||||
toYear?: number;
|
||||
notes?: string;
|
||||
};
|
||||
RelationshipDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: uuid */
|
||||
personId: string;
|
||||
/** Format: uuid */
|
||||
relatedPersonId: string;
|
||||
personDisplayName: string;
|
||||
/** Format: int32 */
|
||||
personBirthYear?: number;
|
||||
/** Format: int32 */
|
||||
personDeathYear?: number;
|
||||
relatedPersonDisplayName: string;
|
||||
/** Format: int32 */
|
||||
relatedPersonBirthYear?: number;
|
||||
/** Format: int32 */
|
||||
relatedPersonDeathYear?: number;
|
||||
/** @enum {string} */
|
||||
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
|
||||
/** Format: int32 */
|
||||
fromYear?: number;
|
||||
/** Format: int32 */
|
||||
toYear?: number;
|
||||
notes?: string;
|
||||
};
|
||||
PersonNameAliasDTO: {
|
||||
lastName: string;
|
||||
firstName?: string;
|
||||
@@ -1660,6 +1820,7 @@ export interface components {
|
||||
height?: number;
|
||||
text?: string;
|
||||
label?: string;
|
||||
mentionedPersons?: components["schemas"]["PersonMention"][];
|
||||
};
|
||||
CreateCommentDTO: {
|
||||
content?: string;
|
||||
@@ -1808,6 +1969,9 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
count: number;
|
||||
};
|
||||
FamilyMemberPatchDTO: {
|
||||
familyMember?: boolean;
|
||||
};
|
||||
NotificationDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -1912,15 +2076,38 @@ export interface components {
|
||||
displayName?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** Format: int64 */
|
||||
documentCount?: number;
|
||||
personType?: string;
|
||||
/** Format: int32 */
|
||||
birthYear?: number;
|
||||
/** Format: int32 */
|
||||
deathYear?: number;
|
||||
alias?: string;
|
||||
familyMember?: boolean;
|
||||
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: {
|
||||
/** Format: uuid */
|
||||
@@ -2021,6 +2208,10 @@ export interface components {
|
||||
empty?: boolean;
|
||||
unsorted?: boolean;
|
||||
};
|
||||
NetworkDTO: {
|
||||
nodes: components["schemas"]["PersonNodeDTO"][];
|
||||
edges: components["schemas"]["RelationshipDTO"][];
|
||||
};
|
||||
DocumentVersionSummary: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -2131,7 +2322,7 @@ export interface components {
|
||||
};
|
||||
ActivityFeedItemDTO: {
|
||||
/** @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"];
|
||||
/** Format: uuid */
|
||||
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: {
|
||||
parameters: {
|
||||
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: {
|
||||
parameters: {
|
||||
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: {
|
||||
parameters: {
|
||||
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: {
|
||||
parameters: {
|
||||
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: {
|
||||
parameters: {
|
||||
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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4470,7 +4822,7 @@ export interface operations {
|
||||
query?: {
|
||||
limit?: number;
|
||||
/** @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;
|
||||
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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -60,13 +60,13 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/briefwechsel"
|
||||
href="/stammbaum"
|
||||
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||
{page.url.pathname.startsWith('/briefwechsel')
|
||||
{page.url.pathname.startsWith('/stammbaum')
|
||||
? 'border-b-2 border-accent text-white'
|
||||
: 'text-white/70 hover:text-white'}"
|
||||
>
|
||||
{m.nav_conversations()}
|
||||
{m.nav_stammbaum()}
|
||||
</a>
|
||||
{#if isAdmin}
|
||||
<a
|
||||
@@ -161,13 +161,13 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/briefwechsel"
|
||||
href="/stammbaum"
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
|
||||
{page.url.pathname.startsWith('/briefwechsel')
|
||||
{page.url.pathname.startsWith('/stammbaum')
|
||||
? 'bg-accent-bg text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.nav_conversations()}
|
||||
{m.nav_stammbaum()}
|
||||
</a>
|
||||
|
||||
{#if isAdmin}
|
||||
|
||||
@@ -131,7 +131,8 @@ describe('DocumentList – sender grouping', () => {
|
||||
id: 's1',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -143,7 +144,8 @@ describe('DocumentList – sender grouping', () => {
|
||||
id: 's2',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -162,7 +164,8 @@ describe('DocumentList – sender grouping', () => {
|
||||
id: 's1',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON' as const
|
||||
personType: 'PERSON' as const,
|
||||
familyMember: false
|
||||
};
|
||||
const items = [
|
||||
makeItem({ document: { ...makeItem().document, id: '1', sender } }),
|
||||
@@ -191,7 +194,13 @@ describe('DocumentList – receiver grouping', () => {
|
||||
...makeItem().document,
|
||||
id: '1',
|
||||
receivers: [
|
||||
{ id: 'r1', lastName: 'Brandt', displayName: 'Felix Brandt', personType: 'PERSON' }
|
||||
{
|
||||
id: 'r1',
|
||||
lastName: 'Brandt',
|
||||
displayName: 'Felix Brandt',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -210,8 +219,20 @@ describe('DocumentList – receiver grouping', () => {
|
||||
id: '1',
|
||||
title: 'Rundbriefchen',
|
||||
receivers: [
|
||||
{ id: 'r1', lastName: 'Brandt', displayName: 'Felix Brandt', personType: 'PERSON' },
|
||||
{ id: 'r2', lastName: 'Meier', displayName: 'Hans Meier', personType: 'PERSON' }
|
||||
{
|
||||
id: 'r1',
|
||||
lastName: 'Brandt',
|
||||
displayName: 'Felix Brandt',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
lastName: 'Meier',
|
||||
displayName: 'Hans Meier',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ let {
|
||||
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
|
||||
<!-- Sender -->
|
||||
<div
|
||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
@@ -73,7 +73,7 @@ let {
|
||||
|
||||
<!-- Receiver -->
|
||||
<div
|
||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
class="[&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="receiverId"
|
||||
@@ -86,7 +86,7 @@ let {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 grid grid-cols-1 items-end gap-6 md:grid-cols-3">
|
||||
<div class="grid grid-cols-1 items-end gap-6 md:grid-cols-3">
|
||||
<!-- Date From -->
|
||||
<div>
|
||||
<label
|
||||
|
||||
@@ -35,6 +35,7 @@ const makePerson = (overrides: Record<string, unknown> = {}) => ({
|
||||
firstName: 'Hans',
|
||||
lastName: 'Müller',
|
||||
personType: 'PERSON' as const,
|
||||
familyMember: false,
|
||||
displayName: 'Hans Müller',
|
||||
...overrides
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
import { inferredRelationshipLabel } from '$lib/relationshipLabels';
|
||||
|
||||
export async function load({ params, fetch }) {
|
||||
const { id } = params;
|
||||
@@ -15,5 +16,38 @@ export async function load({ params, fetch }) {
|
||||
throw error(docResult.response.status, getErrorMessage(code));
|
||||
}
|
||||
|
||||
return { document: docResult.data! };
|
||||
const document = docResult.data!;
|
||||
const inferredRelationship = await loadInferredRelationship(api, document);
|
||||
|
||||
return { document, inferredRelationship };
|
||||
}
|
||||
|
||||
async function loadInferredRelationship(
|
||||
api: ReturnType<typeof createApiClient>,
|
||||
document: {
|
||||
sender?: { id: string; familyMember?: boolean } | null;
|
||||
receivers?: { id: string; familyMember?: boolean }[];
|
||||
}
|
||||
): Promise<{ labelFromA: string; labelFromB: string } | null> {
|
||||
const sender = document.sender;
|
||||
const receivers = document.receivers ?? [];
|
||||
|
||||
// The badge is shown only when both endpoints are family members and the
|
||||
// document has exactly one receiver.
|
||||
if (!sender?.familyMember) return null;
|
||||
if (receivers.length !== 1) return null;
|
||||
const receiver = receivers[0];
|
||||
if (!receiver?.familyMember) return null;
|
||||
|
||||
const result = await api.GET('/api/persons/{aId}/relationship-to/{bId}', {
|
||||
params: { path: { aId: sender.id, bId: receiver.id } }
|
||||
});
|
||||
|
||||
if (result.response.status === 404) return null;
|
||||
if (!result.response.ok || !result.data) return null;
|
||||
|
||||
return {
|
||||
labelFromA: inferredRelationshipLabel(result.data.labelFromA),
|
||||
labelFromB: inferredRelationshipLabel(result.data.labelFromB)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,6 +137,18 @@ async function reviewToggle(blockId: string) {
|
||||
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
|
||||
}
|
||||
|
||||
async function markAllReviewed() {
|
||||
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/review-all`, {
|
||||
method: 'PUT'
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const updated = await res.json();
|
||||
for (const b of updated) {
|
||||
const existing = transcriptionBlocks.find((x) => x.id === b.id);
|
||||
if (existing) existing.reviewed = b.reviewed;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTrainingLabel(label: string, enrolled: boolean) {
|
||||
const res = await fetch(`/api/documents/${doc.id}/training-labels`, {
|
||||
method: 'PATCH',
|
||||
@@ -383,6 +395,7 @@ onMount(() => {
|
||||
canWrite={canWrite}
|
||||
fileUrl={fileLoader.fileUrl}
|
||||
bind:transcribeMode={transcribeMode}
|
||||
inferredRelationship={data.inferredRelationship}
|
||||
/>
|
||||
|
||||
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">
|
||||
@@ -501,6 +514,7 @@ onMount(() => {
|
||||
onSaveBlock={saveBlock}
|
||||
onDeleteBlock={deleteBlock}
|
||||
onReviewToggle={reviewToggle}
|
||||
onMarkAllReviewed={markAllReviewed}
|
||||
onTriggerOcr={triggerOcr}
|
||||
onToggleTrainingLabel={toggleTrainingLabel}
|
||||
/>
|
||||
|
||||
@@ -11,11 +11,20 @@ export async function load({ params, fetch, locals }) {
|
||||
g.permissions.includes('WRITE_ALL')
|
||||
) ?? false;
|
||||
|
||||
const [personResult, sentDocsResult, receivedDocsResult, aliasesResult] = await Promise.all([
|
||||
const [
|
||||
personResult,
|
||||
sentDocsResult,
|
||||
receivedDocsResult,
|
||||
aliasesResult,
|
||||
relsResult,
|
||||
inferredResult
|
||||
] = await Promise.all([
|
||||
api.GET('/api/persons/{id}', { params: { path: { id } } }),
|
||||
api.GET('/api/persons/{id}/documents', { params: { path: { id } } }),
|
||||
api.GET('/api/persons/{id}/received-documents', { params: { path: { id } } }),
|
||||
api.GET('/api/persons/{id}/aliases', { params: { path: { id } } })
|
||||
api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }),
|
||||
api.GET('/api/persons/{id}/relationships', { params: { path: { id } } }),
|
||||
api.GET('/api/persons/{id}/inferred-relationships', { params: { path: { id } } })
|
||||
]);
|
||||
|
||||
if (!personResult.response.ok) {
|
||||
@@ -28,6 +37,8 @@ export async function load({ params, fetch, locals }) {
|
||||
sentDocuments: sentDocsResult.data ?? [],
|
||||
receivedDocuments: receivedDocsResult.data ?? [],
|
||||
aliases: aliasesResult.data ?? [],
|
||||
relationships: relsResult.data ?? [],
|
||||
inferredRelationships: inferredResult.data ?? [],
|
||||
canWrite
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import PersonCard from './PersonCard.svelte';
|
||||
import NameHistoryCard from './NameHistoryCard.svelte';
|
||||
import CoCorrespondentsList from './CoCorrespondentsList.svelte';
|
||||
import PersonDocumentList from './PersonDocumentList.svelte';
|
||||
import PersonRelationshipsCard from './PersonRelationshipsCard.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -64,21 +65,33 @@ const coCorrespondents = $derived.by(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: correspondents + documents -->
|
||||
<!-- Right column: correspondents + relationships + documents -->
|
||||
<div>
|
||||
<CoCorrespondentsList coCorrespondents={coCorrespondents} personId={person.id} />
|
||||
|
||||
<PersonDocumentList
|
||||
documents={sentDocuments}
|
||||
heading={m.person_docs_heading()}
|
||||
emptyMessage={m.person_no_docs()}
|
||||
/>
|
||||
<div class="mt-6">
|
||||
<PersonRelationshipsCard
|
||||
personId={person.id}
|
||||
relationships={data.relationships}
|
||||
inferredRelationships={data.inferredRelationships}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PersonDocumentList
|
||||
documents={receivedDocuments}
|
||||
heading={m.person_received_docs_heading()}
|
||||
emptyMessage={m.person_no_received_docs()}
|
||||
/>
|
||||
<div class="mt-6">
|
||||
<PersonDocumentList
|
||||
documents={sentDocuments}
|
||||
heading={m.person_docs_heading()}
|
||||
emptyMessage={m.person_no_docs()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<PersonDocumentList
|
||||
documents={receivedDocuments}
|
||||
heading={m.person_received_docs_heading()}
|
||||
emptyMessage={m.person_no_received_docs()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,8 +15,8 @@ let mergeTargetId = $state('');
|
||||
let showMergeConfirm = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="mb-10 overflow-hidden rounded-sm border border-red-200 bg-surface shadow-sm">
|
||||
<div class="p-6 md:p-8">
|
||||
<div class="mt-6 mb-10 rounded-sm border border-red-200 bg-surface p-6 shadow-sm">
|
||||
<div>
|
||||
<h2 class="mb-1 font-serif text-lg text-ink">{m.person_merge_heading()}</h2>
|
||||
<p class="mb-5 font-sans text-sm text-ink-2">
|
||||
{m.person_merge_description()}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
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;
|
||||
relationships: RelationshipDTO[];
|
||||
inferredRelationships: InferredRelationshipWithPersonDTO[];
|
||||
}
|
||||
|
||||
let { personId, relationships, inferredRelationships }: Props = $props();
|
||||
|
||||
const directOtherIds = $derived(new Set(relationships.map((rel) => otherId(rel))));
|
||||
const topDerived = $derived(
|
||||
inferredRelationships.filter((d) => !directOtherIds.has(d.person.id)).slice(0, 5)
|
||||
);
|
||||
|
||||
function otherId(rel: RelationshipDTO): string {
|
||||
return rel.personId === personId ? rel.relatedPersonId : rel.personId;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.person_relationships_heading()}
|
||||
</h2>
|
||||
|
||||
{#if relationships.length === 0 && topDerived.length === 0}
|
||||
<p class="font-serif text-sm text-ink-2 italic">{m.person_relationships_empty()}</p>
|
||||
{:else}
|
||||
{#if relationships.length > 0}
|
||||
<ul class="mb-4 space-y-2">
|
||||
{#each relationships as rel (rel.id)}
|
||||
<li class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||
>
|
||||
{chipLabel(rel, personId)}
|
||||
</span>
|
||||
<a
|
||||
href="/persons/{otherId(rel)}"
|
||||
class="min-w-0 flex-1 truncate font-serif text-sm text-ink hover:underline"
|
||||
>
|
||||
{otherName(rel, personId)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if topDerived.length > 0}
|
||||
<ul class="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>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import PersonRelationshipsCard from './PersonRelationshipsCard.svelte';
|
||||
|
||||
const PERSON_ID = '00000000-0000-0000-0000-000000000001';
|
||||
const SPOUSE_ID = '00000000-0000-0000-0000-000000000002';
|
||||
const PARENT_ID = '00000000-0000-0000-0000-000000000003';
|
||||
|
||||
describe('PersonRelationshipsCard', () => {
|
||||
it('hides an inferred relationship that is already a direct one', async () => {
|
||||
render(PersonRelationshipsCard, {
|
||||
personId: PERSON_ID,
|
||||
relationships: [
|
||||
{
|
||||
id: 'r1',
|
||||
personId: PERSON_ID,
|
||||
relatedPersonId: SPOUSE_ID,
|
||||
personDisplayName: 'Anna Müller',
|
||||
relatedPersonDisplayName: 'Bertha Müller',
|
||||
relationType: 'SPOUSE_OF'
|
||||
}
|
||||
],
|
||||
inferredRelationships: [
|
||||
{
|
||||
person: {
|
||||
id: SPOUSE_ID,
|
||||
displayName: 'Bertha Müller',
|
||||
familyMember: true
|
||||
},
|
||||
label: 'SPOUSE',
|
||||
hops: 1
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const matches = await page.getByText('Bertha Müller').all();
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still renders inferred relationships that are not direct', async () => {
|
||||
const COUSIN_ID = '00000000-0000-0000-0000-000000000003';
|
||||
render(PersonRelationshipsCard, {
|
||||
personId: PERSON_ID,
|
||||
relationships: [],
|
||||
inferredRelationships: [
|
||||
{
|
||||
person: { id: COUSIN_ID, displayName: 'Carla Cousine', familyMember: true },
|
||||
label: 'COUSIN',
|
||||
hops: 4
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Carla Cousine')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Elternteil-von chip when personId is the PARENT_OF subject', async () => {
|
||||
render(PersonRelationshipsCard, {
|
||||
personId: PERSON_ID,
|
||||
relationships: [
|
||||
{
|
||||
id: 'r1',
|
||||
personId: PERSON_ID,
|
||||
relatedPersonId: PARENT_ID,
|
||||
personDisplayName: 'Anna Müller',
|
||||
relatedPersonDisplayName: 'Kind Müller',
|
||||
relationType: 'PARENT_OF'
|
||||
}
|
||||
],
|
||||
inferredRelationships: []
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Elternteil von')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('chip labels use text-xs (≥12px) not text-[10px] — WCAG readable font size', async () => {
|
||||
render(PersonRelationshipsCard, {
|
||||
personId: PERSON_ID,
|
||||
relationships: [
|
||||
{
|
||||
id: 'r1',
|
||||
personId: PERSON_ID,
|
||||
relatedPersonId: SPOUSE_ID,
|
||||
personDisplayName: 'Anna',
|
||||
relatedPersonDisplayName: 'Bertha',
|
||||
relationType: 'SPOUSE_OF'
|
||||
}
|
||||
],
|
||||
inferredRelationships: [
|
||||
{
|
||||
person: { id: PARENT_ID, displayName: 'Großmutter', familyMember: true },
|
||||
label: 'Großmutter',
|
||||
hops: 2
|
||||
}
|
||||
]
|
||||
});
|
||||
const chips = document.querySelectorAll<HTMLElement>('li span.rounded-full');
|
||||
expect(chips.length).toBeGreaterThan(0);
|
||||
chips.forEach((chip) => {
|
||||
expect(chip.classList.contains('text-xs')).toBe(true);
|
||||
expect(chip.classList.contains('text-[10px]')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Kind-von chip when personId is the PARENT_OF object', async () => {
|
||||
render(PersonRelationshipsCard, {
|
||||
personId: PERSON_ID,
|
||||
relationships: [
|
||||
{
|
||||
id: 'r2',
|
||||
personId: PARENT_ID,
|
||||
relatedPersonId: PERSON_ID,
|
||||
personDisplayName: 'Eltern Müller',
|
||||
relatedPersonDisplayName: 'Anna Müller',
|
||||
relationType: 'PARENT_OF'
|
||||
}
|
||||
],
|
||||
inferredRelationships: []
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Kind von')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -17,9 +17,11 @@ export async function load({ params, fetch, locals }) {
|
||||
|
||||
const { id } = params;
|
||||
const api = createApiClient(fetch);
|
||||
const [result, aliasesResult] = await Promise.all([
|
||||
const [result, aliasesResult, relsResult, inferredResult] = await Promise.all([
|
||||
api.GET('/api/persons/{id}', { params: { path: { id } } }),
|
||||
api.GET('/api/persons/{id}/aliases', { params: { path: { id } } })
|
||||
api.GET('/api/persons/{id}/aliases', { params: { path: { id } } }),
|
||||
api.GET('/api/persons/{id}/relationships', { params: { path: { id } } }),
|
||||
api.GET('/api/persons/{id}/inferred-relationships', { params: { path: { id } } })
|
||||
]);
|
||||
|
||||
if (!result.response.ok) {
|
||||
@@ -29,7 +31,12 @@ export async function load({ params, fetch, locals }) {
|
||||
|
||||
const person = result.data!;
|
||||
const personType = normalizePersonType(person.personType);
|
||||
return { person: { ...person, personType }, aliases: aliasesResult.data ?? [] };
|
||||
return {
|
||||
person: { ...person, personType },
|
||||
aliases: aliasesResult.data ?? [],
|
||||
relationships: relsResult.data ?? [],
|
||||
inferredRelationships: inferredResult.data ?? []
|
||||
};
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
@@ -146,5 +153,86 @@ export const actions = {
|
||||
}
|
||||
|
||||
return { aliasSuccess: true };
|
||||
},
|
||||
|
||||
toggleFamilyMember: async ({ request, params, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const value = formData.get('familyMember')?.toString() === 'true';
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.PATCH('/api/persons/{id}/family-member', {
|
||||
params: { path: { id: params.id } },
|
||||
body: { familyMember: value }
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { relationshipError: getErrorMessage(code) });
|
||||
}
|
||||
return { relationshipSuccess: true };
|
||||
},
|
||||
|
||||
addRelationship: async ({ request, params, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const relatedPersonId = formData.get('relatedPersonId')?.toString();
|
||||
const relationType = formData.get('relationType')?.toString();
|
||||
const fromYearRaw = formData.get('fromYear')?.toString().trim();
|
||||
const toYearRaw = formData.get('toYear')?.toString().trim();
|
||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||
|
||||
if (!relatedPersonId || !relationType) {
|
||||
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
||||
}
|
||||
if (relatedPersonId === params.id) {
|
||||
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
||||
}
|
||||
const fromYear = fromYearRaw ? parseInt(fromYearRaw, 10) : undefined;
|
||||
const toYear = toYearRaw ? parseInt(toYearRaw, 10) : undefined;
|
||||
if (
|
||||
fromYear !== undefined &&
|
||||
toYear !== undefined &&
|
||||
!Number.isNaN(fromYear) &&
|
||||
!Number.isNaN(toYear) &&
|
||||
toYear < fromYear
|
||||
) {
|
||||
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
||||
}
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.POST('/api/persons/{id}/relationships', {
|
||||
params: { path: { id: params.id } },
|
||||
body: {
|
||||
relatedPersonId,
|
||||
relationType,
|
||||
...(fromYear !== undefined && !Number.isNaN(fromYear) ? { fromYear } : {}),
|
||||
...(toYear !== undefined && !Number.isNaN(toYear) ? { toYear } : {}),
|
||||
...(notes ? { notes } : {})
|
||||
}
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { relationshipError: getErrorMessage(code) });
|
||||
}
|
||||
return { relationshipSuccess: true };
|
||||
},
|
||||
|
||||
deleteRelationship: async ({ request, params, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const relId = formData.get('relId')?.toString();
|
||||
if (!relId) {
|
||||
return fail(400, { relationshipError: getErrorMessage('VALIDATION_ERROR') });
|
||||
}
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.DELETE('/api/persons/{id}/relationships/{relId}', {
|
||||
params: { path: { id: params.id, relId } }
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { relationshipError: getErrorMessage(code) });
|
||||
}
|
||||
return { relationshipSuccess: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import PersonEditForm from './PersonEditForm.svelte';
|
||||
import PersonEditSaveBar from './PersonEditSaveBar.svelte';
|
||||
import NameHistoryEditCard from './NameHistoryEditCard.svelte';
|
||||
import PersonMergePanel from '../PersonMergePanel.svelte';
|
||||
import StammbaumCard from '$lib/components/StammbaumCard.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
const person = $derived(data.person);
|
||||
@@ -35,6 +36,15 @@ const person = $derived(data.person);
|
||||
|
||||
<NameHistoryEditCard aliases={data.aliases} canWrite={true} aliasError={form?.aliasError} />
|
||||
|
||||
<StammbaumCard
|
||||
personId={person.id}
|
||||
familyMember={person.familyMember ?? false}
|
||||
relationships={data.relationships}
|
||||
inferredRelationships={data.inferredRelationships}
|
||||
canWrite={true}
|
||||
relationshipError={form?.relationshipError}
|
||||
/>
|
||||
|
||||
{#key person.id}
|
||||
<PersonMergePanel person={person} form={form} />
|
||||
{/key}
|
||||
|
||||
@@ -27,6 +27,8 @@ describe('person detail load — happy path', () => {
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1', title: 'Brief' }] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
||||
@@ -47,6 +49,8 @@ describe('person detail load — happy path', () => {
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocalsWriter });
|
||||
@@ -65,6 +69,8 @@ describe('person detail load — happy path', () => {
|
||||
.mockResolvedValueOnce({ response: { ok: false }, data: null })
|
||||
.mockResolvedValueOnce({ response: { ok: false }, data: null })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
const result = await load({ params: { id: 'p1' }, fetch: mockFetch, locals: mockLocals });
|
||||
@@ -85,6 +91,8 @@ describe('person detail load — error paths', () => {
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
await expect(
|
||||
@@ -102,6 +110,8 @@ describe('person detail load — error paths', () => {
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
await expect(
|
||||
|
||||
18
frontend/src/routes/stammbaum/+page.server.ts
Normal file
18
frontend/src/routes/stammbaum/+page.server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.GET('/api/network');
|
||||
|
||||
if (result.response.status === 401) throw redirect(302, '/login');
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
}
|
||||
|
||||
const network = result.data!;
|
||||
return { nodes: network.nodes ?? [], edges: network.edges ?? [] };
|
||||
}
|
||||
128
frontend/src/routes/stammbaum/+page.svelte
Normal file
128
frontend/src/routes/stammbaum/+page.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { page } from '$app/state';
|
||||
import StammbaumTree from '$lib/components/StammbaumTree.svelte';
|
||||
import StammbaumSidePanel from '$lib/components/StammbaumSidePanel.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
interface Props {
|
||||
data: { nodes: PersonNodeDTO[]; edges: RelationshipDTO[] };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const canWrite = $derived<boolean>(page.data.canWrite ?? false);
|
||||
|
||||
const focusId = page.url.searchParams.get('focus');
|
||||
let selectedId = $state<string | null>(
|
||||
focusId && data.nodes.some((n) => n.id === focusId) ? focusId : null
|
||||
);
|
||||
|
||||
const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null);
|
||||
|
||||
let zoom = $state(1);
|
||||
function zoomIn() {
|
||||
zoom = Math.min(2, zoom + 0.1);
|
||||
}
|
||||
function zoomOut() {
|
||||
zoom = Math.max(0.4, zoom - 0.1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 4.25rem = 4rem navbar (h-16) + 0.25rem accent strip (h-1).
|
||||
-my-6 cancels <main>'s py-6 so the canvas sits flush against the navbar
|
||||
on top and the viewport edge on the bottom. -->
|
||||
<div class="-my-6 flex h-[calc(100dvh-4.25rem)] flex-col">
|
||||
<header
|
||||
class="flex shrink-0 items-center justify-between border-b border-line bg-surface px-6 py-4"
|
||||
>
|
||||
<h1 class="font-serif text-2xl text-ink">{m.nav_stammbaum()}</h1>
|
||||
{#if data.nodes.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={zoomOut}
|
||||
aria-label={m.stammbaum_zoom_out()}
|
||||
class="inline-flex h-11 w-11 items-center justify-center rounded-sm border border-line bg-surface text-ink-2 transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={zoomIn}
|
||||
aria-label={m.stammbaum_zoom_in()}
|
||||
class="inline-flex h-11 w-11 items-center justify-center rounded-sm border border-line bg-surface text-ink-2 transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if data.nodes.length === 0}
|
||||
<div class="flex flex-1 items-center justify-center p-8">
|
||||
<div
|
||||
class="mx-auto max-w-md rounded-sm border border-line bg-surface p-10 text-center shadow-sm"
|
||||
>
|
||||
<svg
|
||||
class="mx-auto mb-4 h-12 w-12 text-ink-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="5" r="2.5" />
|
||||
<circle cx="6" cy="14" r="2.5" />
|
||||
<circle cx="18" cy="14" r="2.5" />
|
||||
<path stroke-linecap="round" d="M12 7.5v3M9.5 12.5L9 14M14.5 12.5l.5 1.5" />
|
||||
</svg>
|
||||
<h2 class="mb-2 font-serif text-xl text-ink">{m.stammbaum_empty_heading()}</h2>
|
||||
<p class="mb-4 font-serif text-sm text-ink-2">{m.stammbaum_empty_body()}</p>
|
||||
<a
|
||||
href="/persons"
|
||||
class="inline-block font-sans text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{m.stammbaum_empty_link()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<div class="flex-1 overflow-auto bg-muted/20">
|
||||
<StammbaumTree
|
||||
nodes={data.nodes}
|
||||
edges={data.edges}
|
||||
selectedId={selectedId}
|
||||
zoom={zoom}
|
||||
onSelect={(id) => (selectedId = id)}
|
||||
/>
|
||||
</div>
|
||||
{#if selectedNode}
|
||||
<!-- Desktop: side panel on the right -->
|
||||
<aside
|
||||
class="hidden w-[320px] shrink-0 overflow-y-auto border-l border-line bg-surface md:block"
|
||||
>
|
||||
<StammbaumSidePanel
|
||||
node={selectedNode}
|
||||
canWrite={canWrite}
|
||||
onClose={() => (selectedId = null)}
|
||||
/>
|
||||
</aside>
|
||||
<!-- Mobile: fixed bottom sheet -->
|
||||
<div
|
||||
class="fixed inset-x-0 bottom-0 z-40 max-h-[60dvh] overflow-y-auto border-t border-line bg-surface shadow-lg md:hidden"
|
||||
>
|
||||
<StammbaumSidePanel
|
||||
node={selectedNode}
|
||||
canWrite={canWrite}
|
||||
onClose={() => (selectedId = null)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user