Compare commits
2 Commits
ae868f4110
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c641d704a8 | ||
|
|
69ac183fe8 |
@@ -6,7 +6,6 @@ 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;
|
||||
@@ -48,12 +47,6 @@ 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,13 +34,11 @@ 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,6 +1,5 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
|
||||
@@ -46,7 +45,7 @@ public class TranscriptionBlockController {
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public TranscriptionBlock createBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
|
||||
@RequestBody CreateTranscriptionBlockDTO dto,
|
||||
Authentication authentication) {
|
||||
UUID userId = requireUserId(authentication);
|
||||
return transcriptionService.createBlock(documentId, dto, userId);
|
||||
@@ -57,7 +56,7 @@ public class TranscriptionBlockController {
|
||||
public TranscriptionBlock updateBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID blockId,
|
||||
@Valid @RequestBody UpdateTranscriptionBlockDTO dto,
|
||||
@RequestBody UpdateTranscriptionBlockDTO dto,
|
||||
Authentication authentication) {
|
||||
UUID userId = requireUserId(authentication);
|
||||
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
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;
|
||||
@@ -29,8 +22,4 @@ public class CreateTranscriptionBlockDTO {
|
||||
private double height;
|
||||
private String text;
|
||||
private String label;
|
||||
|
||||
@Valid
|
||||
@Builder.Default
|
||||
private List<PersonMention> mentionedPersons = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ public interface PersonSummaryDTO {
|
||||
Integer getBirthYear();
|
||||
Integer getDeathYear();
|
||||
String getNotes();
|
||||
boolean isFamilyMember();
|
||||
long getDocumentCount();
|
||||
|
||||
default String getDisplayName() {
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
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,10 +15,6 @@ 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 */
|
||||
@@ -100,14 +96,6 @@ 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,11 +47,6 @@ 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.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
) {
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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,8 +7,6 @@ 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
|
||||
@@ -35,14 +33,6 @@ 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;
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
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) {}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
) {}
|
||||
@@ -1,4 +0,0 @@
|
||||
package org.raddatz.familienarchiv.relationship.dto;
|
||||
|
||||
/** Body for {@code PATCH /api/persons/{id}/family-member}. */
|
||||
public record FamilyMemberPatchDTO(boolean familyMember) {}
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
) {}
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
) {}
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
) {}
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
) {}
|
||||
@@ -1,32 +0,0 @@
|
||||
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,9 +26,6 @@ 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);
|
||||
|
||||
@@ -41,7 +38,6 @@ 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
|
||||
@@ -54,7 +50,6 @@ 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
|
||||
@@ -63,7 +58,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, p.family_member
|
||||
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
|
||||
ORDER BY p.last_name ASC, p.first_name ASC
|
||||
""",
|
||||
nativeQuery = true)
|
||||
|
||||
@@ -29,15 +29,6 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
|
||||
|
||||
Optional<TranscriptionBlock> findByAnnotationId(UUID annotationId);
|
||||
|
||||
@Query("""
|
||||
SELECT DISTINCT b FROM TranscriptionBlock b
|
||||
JOIN FETCH b.mentionedPersons
|
||||
WHERE b.id IN (
|
||||
SELECT bb.id FROM TranscriptionBlock bb JOIN bb.mentionedPersons m WHERE m.personId = :personId
|
||||
)
|
||||
""")
|
||||
List<TranscriptionBlock> findByPersonIdWithMentionsFetched(@Param("personId") UUID personId);
|
||||
|
||||
void deleteByAnnotationId(UUID annotationId);
|
||||
|
||||
int countByDocumentId(UUID documentId);
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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();
|
||||
Pattern boundary = Pattern.compile(
|
||||
Pattern.quote(oldNeedle) + "(?![\\p{L}0-9\\-]| (?=\\p{Lu}))");
|
||||
String replacement = Matcher.quoteReplacement(newNeedle);
|
||||
|
||||
for (TranscriptionBlock block : blocks) {
|
||||
rewriteBlockText(block, boundary, 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());
|
||||
}
|
||||
|
||||
// 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.
|
||||
private void rewriteBlockText(TranscriptionBlock block, Pattern boundary, String replacement) {
|
||||
if (block.getText() != null) {
|
||||
block.setText(boundary.matcher(block.getText()).replaceAll(replacement));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,11 @@ 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;
|
||||
@@ -34,7 +31,6 @@ public class PersonService {
|
||||
|
||||
private final PersonRepository personRepository;
|
||||
private final PersonNameAliasRepository aliasRepository;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public List<PersonSummaryDTO> findAll(String q) {
|
||||
if (q == null) {
|
||||
@@ -62,17 +58,6 @@ 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);
|
||||
}
|
||||
@@ -161,7 +146,6 @@ 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());
|
||||
@@ -170,17 +154,7 @@ public class PersonService {
|
||||
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
||||
person.setBirthYear(dto.getBirthYear());
|
||||
person.setDeathYear(dto.getDeathYear());
|
||||
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;
|
||||
return personRepository.save(person);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
-- 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';
|
||||
@@ -1,6 +0,0 @@
|
||||
-- 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';
|
||||
@@ -1,25 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Prevent duplicate sidecar rows for the same (block, person) pair.
|
||||
-- @ElementCollection uses DELETE+INSERT per update so normal JPA writes can't
|
||||
-- create duplicates, but a raw-SQL import or concurrent bypass of JPA could.
|
||||
ALTER TABLE transcription_block_mentioned_persons
|
||||
ADD CONSTRAINT uq_tbmp_block_person UNIQUE (block_id, person_id);
|
||||
@@ -57,13 +57,6 @@ 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"))
|
||||
@@ -71,7 +64,7 @@ class PersonControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
@WithMockUser
|
||||
void getPersons_delegatesQueryParam_toService() throws Exception {
|
||||
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
|
||||
when(personService.findAll("Hans")).thenReturn(List.of(dto));
|
||||
@@ -92,7 +85,6 @@ 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; }
|
||||
};
|
||||
}
|
||||
@@ -107,13 +99,6 @@ 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();
|
||||
@@ -333,21 +318,6 @@ 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,36 +183,6 @@ 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
|
||||
@@ -251,34 +221,6 @@ 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_returns400_whenMentionedPersonPersonIdIsNull() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updateBlock_returns404_whenBlockDoesNotExist() throws Exception {
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
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;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
@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_inUnderFiveSeconds_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 doesNotThrow_whenBlockTextIsNull() {
|
||||
UUID personId = savedPersonId("Auguste", "Raddatz");
|
||||
saveBlock(null, List.of(new PersonMention(personId, "Auguste Raddatz")));
|
||||
em.clear();
|
||||
|
||||
assertThatCode(() -> listener.onPersonDisplayNameChanged(
|
||||
new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz")))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@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,7 +2,6 @@ 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;
|
||||
@@ -10,22 +9,14 @@ 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;
|
||||
@@ -40,7 +31,6 @@ class PersonServiceTest {
|
||||
|
||||
@Mock PersonRepository personRepository;
|
||||
@Mock PersonNameAliasRepository aliasRepository;
|
||||
@Mock ApplicationEventPublisher eventPublisher;
|
||||
@InjectMocks PersonService personService;
|
||||
|
||||
// ─── getById ─────────────────────────────────────────────────────────────
|
||||
@@ -252,121 +242,6 @@ 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,9 +98,7 @@ class TranscriptionServiceTest {
|
||||
return b;
|
||||
});
|
||||
|
||||
CreateTranscriptionBlockDTO dto = CreateTranscriptionBlockDTO.builder()
|
||||
.pageNumber(1).x(0.1).y(0.2).width(0.3).height(0.4)
|
||||
.text("hello").build();
|
||||
CreateTranscriptionBlockDTO dto = new CreateTranscriptionBlockDTO(1, 0.1, 0.2, 0.3, 0.4, "hello", null);
|
||||
|
||||
TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId);
|
||||
|
||||
@@ -170,7 +168,7 @@ class TranscriptionServiceTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(
|
||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||
|
||||
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder().text("new text").build();
|
||||
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("new text", null);
|
||||
|
||||
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId);
|
||||
|
||||
@@ -191,7 +189,7 @@ class TranscriptionServiceTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(
|
||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||
|
||||
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder().text("text").label("Anrede").build();
|
||||
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("text", "Anrede");
|
||||
|
||||
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
|
||||
|
||||
@@ -210,7 +208,7 @@ class TranscriptionServiceTest {
|
||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||
|
||||
TranscriptionBlock result = transcriptionService.updateBlock(
|
||||
docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new").build(), UUID.randomUUID());
|
||||
docId, blockId, new UpdateTranscriptionBlockDTO("new", null), UUID.randomUUID());
|
||||
|
||||
assertThat(result.getSource()).isEqualTo(BlockSource.MANUAL);
|
||||
}
|
||||
@@ -228,7 +226,7 @@ class TranscriptionServiceTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(
|
||||
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).sender(sender).build());
|
||||
|
||||
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("text").build(), UUID.randomUUID());
|
||||
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null), UUID.randomUUID());
|
||||
|
||||
verify(senderModelService).checkAndTriggerTraining(senderId);
|
||||
}
|
||||
@@ -244,7 +242,7 @@ class TranscriptionServiceTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(
|
||||
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).build());
|
||||
|
||||
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("text").build(), UUID.randomUUID());
|
||||
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null), UUID.randomUUID());
|
||||
|
||||
verify(senderModelService, never()).checkAndTriggerTraining(any());
|
||||
}
|
||||
@@ -479,7 +477,7 @@ class TranscriptionServiceTest {
|
||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||
when(annotationRepository.findById(annotId)).thenReturn(Optional.of(annotation));
|
||||
|
||||
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new text").build(), userId);
|
||||
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("new text", null), userId);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
|
||||
@@ -504,7 +502,7 @@ class TranscriptionServiceTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(
|
||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||
|
||||
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("same text").build(), userId);
|
||||
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("same text", null), userId);
|
||||
|
||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,987 +0,0 @@
|
||||
<!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>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,197 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,141 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,154 +0,0 @@
|
||||
import { test, expect, devices } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||
const STORAGE_STATE = path.resolve(__dirname, '.auth/user.json');
|
||||
|
||||
/**
|
||||
* E2E for issue #362 — Person @mentions, read-mode rendering + hover card (B20/B21).
|
||||
*
|
||||
* Strategy:
|
||||
* - Create a document, a Person, and a transcription block whose text contains
|
||||
* `@DisplayName` and whose mentionedPersons sidecar links to that person.
|
||||
* - Open the document in read mode.
|
||||
* - B20: page.hover() on the .person-mention link → hover card mounts.
|
||||
* - B21: with context.setHasTouch(true), page.tap() on the link → navigates
|
||||
* to /persons/{id} without ever showing the hover card.
|
||||
*/
|
||||
|
||||
let docId: string;
|
||||
let personId: string;
|
||||
let docHref: string;
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Person mention — read mode', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||
|
||||
// 1. Person we will mention.
|
||||
const personRes = await request.post('/api/persons', {
|
||||
data: {
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
personType: 'PERSON',
|
||||
birthYear: 1882,
|
||||
deathYear: 1944
|
||||
}
|
||||
});
|
||||
if (!personRes.ok()) throw new Error(`Create person failed: ${personRes.status()}`);
|
||||
const person = await personRes.json();
|
||||
personId = person.id;
|
||||
|
||||
// 2. Document with a PDF so the transcription panel is mountable.
|
||||
const docRes = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E Person Mention Read', documentDate: '1945-05-08' }
|
||||
});
|
||||
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
|
||||
const doc = await docRes.json();
|
||||
docId = doc.id;
|
||||
docHref = `${baseURL}/documents/${docId}`;
|
||||
|
||||
await request.put(`/api/documents/${docId}`, {
|
||||
multipart: {
|
||||
title: doc.title,
|
||||
documentDate: '1945-05-08',
|
||||
file: {
|
||||
name: 'minimal.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Annotation to anchor the block on the page.
|
||||
const annRes = await request.post(`/api/documents/${docId}/annotations`, {
|
||||
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.5, height: 0.1, color: '#00C7B1' }
|
||||
});
|
||||
if (!annRes.ok()) throw new Error(`Create annotation failed: ${annRes.status()}`);
|
||||
|
||||
// 4. Block text contains @Auguste Raddatz; sidecar links it to personId.
|
||||
const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||
data: {
|
||||
pageNumber: 1,
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.5,
|
||||
height: 0.1,
|
||||
text: 'Brief an @Auguste Raddatz vom Mai 1944',
|
||||
label: null,
|
||||
mentionedPersons: [{ personId, displayName: 'Auguste Raddatz' }]
|
||||
}
|
||||
});
|
||||
if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (docId) await request.delete(`/api/documents/${docId}`);
|
||||
if (personId) await request.delete(`/api/persons/${personId}`);
|
||||
});
|
||||
|
||||
test('renders the @mention as an underlined anchor link to /persons/{id}', async ({ page }) => {
|
||||
await page.goto(docHref);
|
||||
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||
await expect(link).toBeVisible({ timeout: 5000 });
|
||||
await expect(link).toHaveAttribute('href', `/persons/${personId}`);
|
||||
// The @ trigger is stripped from the rendered text per spec
|
||||
await expect(link).toHaveText('Auguste Raddatz');
|
||||
});
|
||||
|
||||
test('B20: desktop hover mounts the hover card with loaded person data', async ({ page }) => {
|
||||
await page.goto(docHref);
|
||||
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||
await link.hover();
|
||||
|
||||
const card = page.getByTestId('person-hover-card');
|
||||
await expect(card).toBeVisible({ timeout: 5000 });
|
||||
// Loaded state: person displayName is rendered inside the card
|
||||
await expect(page.getByTestId('person-hover-card-name')).toHaveText('Auguste Raddatz');
|
||||
// Footer link points to /persons/{id}
|
||||
await expect(card.locator(`a[href="/persons/${personId}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('B20: hover card unmounts on mouseleave', async ({ page }) => {
|
||||
await page.goto(docHref);
|
||||
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||
await link.hover();
|
||||
await expect(page.getByTestId('person-hover-card')).toBeVisible();
|
||||
|
||||
// Move pointer away
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByTestId('person-hover-card')).toBeHidden({ timeout: 2000 });
|
||||
});
|
||||
|
||||
test('B21: touch-device tap navigates without showing the hover card', async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
...devices['Pixel 7'],
|
||||
storageState: STORAGE_STATE
|
||||
});
|
||||
const touchPage = await context.newPage();
|
||||
try {
|
||||
await touchPage.goto(docHref);
|
||||
await touchPage.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
const link = touchPage.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||
await expect(link).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await link.tap();
|
||||
// The card never mounted — the tap navigated directly per spec
|
||||
await expect(touchPage).toHaveURL(new RegExp(`/persons/${personId}`));
|
||||
await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -420,12 +420,6 @@
|
||||
"notification_unread": "ungelesen",
|
||||
"mention_btn_label": "Person erwähnen",
|
||||
"mention_popup_empty": "Keine Nutzer gefunden",
|
||||
"person_mention_open_link": "Zur Person",
|
||||
"person_mention_hover_hint": "Klick öffnet Seite",
|
||||
"person_mention_load_error": "Person konnte nicht geladen werden.",
|
||||
"person_mention_popup_empty": "Keine Personen gefunden",
|
||||
"person_mention_btn_label": "Person verlinken",
|
||||
"person_mention_create_new": "Neue Person anlegen",
|
||||
"page_title_home": "Archiv",
|
||||
"page_title_persons": "Personen",
|
||||
"page_title_admin": "Administration",
|
||||
@@ -548,7 +542,6 @@
|
||||
"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.",
|
||||
@@ -914,80 +907,5 @@
|
||||
"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",
|
||||
|
||||
"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."
|
||||
"bulk_edit_count_pill": "{count} werden bearbeitet"
|
||||
}
|
||||
|
||||
@@ -420,12 +420,6 @@
|
||||
"notification_unread": "unread",
|
||||
"mention_btn_label": "Mention person",
|
||||
"mention_popup_empty": "No users found",
|
||||
"person_mention_open_link": "Open person",
|
||||
"person_mention_hover_hint": "Click opens the page",
|
||||
"person_mention_load_error": "Could not load person.",
|
||||
"person_mention_popup_empty": "No persons found",
|
||||
"person_mention_btn_label": "Link person",
|
||||
"person_mention_create_new": "Create new person",
|
||||
"page_title_home": "Archive",
|
||||
"page_title_persons": "Persons",
|
||||
"page_title_admin": "Administration",
|
||||
@@ -548,7 +542,6 @@
|
||||
"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.",
|
||||
@@ -914,80 +907,5 @@
|
||||
"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",
|
||||
|
||||
"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."
|
||||
"bulk_edit_count_pill": "{count} will be edited"
|
||||
}
|
||||
|
||||
@@ -420,12 +420,6 @@
|
||||
"notification_unread": "no leído",
|
||||
"mention_btn_label": "Mencionar persona",
|
||||
"mention_popup_empty": "No se encontraron usuarios",
|
||||
"person_mention_open_link": "Ir a la persona",
|
||||
"person_mention_hover_hint": "Clic abre la página",
|
||||
"person_mention_load_error": "No se pudo cargar la persona.",
|
||||
"person_mention_popup_empty": "No se encontraron personas",
|
||||
"person_mention_btn_label": "Vincular persona",
|
||||
"person_mention_create_new": "Crear nueva persona",
|
||||
"page_title_home": "Archivo",
|
||||
"page_title_persons": "Personas",
|
||||
"page_title_admin": "Administración",
|
||||
@@ -548,7 +542,6 @@
|
||||
"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.",
|
||||
@@ -914,80 +907,5 @@
|
||||
"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}",
|
||||
|
||||
"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."
|
||||
"bulk_edit_count_pill": "Se editarán {count}"
|
||||
}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
<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}
|
||||
@@ -1,65 +0,0 @@
|
||||
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,7 +3,6 @@ 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 };
|
||||
@@ -15,18 +14,9 @@ type Props = {
|
||||
sender: Person | null;
|
||||
receivers: Person[];
|
||||
tags: Tag[];
|
||||
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
||||
};
|
||||
|
||||
let {
|
||||
documentDate,
|
||||
location,
|
||||
status,
|
||||
sender,
|
||||
receivers,
|
||||
tags,
|
||||
inferredRelationship = null
|
||||
}: Props = $props();
|
||||
let { documentDate, location, status, sender, receivers, tags }: Props = $props();
|
||||
|
||||
const VISIBLE_RECEIVER_LIMIT = 5;
|
||||
|
||||
@@ -47,7 +37,7 @@ function getFullName(person: Person): string {
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet personCard(person: Person, relationLabel: string | null = null)}
|
||||
{#snippet personCard(person: Person)}
|
||||
<a
|
||||
href="/persons/{person.id}"
|
||||
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
|
||||
@@ -59,10 +49,7 @@ function getFullName(person: Person): string {
|
||||
>
|
||||
{getInitials(person.displayName)}
|
||||
</span>
|
||||
<span class="min-w-0 truncate font-serif text-sm text-ink">{getFullName(person)}</span>
|
||||
{#if relationLabel}
|
||||
<RelationshipPill label={relationLabel} />
|
||||
{/if}
|
||||
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
@@ -101,7 +88,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, inferredRelationship?.labelFromA ?? null)}
|
||||
{@render personCard(sender)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if receivers.length > 0}
|
||||
@@ -110,16 +97,8 @@ function getFullName(person: Person): string {
|
||||
{m.doc_details_field_receivers()}
|
||||
</p>
|
||||
<div class="space-y-0.5">
|
||||
{#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 displayedReceivers as receiver (receiver.id)}
|
||||
{@render personCard(receiver)}
|
||||
{/each}
|
||||
</div>
|
||||
{#if hiddenReceiverCount > 0 && !showAllReceivers}
|
||||
|
||||
@@ -81,25 +81,6 @@ 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,16 +30,9 @@ type Props = {
|
||||
canWrite: boolean;
|
||||
fileUrl: string;
|
||||
transcribeMode: boolean;
|
||||
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
||||
};
|
||||
|
||||
let {
|
||||
doc,
|
||||
canWrite,
|
||||
fileUrl,
|
||||
transcribeMode = $bindable(),
|
||||
inferredRelationship = null
|
||||
}: Props = $props();
|
||||
let { doc, canWrite, fileUrl, transcribeMode = $bindable() }: Props = $props();
|
||||
|
||||
let detailsOpen = $state(false);
|
||||
|
||||
@@ -282,7 +275,6 @@ let mobileMenuOpen = $state(false);
|
||||
sender={doc.sender ?? null}
|
||||
receivers={doc.receivers ? [...doc.receivers] : []}
|
||||
tags={doc.tags ? [...doc.tags] : []}
|
||||
inferredRelationship={inferredRelationship}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
export type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error' }
|
||||
| { status: 'loaded'; person: Person; relationships: RelationshipDTO[] };
|
||||
|
||||
type Props = {
|
||||
personId: string;
|
||||
cardId: string;
|
||||
position: { top: number; left: number };
|
||||
state: LoadState;
|
||||
};
|
||||
|
||||
let { personId, cardId, position, state }: Props = $props();
|
||||
|
||||
const FAMILY_REL_TYPES: ReadonlySet<RelationshipDTO['relationType']> = new Set([
|
||||
'PARENT_OF',
|
||||
'SPOUSE_OF',
|
||||
'SIBLING_OF'
|
||||
]);
|
||||
const NOTES_MAX = 120;
|
||||
|
||||
const familyChips = $derived(
|
||||
state.status === 'loaded'
|
||||
? state.relationships.filter((r) => FAMILY_REL_TYPES.has(r.relationType))
|
||||
: []
|
||||
);
|
||||
|
||||
const dateRange = $derived(
|
||||
state.status === 'loaded'
|
||||
? formatLifeDateRange(state.person.birthYear, state.person.deathYear)
|
||||
: ''
|
||||
);
|
||||
|
||||
const notesExcerpt = $derived.by(() => {
|
||||
if (state.status !== 'loaded') return null;
|
||||
const notes = state.person.notes;
|
||||
if (!notes) return null;
|
||||
if (notes.length <= NOTES_MAX) return notes;
|
||||
return notes.slice(0, NOTES_MAX) + '…';
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="person-hover-card"
|
||||
data-testid="person-hover-card"
|
||||
id={cardId}
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
style:position="absolute"
|
||||
style:top={`${position.top}px`}
|
||||
style:left={`${position.left}px`}
|
||||
>
|
||||
{#if state.status === 'loading'}
|
||||
<div data-testid="person-hover-card-skeleton" class="skeleton">
|
||||
<div class="bar"></div>
|
||||
<div class="bar"></div>
|
||||
<div class="bar"></div>
|
||||
</div>
|
||||
{:else if state.status === 'error'}
|
||||
<div data-testid="person-hover-card-error" class="error-message">
|
||||
{m.person_mention_load_error()}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()} →</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div data-testid="person-hover-card-content" class="content">
|
||||
<div class="header">
|
||||
<div class="name" data-testid="person-hover-card-name">{state.person.displayName}</div>
|
||||
{#if dateRange}
|
||||
<div class="dates" data-testid="person-hover-card-dates">{dateRange}</div>
|
||||
{/if}
|
||||
{#if state.person.alias}
|
||||
<div class="maiden" data-testid="person-hover-card-maiden">geb. {state.person.alias}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if familyChips.length > 0}
|
||||
<div class="chips" data-testid="person-hover-card-chips">
|
||||
{#each familyChips as chip (chip.id)}
|
||||
<span class="chip">{chip.relatedPersonDisplayName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if notesExcerpt}
|
||||
<p class="notes" data-testid="person-hover-card-notes">{notesExcerpt}</p>
|
||||
{/if}
|
||||
<div class="footer">
|
||||
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()} →</a>
|
||||
<span class="hint">{m.person_mention_hover_hint()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.person-hover-card {
|
||||
width: 320px;
|
||||
min-height: 180px;
|
||||
background-color: var(--c-surface);
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
padding: 14px 16px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
color: var(--c-ink);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* On touch devices the card is suppressed entirely — tap navigates directly. */
|
||||
@media (hover: none) {
|
||||
.person-hover-card {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.skeleton .bar {
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--c-line);
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton .bar:nth-child(1) {
|
||||
width: 60%;
|
||||
}
|
||||
.skeleton .bar:nth-child(2) {
|
||||
width: 40%;
|
||||
}
|
||||
.skeleton .bar:nth-child(3) {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton .bar {
|
||||
animation: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background-color: var(--c-ink);
|
||||
color: var(--c-surface);
|
||||
margin: -14px -16px 12px;
|
||||
padding: 12px 16px;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dates,
|
||||
.maiden {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--c-surface) 75%, transparent);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: 12px;
|
||||
background-color: var(--c-accent-bg);
|
||||
color: var(--c-ink);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.notes {
|
||||
font-size: 13px;
|
||||
color: var(--c-ink-2);
|
||||
line-height: 1.4;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 13px;
|
||||
color: var(--c-ink-2);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--c-line);
|
||||
padding-top: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.open-link {
|
||||
color: var(--c-ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--c-ink-3);
|
||||
}
|
||||
</style>
|
||||
@@ -1,272 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import PersonHoverCard from './PersonHoverCard.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
const AUGUSTE: Person = {
|
||||
id: 'p-aug',
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Auguste Raddatz',
|
||||
personType: 'PERSON',
|
||||
familyMember: true,
|
||||
birthYear: 1882,
|
||||
deathYear: 1944
|
||||
} as unknown as Person;
|
||||
|
||||
const POSITION = { top: 100, left: 200 };
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('PersonHoverCard — loading state', () => {
|
||||
it('shows the skeleton when state.status is loading', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
await expect.element(page.getByTestId('person-hover-card-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders three skeleton bars', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const bars = document.querySelectorAll('[data-testid="person-hover-card-skeleton"] .bar');
|
||||
expect(bars.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersonHoverCard — error state', () => {
|
||||
it('shows a generic error message when state.status is error', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'error' }
|
||||
});
|
||||
await expect.element(page.getByTestId('person-hover-card-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still allows the link footer to navigate (link present in error state)', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'error' }
|
||||
});
|
||||
// The card root must show the footer link even when the body errored —
|
||||
// click navigation works regardless of fetch outcome.
|
||||
const link = document.querySelector('a[href="/persons/p-aug"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersonHoverCard — loaded state', () => {
|
||||
it('renders the person displayName as the header name', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the life-date range when birthYear and deathYear are present', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the life-date line when both years are missing', async () => {
|
||||
const noDates = { ...AUGUSTE, birthYear: undefined, deathYear: undefined } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: noDates, relationships: [] }
|
||||
});
|
||||
const dates = document.querySelector('[data-testid="person-hover-card-dates"]');
|
||||
expect(dates).toBeNull();
|
||||
});
|
||||
|
||||
it('renders "geb. <alias>" when alias is set', async () => {
|
||||
const withAlias = { ...AUGUSTE, alias: 'Müller' } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withAlias, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('geb. Müller')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the maiden name line when alias is null', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const maiden = document.querySelector('[data-testid="person-hover-card-maiden"]');
|
||||
expect(maiden).toBeNull();
|
||||
});
|
||||
|
||||
it('renders family relationship chips for PARENT_OF, SPOUSE_OF, SIBLING_OF only', async () => {
|
||||
const relationships: RelationshipDTO[] = [
|
||||
{
|
||||
id: 'r1',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-spouse',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Otto Raddatz',
|
||||
relationType: 'SPOUSE_OF'
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-friend',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Karl Friend',
|
||||
relationType: 'FRIEND'
|
||||
},
|
||||
{
|
||||
id: 'r3',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-sibling',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Marie Sister',
|
||||
relationType: 'SIBLING_OF'
|
||||
}
|
||||
];
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships }
|
||||
});
|
||||
await expect.element(page.getByText('Otto Raddatz')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Marie Sister')).toBeInTheDocument();
|
||||
// Non-family relationship type must be filtered out
|
||||
const friendChip = page.getByText('Karl Friend');
|
||||
await expect.element(friendChip).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the chips section entirely when no family relationships', async () => {
|
||||
const onlyFriend: RelationshipDTO[] = [
|
||||
{
|
||||
id: 'r1',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-friend',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Karl Friend',
|
||||
relationType: 'FRIEND'
|
||||
}
|
||||
];
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: onlyFriend }
|
||||
});
|
||||
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
|
||||
expect(chips).toBeNull();
|
||||
});
|
||||
|
||||
it('renders notes excerpt unchanged when notes ≤ 120 characters', async () => {
|
||||
const withNotes = { ...AUGUSTE, notes: 'Born in Berlin.' } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withNotes, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('truncates notes longer than 120 characters with an ellipsis', async () => {
|
||||
const long = 'x'.repeat(150);
|
||||
const withLongNotes = { ...AUGUSTE, notes: long } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
||||
});
|
||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
||||
expect(notes.textContent!.length).toBeLessThanOrEqual(122);
|
||||
expect(notes.textContent).toContain('…');
|
||||
});
|
||||
|
||||
it('omits notes section when notes is null', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]');
|
||||
expect(notes).toBeNull();
|
||||
});
|
||||
|
||||
it('footer renders an anchor link to /persons/{personId}', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const link = document.querySelector('a[href="/persons/p-aug"]')!;
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersonHoverCard — accessibility', () => {
|
||||
it('uses aria-live="polite" so screen readers announce loaded content', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
expect(root.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-xyz',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
expect(root.id).toBe('card-xyz');
|
||||
});
|
||||
|
||||
it('positions itself absolutely at the given top/left', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: { top: 333, left: 444 },
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement;
|
||||
expect(root.style.top).toBe('333px');
|
||||
expect(root.style.left).toBe('444px');
|
||||
expect(root.style.position).toBe('absolute');
|
||||
});
|
||||
});
|
||||
@@ -1,263 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { detectPersonMention } from '$lib/utils/personMention';
|
||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type PersonMention = components['schemas']['PersonMention'];
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
mentionedPersons: PersonMention[];
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
disabled?: boolean;
|
||||
onfocus?: () => void;
|
||||
onblur?: () => void;
|
||||
// Optional escape hatch: lets the parent observe the underlying textarea node
|
||||
// (e.g. to read selection bounds for quote-selection features). Returning a
|
||||
// cleanup function from the parent is not required.
|
||||
captureTextarea?: (node: HTMLTextAreaElement) => void | (() => void);
|
||||
};
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
mentionedPersons = $bindable([]),
|
||||
placeholder = '',
|
||||
rows = 1,
|
||||
disabled = false,
|
||||
onfocus,
|
||||
onblur,
|
||||
captureTextarea
|
||||
}: Props = $props();
|
||||
|
||||
let query: string | null = $state(null);
|
||||
let results: Person[] = $state([]);
|
||||
let highlightedIndex = $state(0);
|
||||
let mentionStart = $state(0);
|
||||
let loading = $state(false);
|
||||
|
||||
let textarea: HTMLTextAreaElement | null = null;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function attachTextarea(node: HTMLTextAreaElement) {
|
||||
textarea = node;
|
||||
resizeTextarea();
|
||||
const parentCleanup = captureTextarea?.(node);
|
||||
return () => {
|
||||
parentCleanup?.();
|
||||
textarea = null;
|
||||
};
|
||||
}
|
||||
|
||||
function resizeTextarea() {
|
||||
if (!textarea) return;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}
|
||||
|
||||
// Autoresize on every value change — read `value` so this $effect
|
||||
// re-runs whenever the bound prop is reassigned.
|
||||
$effect(() => {
|
||||
void value;
|
||||
resizeTextarea();
|
||||
});
|
||||
|
||||
function handleInput() {
|
||||
if (!textarea) return;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const detected = detectPersonMention(value, cursorPos);
|
||||
|
||||
if (detected === null) {
|
||||
closePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const before = value.slice(0, cursorPos);
|
||||
mentionStart = before.lastIndexOf('@');
|
||||
|
||||
if (query !== detected) {
|
||||
query = detected;
|
||||
highlightedIndex = 0;
|
||||
scheduleSearch(detected);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSearch(q: string) {
|
||||
clearTimeout(debounceTimer);
|
||||
if (!q.trim()) {
|
||||
// Empty query: keep popup open with last results so the user can browse,
|
||||
// but don't fire a backend call until they actually type something.
|
||||
results = [];
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
// SECURITY: relies on the SvelteKit Vite proxy injecting the auth_token
|
||||
// cookie as the Authorization header (vite.config.ts) and on the
|
||||
// browser's same-origin policy for the /api/* path. Mounted in
|
||||
// transcribe mode behind WRITE_ALL — never reachable to unauthenticated
|
||||
// users.
|
||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(q)}`);
|
||||
if (res.ok) {
|
||||
const data: Person[] = await res.json();
|
||||
results = data.slice(0, 5);
|
||||
} else {
|
||||
results = [];
|
||||
}
|
||||
} catch {
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async function selectPerson(person: Person) {
|
||||
if (!textarea) return;
|
||||
|
||||
const displayName = person.displayName ?? '';
|
||||
const replacement = `@${displayName} `;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const before = value.slice(0, mentionStart);
|
||||
const after = value.slice(cursorPos);
|
||||
value = before + replacement + after;
|
||||
|
||||
if (!mentionedPersons.some((existing) => existing.personId === person.id)) {
|
||||
mentionedPersons = [...mentionedPersons, { personId: person.id!, displayName }];
|
||||
}
|
||||
|
||||
closePopup();
|
||||
|
||||
await tick();
|
||||
if (!textarea) return;
|
||||
const pos = mentionStart + replacement.length;
|
||||
textarea.selectionStart = pos;
|
||||
textarea.selectionEnd = pos;
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
query = null;
|
||||
results = [];
|
||||
highlightedIndex = 0;
|
||||
loading = false;
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Small delay so an option's onmousedown can fire and select before the
|
||||
// popup unmounts. Without this, clicking a result on the way out would
|
||||
// race with blur and lose the selection.
|
||||
setTimeout(() => closePopup(), 150);
|
||||
onblur?.();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (query === null) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closePopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (results.length > 0) {
|
||||
highlightedIndex = (highlightedIndex + 1) % results.length;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (results.length > 0) {
|
||||
highlightedIndex = (highlightedIndex - 1 + results.length) % results.length;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && results.length > 0) {
|
||||
e.preventDefault();
|
||||
selectPerson(results[highlightedIndex]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => clearTimeout(debounceTimer));
|
||||
|
||||
const popupOpen = $derived(query !== null);
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<textarea
|
||||
{@attach attachTextarea}
|
||||
class="block min-h-[44px] w-full resize-none rounded-sm border border-transparent bg-transparent px-1 py-2.5 font-serif text-base leading-relaxed text-ink placeholder:text-ink-3 focus-visible:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-mint/40 focus-visible:outline-none"
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
bind:value={value}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
onfocus={onfocus}
|
||||
onblur={handleBlur}
|
||||
></textarea>
|
||||
|
||||
{#if popupOpen}
|
||||
<div
|
||||
class="absolute z-20 mt-1 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
||||
role="listbox"
|
||||
aria-label={m.person_mention_btn_label()}
|
||||
>
|
||||
{#if loading}
|
||||
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.comp_typeahead_loading()}</p>
|
||||
{:else if results.length === 0}
|
||||
<div class="flex flex-col gap-2 px-3 py-2.5">
|
||||
<p class="font-sans text-sm text-ink-3">{m.person_mention_popup_empty()}</p>
|
||||
<a
|
||||
href="/persons/new?name={encodeURIComponent(query ?? '')}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="font-sans text-sm font-medium text-brand-navy underline-offset-2 hover:underline"
|
||||
>
|
||||
{m.person_mention_create_new()} →
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
{#each results as person, i (person.id)}
|
||||
<div
|
||||
class={[
|
||||
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
|
||||
// Keyboard-highlighted row gets a stronger token than hover so
|
||||
// keyboard users (and tablet stylus users sweeping over rows)
|
||||
// can tell the cursor position apart from a hover (Leonie #5507 §3,
|
||||
// WCAG 1.4.11 Non-Text Contrast).
|
||||
i === highlightedIndex &&
|
||||
'bg-brand-mint/20 ring-2 ring-brand-mint ring-inset'
|
||||
]}
|
||||
role="option"
|
||||
aria-selected={i === highlightedIndex}
|
||||
data-test-person-id={person.id}
|
||||
tabindex="-1"
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
selectPerson(person);
|
||||
}}
|
||||
>
|
||||
<span class="truncate font-serif text-base text-ink">{person.displayName}</span>
|
||||
{#if formatLifeDateRange(person.birthYear, person.deathYear)}
|
||||
<span class="truncate font-sans text-xs text-ink-3">
|
||||
{formatLifeDateRange(person.birthYear, person.deathYear)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,385 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type PersonMention = components['schemas']['PersonMention'];
|
||||
|
||||
// Editor's internal search debounce is 200ms — drive it via fake timers
|
||||
// so tests are deterministic and fast (Tester #5506 §1).
|
||||
const DEBOUNCE_MS = 200;
|
||||
|
||||
async function flushDebounce() {
|
||||
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
||||
// Let the awaited fetch resolve and the resulting state assignments flush.
|
||||
await vi.runAllTimersAsync();
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
|
||||
const AUGUSTE: Person = {
|
||||
id: 'p-aug',
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Auguste Raddatz',
|
||||
birthYear: 1882,
|
||||
deathYear: 1944
|
||||
} as unknown as Person;
|
||||
|
||||
const ANNA: Person = {
|
||||
id: 'p-anna',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Schmidt',
|
||||
displayName: 'Anna Schmidt',
|
||||
birthYear: 1860
|
||||
} as unknown as Person;
|
||||
|
||||
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function mockFetchEmpty() {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function mockFetchRejects() {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function getTextarea(): HTMLTextAreaElement {
|
||||
return document.querySelector('textarea')!;
|
||||
}
|
||||
|
||||
function clickOption(personId: string) {
|
||||
const opt = document.querySelector(
|
||||
`[role="option"][data-test-person-id="${personId}"]`
|
||||
) as HTMLElement;
|
||||
opt.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
|
||||
|
||||
function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[] } = {}) {
|
||||
let snapshot: Snapshot = {
|
||||
value: initial.value ?? '',
|
||||
mentionedPersons: initial.mentionedPersons ?? []
|
||||
};
|
||||
render(PersonMentionEditorHost, {
|
||||
initialValue: initial.value ?? '',
|
||||
initialMentions: initial.mentionedPersons ?? [],
|
||||
onChange: (snap: Snapshot) => {
|
||||
snapshot = snap;
|
||||
}
|
||||
});
|
||||
return {
|
||||
get snapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — rendering', () => {
|
||||
it('renders the textarea with placeholder', async () => {
|
||||
render(PersonMentionEditorHost, {
|
||||
initialValue: '',
|
||||
initialMentions: [],
|
||||
placeholder: 'Transkription…',
|
||||
onChange: () => {}
|
||||
});
|
||||
await expect.element(page.getByPlaceholder('Transkription…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reflects bound initial value', async () => {
|
||||
render(PersonMentionEditorHost, {
|
||||
initialValue: 'Hallo Welt',
|
||||
initialMentions: [],
|
||||
onChange: () => {}
|
||||
});
|
||||
await expect.element(page.getByRole('textbox')).toHaveValue('Hallo Welt');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Typeahead opens on @ ─────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — typeahead', () => {
|
||||
it('opens the popup when typing @ + query and shows results', async () => {
|
||||
mockFetchWithPersons();
|
||||
renderHost();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@Aug';
|
||||
ta.selectionStart = 4;
|
||||
ta.selectionEnd = 4;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
await flushDebounce();
|
||||
|
||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hits /api/persons?q= with the typed query', async () => {
|
||||
const fetchMock = mockFetchWithPersons();
|
||||
renderHost();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@Aug';
|
||||
ta.selectionStart = 4;
|
||||
ta.selectionEnd = 4;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
await flushDebounce();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
|
||||
});
|
||||
|
||||
it('shows life dates next to the name in the dropdown', async () => {
|
||||
mockFetchWithPersons();
|
||||
renderHost();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@Aug';
|
||||
ta.selectionStart = 4;
|
||||
ta.selectionEnd = 4;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
|
||||
await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no persons match', async () => {
|
||||
mockFetchEmpty();
|
||||
renderHost();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@xyz';
|
||||
ta.selectionStart = 4;
|
||||
ta.selectionEnd = 4;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
|
||||
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the empty state when the typeahead fetch rejects (network error)', async () => {
|
||||
mockFetchRejects();
|
||||
renderHost();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@Aug';
|
||||
ta.selectionStart = 4;
|
||||
ta.selectionEnd = 4;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
|
||||
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the popup open when the query has a trailing space (multi-word names)', async () => {
|
||||
mockFetchWithPersons();
|
||||
renderHost();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@Auguste ';
|
||||
ta.selectionStart = 9;
|
||||
ta.selectionEnd = 9;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
|
||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Selection writes text + sidecar ─────────────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — selecting a person', () => {
|
||||
it('inserts @DisplayName followed by a trailing space into the textarea', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = renderHost();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@Aug';
|
||||
ta.selectionStart = 4;
|
||||
ta.selectionEnd = 4;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
|
||||
clickOption('p-aug');
|
||||
await tick();
|
||||
|
||||
expect(host.snapshot.value).toBe('@Auguste Raddatz ');
|
||||
});
|
||||
|
||||
it('pushes {personId, displayName} into the bound mentionedPersons array', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = renderHost();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@Aug';
|
||||
ta.selectionStart = 4;
|
||||
ta.selectionEnd = 4;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
|
||||
clickOption('p-aug');
|
||||
await tick();
|
||||
|
||||
expect(host.snapshot.mentionedPersons).toEqual([
|
||||
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = renderHost({
|
||||
value: '@Auguste Raddatz ',
|
||||
mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
|
||||
});
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@Auguste Raddatz @Aug';
|
||||
ta.selectionStart = ta.value.length;
|
||||
ta.selectionEnd = ta.value.length;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
|
||||
clickOption('p-aug');
|
||||
await tick();
|
||||
|
||||
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Keyboard navigation (B11b) ──────────────────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — keyboard navigation (B11b)', () => {
|
||||
it('ArrowDown / ArrowUp cycle the highlighted result', async () => {
|
||||
mockFetchWithPersons();
|
||||
renderHost();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@A';
|
||||
ta.selectionStart = 2;
|
||||
ta.selectionEnd = 2;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
|
||||
const optAuguste = document.querySelector(
|
||||
'[role="option"][data-test-person-id="p-aug"]'
|
||||
) as HTMLElement;
|
||||
const optAnna = document.querySelector(
|
||||
'[role="option"][data-test-person-id="p-anna"]'
|
||||
) as HTMLElement;
|
||||
|
||||
expect(optAuguste.getAttribute('aria-selected')).toBe('true');
|
||||
expect(optAnna.getAttribute('aria-selected')).toBe('false');
|
||||
|
||||
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||
await tick();
|
||||
|
||||
expect(optAuguste.getAttribute('aria-selected')).toBe('false');
|
||||
expect(optAnna.getAttribute('aria-selected')).toBe('true');
|
||||
|
||||
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
||||
await tick();
|
||||
|
||||
expect(optAuguste.getAttribute('aria-selected')).toBe('true');
|
||||
expect(optAnna.getAttribute('aria-selected')).toBe('false');
|
||||
});
|
||||
|
||||
it('Enter selects the currently highlighted result', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = renderHost();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@A';
|
||||
ta.selectionStart = 2;
|
||||
ta.selectionEnd = 2;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
|
||||
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||
await tick();
|
||||
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
await tick();
|
||||
|
||||
expect(host.snapshot.mentionedPersons).toEqual([
|
||||
{ personId: 'p-anna', displayName: 'Anna Schmidt' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('Escape closes the popup without inserting anything', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = renderHost();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@Aug';
|
||||
ta.selectionStart = 4;
|
||||
ta.selectionEnd = 4;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
|
||||
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await tick();
|
||||
|
||||
await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
|
||||
expect(host.snapshot.value).toBe('@Aug');
|
||||
expect(host.snapshot.mentionedPersons).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — touch target', () => {
|
||||
it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => {
|
||||
mockFetchWithPersons();
|
||||
renderHost();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@Aug';
|
||||
ta.selectionStart = 4;
|
||||
ta.selectionEnd = 4;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await flushDebounce();
|
||||
|
||||
const option = document.querySelector('[role="option"]') as HTMLElement;
|
||||
expect(option).not.toBeNull();
|
||||
expect(option.className).toContain('min-h-[44px]');
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import PersonMentionEditor from './PersonMentionEditor.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type PersonMention = components['schemas']['PersonMention'];
|
||||
|
||||
type Props = {
|
||||
initialValue?: string;
|
||||
initialMentions?: PersonMention[];
|
||||
placeholder?: string;
|
||||
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
|
||||
};
|
||||
|
||||
let { initialValue = '', initialMentions = [], placeholder, onChange }: Props = $props();
|
||||
|
||||
// initial* props seed mount-time state; reading them inside untrack signals
|
||||
// the intentional one-shot capture and silences state_referenced_locally.
|
||||
let value = $state(untrack(() => initialValue));
|
||||
let mentionedPersons = $state<PersonMention[]>(untrack(() => [...initialMentions]));
|
||||
|
||||
$effect(() => {
|
||||
onChange({ value, mentionedPersons: [...mentionedPersons] });
|
||||
});
|
||||
</script>
|
||||
|
||||
<PersonMentionEditor
|
||||
bind:value={value}
|
||||
bind:mentionedPersons={mentionedPersons}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
@@ -12,25 +12,16 @@ const PERSONS = [
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
personType: 'PERSON'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
personType: 'PERSON'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
firstName: 'Karl',
|
||||
lastName: 'König',
|
||||
displayName: 'Karl König',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
{ id: '3', firstName: 'Karl', lastName: 'König', displayName: 'Karl König', personType: 'PERSON' }
|
||||
];
|
||||
|
||||
function mockFetch(persons = PERSONS) {
|
||||
@@ -71,16 +62,14 @@ describe('PersonMultiSelect – rendering', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
personType: 'PERSON'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -97,16 +86,14 @@ describe('PersonMultiSelect – rendering', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
personType: 'PERSON'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -125,8 +112,7 @@ describe('PersonMultiSelect – rendering', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -180,8 +166,7 @@ describe('PersonMultiSelect – selecting persons', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -202,8 +187,7 @@ describe('PersonMultiSelect – selecting persons', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]);
|
||||
render(PersonMultiSelect, { selectedPersons: [] });
|
||||
@@ -226,16 +210,14 @@ describe('PersonMultiSelect – removing persons', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
personType: 'PERSON'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -254,16 +236,14 @@ describe('PersonMultiSelect – removing persons', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
personType: 'PERSON'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -19,7 +19,6 @@ interface Props {
|
||||
autofocus?: boolean;
|
||||
required?: boolean;
|
||||
restrictToCorrespondentsOf?: string;
|
||||
excludePersonId?: string;
|
||||
badge?: 'additive' | 'replace';
|
||||
onchange?: (value: string) => void;
|
||||
onfocused?: () => void;
|
||||
@@ -37,7 +36,6 @@ let {
|
||||
autofocus = false,
|
||||
required = false,
|
||||
restrictToCorrespondentsOf,
|
||||
excludePersonId,
|
||||
badge,
|
||||
onchange,
|
||||
onfocused
|
||||
@@ -63,40 +61,21 @@ $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 ? filter(await res.json()) : [];
|
||||
return res.ok ? await res.json() : [];
|
||||
}
|
||||
if (term.length < 1) return [];
|
||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`);
|
||||
return res.ok ? filter(await res.json()) : [];
|
||||
return res.ok ? 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 = '';
|
||||
@@ -109,7 +88,6 @@ function handleInput() {
|
||||
|
||||
function handleFocus() {
|
||||
onfocused?.();
|
||||
updateDropdownPosition();
|
||||
if (restrictToCorrespondentsOf) {
|
||||
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
||||
(async () => {
|
||||
@@ -131,47 +109,13 @@ 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>
|
||||
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={closeDropdown}>
|
||||
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
|
||||
<label
|
||||
for="{name}-search"
|
||||
for={name}
|
||||
class={compact
|
||||
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||
: 'block text-sm font-medium text-ink-2'}
|
||||
@@ -181,22 +125,13 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
<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'
|
||||
@@ -205,34 +140,29 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
: '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 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.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 typeahead.loading}
|
||||
<li class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</li>
|
||||
<div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div>
|
||||
{:else}
|
||||
{#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' : ''}"
|
||||
{#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"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
tabindex="-1"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="block truncate font-medium">
|
||||
{person.displayName}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import { page } 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="option"]')!.click();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
await expect
|
||||
.element(page.getByRole('option', { name: 'Max Mustermann' }))
|
||||
.element(page.getByRole('button', { 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="option"]')!.click();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.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="option"]')!.click();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.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="option"]')!.click();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.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="option"]')!.click();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
await tick();
|
||||
expect(onchange).toHaveBeenCalledWith('1');
|
||||
onchange.mockClear();
|
||||
@@ -285,194 +285,3 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<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>
|
||||
@@ -1,55 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
<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>
|
||||
@@ -1,174 +0,0 @@
|
||||
<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>
|
||||
@@ -1,54 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
<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>
|
||||
@@ -1,90 +0,0 @@
|
||||
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([]) });
|
||||
});
|
||||
});
|
||||
@@ -1,548 +0,0 @@
|
||||
<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>
|
||||
@@ -1,349 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import StammbaumTree from './StammbaumTree.svelte';
|
||||
|
||||
const ID_A = '00000000-0000-0000-0000-000000000001';
|
||||
const ID_B = '00000000-0000-0000-0000-000000000002';
|
||||
|
||||
function parseViewBox(svg: SVGElement): [number, number, number, number] {
|
||||
const parts = svg.getAttribute('viewBox')!.split(/\s+/).map(Number);
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function rectsCentroid(svg: SVGElement): { x: number; y: number } {
|
||||
const rects = Array.from(svg.querySelectorAll('rect'));
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let n = 0;
|
||||
for (const r of rects) {
|
||||
const x = parseFloat(r.getAttribute('x') ?? '0');
|
||||
const y = parseFloat(r.getAttribute('y') ?? '0');
|
||||
const w = parseFloat(r.getAttribute('width') ?? '0');
|
||||
const h = parseFloat(r.getAttribute('height') ?? '0');
|
||||
// Skip the narrow accent stripe.
|
||||
if (w < 10) continue;
|
||||
// Each node rect lives inside <g transform="translate(...)">.
|
||||
const g = r.closest('g[transform]');
|
||||
const transform = g?.getAttribute('transform') ?? '';
|
||||
const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
|
||||
const tx = match ? parseFloat(match[1]) : 0;
|
||||
const ty = match ? parseFloat(match[2]) : 0;
|
||||
sx += tx + x + w / 2;
|
||||
sy += ty + y + h / 2;
|
||||
n++;
|
||||
}
|
||||
return { x: sx / n, y: sy / n };
|
||||
}
|
||||
|
||||
describe('StammbaumTree viewBox', () => {
|
||||
it('uses the minimum size and centers a single node', async () => {
|
||||
render(StammbaumTree, {
|
||||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||||
edges: [],
|
||||
selectedId: null,
|
||||
zoom: 1,
|
||||
onSelect: () => {}
|
||||
});
|
||||
|
||||
const svg = document.querySelector('svg')!;
|
||||
const [x, y, w, h] = parseViewBox(svg);
|
||||
|
||||
// Single 160x56 node fits inside the 1200x800 minimum viewBox.
|
||||
expect(w).toBe(1200);
|
||||
expect(h).toBe(800);
|
||||
|
||||
// Whatever absolute coordinates the layout uses, the viewBox must
|
||||
// centre on the rendered content.
|
||||
const c = rectsCentroid(svg);
|
||||
expect(x + w / 2).toBeCloseTo(c.x, 1);
|
||||
expect(y + h / 2).toBeCloseTo(c.y, 1);
|
||||
});
|
||||
|
||||
it('renders only orthogonal segments when two parents share two children', async () => {
|
||||
const PARENT_A = '00000000-0000-0000-0000-00000000000a';
|
||||
const PARENT_B = '00000000-0000-0000-0000-00000000000b';
|
||||
const CHILD_1 = '00000000-0000-0000-0000-00000000000c';
|
||||
const CHILD_2 = '00000000-0000-0000-0000-00000000000d';
|
||||
render(StammbaumTree, {
|
||||
nodes: [
|
||||
{ id: PARENT_A, displayName: 'Walter', familyMember: true },
|
||||
{ id: PARENT_B, displayName: 'Eugenie', familyMember: true },
|
||||
{ id: CHILD_1, displayName: 'Clara', familyMember: true },
|
||||
{ id: CHILD_2, displayName: 'Hans', familyMember: true }
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'sp',
|
||||
personId: PARENT_A,
|
||||
relatedPersonId: PARENT_B,
|
||||
personDisplayName: 'Walter',
|
||||
relatedPersonDisplayName: 'Eugenie',
|
||||
relationType: 'SPOUSE_OF'
|
||||
},
|
||||
{
|
||||
id: 'p1a',
|
||||
personId: PARENT_A,
|
||||
relatedPersonId: CHILD_1,
|
||||
personDisplayName: 'Walter',
|
||||
relatedPersonDisplayName: 'Clara',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 'p1b',
|
||||
personId: PARENT_B,
|
||||
relatedPersonId: CHILD_1,
|
||||
personDisplayName: 'Eugenie',
|
||||
relatedPersonDisplayName: 'Clara',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 'p2a',
|
||||
personId: PARENT_A,
|
||||
relatedPersonId: CHILD_2,
|
||||
personDisplayName: 'Walter',
|
||||
relatedPersonDisplayName: 'Hans',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 'p2b',
|
||||
personId: PARENT_B,
|
||||
relatedPersonId: CHILD_2,
|
||||
personDisplayName: 'Eugenie',
|
||||
relatedPersonDisplayName: 'Hans',
|
||||
relationType: 'PARENT_OF'
|
||||
}
|
||||
],
|
||||
selectedId: null,
|
||||
zoom: 1,
|
||||
onSelect: () => {}
|
||||
});
|
||||
|
||||
const lines = Array.from(document.querySelectorAll('svg line'));
|
||||
// Every parent-child segment must be either vertical (x1==x2) or
|
||||
// horizontal (y1==y2) — no slanted segments allowed.
|
||||
const slanted = lines.filter(
|
||||
(l) =>
|
||||
l.getAttribute('x1') !== l.getAttribute('x2') &&
|
||||
l.getAttribute('y1') !== l.getAttribute('y2')
|
||||
);
|
||||
expect(slanted).toHaveLength(0);
|
||||
// Sibling bar must exist and span the children: at least one
|
||||
// horizontal line whose x1 != x2 and y1 == y2.
|
||||
const horizontalBars = lines.filter(
|
||||
(l) =>
|
||||
l.getAttribute('y1') === l.getAttribute('y2') &&
|
||||
l.getAttribute('x1') !== l.getAttribute('x2')
|
||||
);
|
||||
expect(horizontalBars.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('positions a single child at the midpoint of its two parents (vertical drop)', async () => {
|
||||
const PARENT_A = '00000000-0000-0000-0000-00000000000a';
|
||||
const PARENT_B = '00000000-0000-0000-0000-00000000000b';
|
||||
const CHILD = '00000000-0000-0000-0000-00000000000c';
|
||||
render(StammbaumTree, {
|
||||
nodes: [
|
||||
{ id: PARENT_A, displayName: 'Walter', familyMember: true },
|
||||
{ id: PARENT_B, displayName: 'Eugenie', familyMember: true },
|
||||
{ id: CHILD, displayName: 'Hans', familyMember: true }
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'sp',
|
||||
personId: PARENT_A,
|
||||
relatedPersonId: PARENT_B,
|
||||
personDisplayName: 'Walter',
|
||||
relatedPersonDisplayName: 'Eugenie',
|
||||
relationType: 'SPOUSE_OF'
|
||||
},
|
||||
{
|
||||
id: 'p1',
|
||||
personId: PARENT_A,
|
||||
relatedPersonId: CHILD,
|
||||
personDisplayName: 'Walter',
|
||||
relatedPersonDisplayName: 'Hans',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
personId: PARENT_B,
|
||||
relatedPersonId: CHILD,
|
||||
personDisplayName: 'Eugenie',
|
||||
relatedPersonDisplayName: 'Hans',
|
||||
relationType: 'PARENT_OF'
|
||||
}
|
||||
],
|
||||
selectedId: null,
|
||||
zoom: 1,
|
||||
onSelect: () => {}
|
||||
});
|
||||
|
||||
const lines = Array.from(document.querySelectorAll('svg line'));
|
||||
// No slanted segments. With one child, no horizontal sibling bar
|
||||
// is needed because midX == child.center.x.
|
||||
const slanted = lines.filter(
|
||||
(l) =>
|
||||
l.getAttribute('x1') !== l.getAttribute('x2') &&
|
||||
l.getAttribute('y1') !== l.getAttribute('y2')
|
||||
);
|
||||
expect(slanted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('places a loose spouse adjacent to their partner and demotes their child a generation', async () => {
|
||||
// Walter ↔ Eugenie (gen 0); their children Hans + Clara (gen 1).
|
||||
// Hans ↔ Hilde (Hilde has no parents in graph). Hans + Hilde have
|
||||
// child Lili. Hilde must sit next to Hans, and Lili must be on a
|
||||
// row below Hans/Hilde — not on the same row.
|
||||
const WALTER = '00000000-0000-0000-0000-000000000001';
|
||||
const EUGENIE = '00000000-0000-0000-0000-000000000002';
|
||||
const HANS = '00000000-0000-0000-0000-000000000003';
|
||||
const CLARA = '00000000-0000-0000-0000-000000000004';
|
||||
const HILDE = '00000000-0000-0000-0000-000000000005';
|
||||
const LILI = '00000000-0000-0000-0000-000000000006';
|
||||
|
||||
render(StammbaumTree, {
|
||||
nodes: [
|
||||
{ id: WALTER, displayName: 'Walter', familyMember: true },
|
||||
{ id: EUGENIE, displayName: 'Eugenie', familyMember: true },
|
||||
{ id: HANS, displayName: 'Hans', familyMember: true },
|
||||
{ id: CLARA, displayName: 'Clara', familyMember: true },
|
||||
{ id: HILDE, displayName: 'Hilde', familyMember: true },
|
||||
{ id: LILI, displayName: 'Lili', familyMember: true }
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 's1',
|
||||
personId: WALTER,
|
||||
relatedPersonId: EUGENIE,
|
||||
personDisplayName: 'Walter',
|
||||
relatedPersonDisplayName: 'Eugenie',
|
||||
relationType: 'SPOUSE_OF'
|
||||
},
|
||||
{
|
||||
id: 'p1',
|
||||
personId: WALTER,
|
||||
relatedPersonId: HANS,
|
||||
personDisplayName: 'Walter',
|
||||
relatedPersonDisplayName: 'Hans',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
personId: EUGENIE,
|
||||
relatedPersonId: HANS,
|
||||
personDisplayName: 'Eugenie',
|
||||
relatedPersonDisplayName: 'Hans',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
personId: WALTER,
|
||||
relatedPersonId: CLARA,
|
||||
personDisplayName: 'Walter',
|
||||
relatedPersonDisplayName: 'Clara',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 'p4',
|
||||
personId: EUGENIE,
|
||||
relatedPersonId: CLARA,
|
||||
personDisplayName: 'Eugenie',
|
||||
relatedPersonDisplayName: 'Clara',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
personId: HANS,
|
||||
relatedPersonId: HILDE,
|
||||
personDisplayName: 'Hans',
|
||||
relatedPersonDisplayName: 'Hilde',
|
||||
relationType: 'SPOUSE_OF'
|
||||
},
|
||||
{
|
||||
id: 'p5',
|
||||
personId: HANS,
|
||||
relatedPersonId: LILI,
|
||||
personDisplayName: 'Hans',
|
||||
relatedPersonDisplayName: 'Lili',
|
||||
relationType: 'PARENT_OF'
|
||||
},
|
||||
{
|
||||
id: 'p6',
|
||||
personId: HILDE,
|
||||
relatedPersonId: LILI,
|
||||
personDisplayName: 'Hilde',
|
||||
relatedPersonDisplayName: 'Lili',
|
||||
relationType: 'PARENT_OF'
|
||||
}
|
||||
],
|
||||
selectedId: null,
|
||||
zoom: 1,
|
||||
onSelect: () => {}
|
||||
});
|
||||
|
||||
const ys = new Map<string, number>();
|
||||
for (const g of Array.from(document.querySelectorAll('g[transform]'))) {
|
||||
const aria = g.getAttribute('aria-label') ?? '';
|
||||
const transform = g.getAttribute('transform') ?? '';
|
||||
const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
|
||||
if (!match) continue;
|
||||
ys.set(aria.split(',')[0], parseFloat(match[2]));
|
||||
}
|
||||
|
||||
// Lili must be on a deeper row than Hans / Hilde.
|
||||
expect(ys.get('Lili')).toBeGreaterThan(ys.get('Hans')!);
|
||||
expect(ys.get('Hans')).toEqual(ys.get('Hilde'));
|
||||
|
||||
// Hans and Hilde must be horizontally adjacent (|Δx| == NODE_W + COL_GAP).
|
||||
const xs = new Map<string, number>();
|
||||
for (const g of Array.from(document.querySelectorAll('g[transform]'))) {
|
||||
const aria = g.getAttribute('aria-label') ?? '';
|
||||
const transform = g.getAttribute('transform') ?? '';
|
||||
const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
|
||||
if (!match) continue;
|
||||
xs.set(aria.split(',')[0], parseFloat(match[1]));
|
||||
}
|
||||
expect(Math.abs(xs.get('Hans')! - xs.get('Hilde')!)).toBe(160 + 40);
|
||||
|
||||
// All parent-child segments must be orthogonal.
|
||||
const lines = Array.from(document.querySelectorAll('svg line'));
|
||||
const slanted = lines.filter(
|
||||
(l) =>
|
||||
l.getAttribute('x1') !== l.getAttribute('x2') &&
|
||||
l.getAttribute('y1') !== l.getAttribute('y2')
|
||||
);
|
||||
expect(slanted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('centers two spouse nodes within the minimum viewBox', async () => {
|
||||
render(StammbaumTree, {
|
||||
nodes: [
|
||||
{ id: ID_A, displayName: 'Anna', familyMember: true },
|
||||
{ id: ID_B, displayName: 'Bertha', familyMember: true }
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'e1',
|
||||
personId: ID_A,
|
||||
relatedPersonId: ID_B,
|
||||
personDisplayName: 'Anna',
|
||||
relatedPersonDisplayName: 'Bertha',
|
||||
relationType: 'SPOUSE_OF'
|
||||
}
|
||||
],
|
||||
selectedId: null,
|
||||
zoom: 1,
|
||||
onSelect: () => {}
|
||||
});
|
||||
|
||||
const svg = document.querySelector('svg')!;
|
||||
const [x, y, w, h] = parseViewBox(svg);
|
||||
|
||||
expect(w).toBe(1200);
|
||||
expect(h).toBe(800);
|
||||
|
||||
const c = rectsCentroid(svg);
|
||||
expect(x + w / 2).toBeCloseTo(c.x, 1);
|
||||
expect(y + h / 2).toBeCloseTo(c.y, 1);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,6 @@
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
import PersonMentionEditor from './PersonMentionEditor.svelte';
|
||||
import type { PersonMention } from '$lib/types';
|
||||
|
||||
const { confirm } = getConfirmService();
|
||||
|
||||
@@ -14,14 +12,13 @@ type Props = {
|
||||
documentId: string;
|
||||
blockNumber: number;
|
||||
text: string;
|
||||
mentionedPersons: PersonMention[];
|
||||
label: string | null;
|
||||
active: boolean;
|
||||
reviewed: boolean;
|
||||
saveState: SaveState;
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
onTextChange: (text: string, mentionedPersons: PersonMention[]) => void;
|
||||
onTextChange: (text: string) => void;
|
||||
onFocus: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onRetry: () => void;
|
||||
@@ -38,7 +35,6 @@ let {
|
||||
documentId,
|
||||
blockNumber,
|
||||
text,
|
||||
mentionedPersons,
|
||||
label = null,
|
||||
active,
|
||||
reviewed,
|
||||
@@ -58,10 +54,10 @@ let {
|
||||
}: Props = $props();
|
||||
|
||||
let localText = $state(text);
|
||||
let localMentions = $state<PersonMention[]>([...mentionedPersons]);
|
||||
let commentOpen = $state(false);
|
||||
let commentCount = $state(0);
|
||||
let selectedQuote = $state<string | null>(null);
|
||||
let textareaEl = $state<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const hasComments = $derived(commentCount > 0);
|
||||
|
||||
@@ -70,7 +66,6 @@ let prevBlockId = $state(blockId);
|
||||
$effect(() => {
|
||||
if (blockId !== prevBlockId) {
|
||||
localText = text;
|
||||
localMentions = [...mentionedPersons];
|
||||
prevBlockId = blockId;
|
||||
}
|
||||
});
|
||||
@@ -79,19 +74,29 @@ let leftBorderClass = $derived(
|
||||
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : ''
|
||||
);
|
||||
|
||||
// Single source of truth for the editor's textarea — stored on attach so
|
||||
// we can read selection bounds for quote selection without re-querying the DOM.
|
||||
let textareaEl: HTMLTextAreaElement | null = null;
|
||||
|
||||
function captureTextarea(node: HTMLTextAreaElement) {
|
||||
function autoresize(node: HTMLTextAreaElement) {
|
||||
textareaEl = node;
|
||||
return () => {
|
||||
textareaEl = null;
|
||||
function resize() {
|
||||
node.style.height = 'auto';
|
||||
node.style.height = `${node.scrollHeight}px`;
|
||||
}
|
||||
|
||||
resize();
|
||||
|
||||
return {
|
||||
update() {
|
||||
resize();
|
||||
},
|
||||
destroy() {
|
||||
textareaEl = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
onTextChange(localText, localMentions);
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLTextAreaElement;
|
||||
localText = target.value;
|
||||
onTextChange(target.value);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
@@ -176,24 +181,17 @@ function handleTextareaMouseUp() {
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Textarea (now powered by PersonMentionEditor for @-mention typeahead) -->
|
||||
<div onmouseup={handleTextareaMouseUp} role="presentation">
|
||||
<PersonMentionEditor
|
||||
bind:value={() => localText,
|
||||
(v) => {
|
||||
localText = v;
|
||||
emitChange();
|
||||
}}
|
||||
bind:mentionedPersons={() => localMentions,
|
||||
(next) => {
|
||||
localMentions = next;
|
||||
emitChange();
|
||||
}}
|
||||
placeholder={m.transcription_block_placeholder()}
|
||||
onfocus={onFocus}
|
||||
captureTextarea={captureTextarea}
|
||||
/>
|
||||
</div>
|
||||
<!-- Textarea -->
|
||||
<textarea
|
||||
use:autoresize={localText}
|
||||
class="w-full resize-none border-none bg-transparent font-serif text-base leading-relaxed text-ink outline-none placeholder:text-ink-3"
|
||||
placeholder={m.transcription_block_placeholder()}
|
||||
rows={1}
|
||||
value={localText}
|
||||
oninput={handleInput}
|
||||
onfocus={onFocus}
|
||||
onmouseup={handleTextareaMouseUp}
|
||||
></textarea>
|
||||
|
||||
{#if selectedQuote}
|
||||
<p class="mt-1 text-xs text-ink-3">{m.transcription_block_quote_hint()}</p>
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { provideConfirmService, type ConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||
import type { PersonMention } from '$lib/types';
|
||||
|
||||
type BlockProps = {
|
||||
blockId: string;
|
||||
documentId: string;
|
||||
blockNumber: number;
|
||||
text: string;
|
||||
mentionedPersons?: PersonMention[];
|
||||
label: string | null;
|
||||
active: boolean;
|
||||
saveState: 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
onTextChange: (text: string, mentionedPersons: PersonMention[]) => void;
|
||||
onTextChange: (text: string) => void;
|
||||
onFocus: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onRetry: () => void;
|
||||
onReviewToggle?: () => void;
|
||||
onMoveUp?: () => void;
|
||||
onMoveDown?: () => void;
|
||||
isFirst?: boolean;
|
||||
@@ -27,22 +24,13 @@ type BlockProps = {
|
||||
|
||||
let {
|
||||
onServiceReady,
|
||||
mentionedPersons = [],
|
||||
reviewed = false,
|
||||
onReviewToggle = () => {},
|
||||
...blockProps
|
||||
}: BlockProps & {
|
||||
onServiceReady: (s: ConfirmService) => void;
|
||||
reviewed?: boolean;
|
||||
} = $props();
|
||||
|
||||
const service = provideConfirmService();
|
||||
onServiceReady(service);
|
||||
</script>
|
||||
|
||||
<TranscriptionBlock
|
||||
{...blockProps}
|
||||
mentionedPersons={mentionedPersons}
|
||||
reviewed={reviewed}
|
||||
onReviewToggle={onReviewToggle}
|
||||
/>
|
||||
<TranscriptionBlock {...blockProps} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||
import OcrTrigger from './OcrTrigger.svelte';
|
||||
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
|
||||
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
|
||||
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
|
||||
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
storedScriptType?: string;
|
||||
canRunOcr?: boolean;
|
||||
onBlockFocus: (blockId: string) => void;
|
||||
onSaveBlock: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>;
|
||||
onSaveBlock: (blockId: string, text: string) => Promise<void>;
|
||||
onDeleteBlock: (blockId: string) => Promise<void>;
|
||||
onReviewToggle: (blockId: string) => Promise<void>;
|
||||
onMarkAllReviewed?: () => Promise<void>;
|
||||
@@ -245,19 +245,16 @@ async function handleLabelToggle(label: string) {
|
||||
documentId={documentId}
|
||||
blockNumber={i + 1}
|
||||
text={block.text}
|
||||
mentionedPersons={block.mentionedPersons ?? []}
|
||||
label={block.label}
|
||||
active={activeBlockId === block.id}
|
||||
reviewed={block.reviewed ?? false}
|
||||
saveState={autoSave.getSaveState(block.id)}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
onTextChange={(text, mentions) =>
|
||||
autoSave.handleTextChange(block.id, text, mentions)}
|
||||
onTextChange={(text) => autoSave.handleTextChange(block.id, text)}
|
||||
onFocus={() => handleFocus(block.id)}
|
||||
onDeleteClick={() => handleDelete(block.id)}
|
||||
onRetry={() =>
|
||||
autoSave.handleRetry(block.id, block.text, block.mentionedPersons ?? [])}
|
||||
onRetry={() => autoSave.handleRetry(block.id, block.text)}
|
||||
onReviewToggle={() => onReviewToggle(block.id)}
|
||||
onMoveUp={() => handleMoveUp(block.id)}
|
||||
onMoveDown={() => handleMoveDown(block.id)}
|
||||
|
||||
@@ -15,8 +15,7 @@ const block1 = {
|
||||
sortOrder: 0,
|
||||
version: 0,
|
||||
source: 'MANUAL' as const,
|
||||
reviewed: false,
|
||||
mentionedPersons: []
|
||||
reviewed: false
|
||||
};
|
||||
const block2 = {
|
||||
id: 'b2',
|
||||
@@ -27,8 +26,7 @@ const block2 = {
|
||||
sortOrder: 1,
|
||||
version: 0,
|
||||
source: 'OCR' as const,
|
||||
reviewed: true,
|
||||
mentionedPersons: []
|
||||
reviewed: true
|
||||
};
|
||||
|
||||
function renderView(overrides: Record<string, unknown> = {}, service = createConfirmService()) {
|
||||
@@ -143,28 +141,7 @@ describe('TranscriptionEditView — auto-save debounce', () => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile', []);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('passes the block mentionedPersons array as the 3rd save argument', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
|
||||
const blockWithMention = {
|
||||
...block1,
|
||||
mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
|
||||
};
|
||||
renderView({ blocks: [blockWithMention], onSaveBlock });
|
||||
|
||||
const textarea = page.getByRole('textbox').first();
|
||||
await textarea.fill('Hallo @Auguste Raddatz');
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [
|
||||
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
|
||||
]);
|
||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -188,7 +165,7 @@ describe('TranscriptionEditView — auto-save debounce', () => {
|
||||
|
||||
// Only one save with the final value
|
||||
expect(onSaveBlock).toHaveBeenCalledTimes(1);
|
||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second', []);
|
||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -243,7 +220,7 @@ describe('TranscriptionEditView — flush on blur', () => {
|
||||
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text', []);
|
||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { splitByMarkers } from '$lib/utils/transcriptionMarkers';
|
||||
import { renderTranscriptionBody } from '$lib/utils/mention';
|
||||
import PersonHoverCard, { type LoadState } from './PersonHoverCard.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
type HoverData = { person: Person; relationships: RelationshipDTO[] };
|
||||
|
||||
interface Props {
|
||||
blocks: TranscriptionBlockData[];
|
||||
@@ -21,172 +11,9 @@ interface Props {
|
||||
let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();
|
||||
|
||||
let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
|
||||
// Per-page in-memory cache: a sweep across 20 mentions of the same person
|
||||
// must not fire 20 backend calls (B15.5). The Promise<HoverData | null> shape
|
||||
// lets simultaneous hovers share the same in-flight fetch.
|
||||
const hoverCache = new SvelteMap<string, Promise<HoverData | null>>();
|
||||
const deletedPersonIds = new SvelteSet<string>();
|
||||
|
||||
let activeCard: {
|
||||
personId: string;
|
||||
cardId: string;
|
||||
state: LoadState;
|
||||
position: { top: number; left: number };
|
||||
} | null = $state(null);
|
||||
|
||||
const CARD_WIDTH = 320;
|
||||
const CARD_HEIGHT = 180;
|
||||
const CARD_GAP = 6;
|
||||
|
||||
// Compose splitByMarkers with renderTranscriptionBody. Markers are pre-rendered
|
||||
// as <em data-marker> tags; text segments run through HTML-escaping + mention
|
||||
// substitution. The two are concatenated to preserve marker boundaries — markers
|
||||
// never end up nested inside an anchor (Felix #5324 B19b).
|
||||
function renderBlockHtml(block: TranscriptionBlockData): string {
|
||||
return splitByMarkers(block.text)
|
||||
.map((segment) => {
|
||||
if (segment.type === 'marker') {
|
||||
return `<em data-marker class="text-ink-2 italic">${segment.text}</em>`;
|
||||
}
|
||||
return renderTranscriptionBody(segment.text, block.mentionedPersons ?? []);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function fetchHoverData(personId: string): Promise<HoverData | null> {
|
||||
let cached = hoverCache.get(personId);
|
||||
if (cached) return cached;
|
||||
|
||||
cached = (async () => {
|
||||
const personRes = await fetch(`/api/persons/${personId}`);
|
||||
if (personRes.status === 404) return null;
|
||||
if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`);
|
||||
const person = (await personRes.json()) as Person;
|
||||
|
||||
const relRes = await fetch(`/api/persons/${personId}/relationships`);
|
||||
const relationships: RelationshipDTO[] = relRes.ok
|
||||
? ((await relRes.json()) as RelationshipDTO[])
|
||||
: [];
|
||||
return { person, relationships };
|
||||
})();
|
||||
|
||||
hoverCache.set(personId, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
function computeCardPosition(rect: DOMRect): { top: number; left: number } {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let top = rect.bottom + CARD_GAP;
|
||||
let left = rect.left;
|
||||
|
||||
// Flip up if the card would overflow the bottom edge OR the mention sits in
|
||||
// the bottom 30% of the viewport (Leonie #5329).
|
||||
if (vh - rect.bottom < CARD_HEIGHT + CARD_GAP || rect.top > vh * 0.7) {
|
||||
top = rect.top - CARD_HEIGHT - CARD_GAP;
|
||||
}
|
||||
|
||||
// Flip left if <300px from the right edge.
|
||||
if (vw - rect.left < 300) {
|
||||
left = rect.right - CARD_WIDTH;
|
||||
}
|
||||
|
||||
return {
|
||||
top: Math.max(0, top + window.scrollY),
|
||||
left: Math.max(0, left + window.scrollX)
|
||||
};
|
||||
}
|
||||
|
||||
async function handleMentionEnter(event: Event) {
|
||||
const link = event.target as HTMLAnchorElement;
|
||||
const personId = link.dataset.personId;
|
||||
if (!personId) return;
|
||||
if (deletedPersonIds.has(personId)) return;
|
||||
|
||||
const cardId = `person-hover-card-${personId}`;
|
||||
link.setAttribute('aria-describedby', cardId);
|
||||
|
||||
const rect = link.getBoundingClientRect();
|
||||
const position = computeCardPosition(rect);
|
||||
|
||||
activeCard = { personId, cardId, position, state: { status: 'loading' } };
|
||||
|
||||
try {
|
||||
const data = await fetchHoverData(personId);
|
||||
// Bail if a different mention is now active
|
||||
if (!activeCard || activeCard.personId !== personId) return;
|
||||
|
||||
if (data === null) {
|
||||
deletedPersonIds.add(personId);
|
||||
link.setAttribute('data-person-deleted', 'true');
|
||||
activeCard = null;
|
||||
return;
|
||||
}
|
||||
|
||||
activeCard = {
|
||||
personId,
|
||||
cardId,
|
||||
position,
|
||||
state: { status: 'loaded', person: data.person, relationships: data.relationships }
|
||||
};
|
||||
} catch {
|
||||
if (!activeCard || activeCard.personId !== personId) return;
|
||||
activeCard = { personId, cardId, position, state: { status: 'error' } };
|
||||
}
|
||||
}
|
||||
|
||||
function handleMentionLeave(event: Event) {
|
||||
const link = event.target as HTMLAnchorElement;
|
||||
link.removeAttribute('aria-describedby');
|
||||
activeCard = null;
|
||||
}
|
||||
|
||||
async function handleMentionClick(event: MouseEvent) {
|
||||
const link = event.target as HTMLAnchorElement;
|
||||
const personId = link.dataset.personId;
|
||||
if (!personId) return;
|
||||
if (deletedPersonIds.has(personId)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
await goto(`/persons/${personId}`);
|
||||
}
|
||||
|
||||
// Attach delegated event listeners on each rendered block. Using {@html ...}
|
||||
// for the body means we cannot bind events declaratively to the injected
|
||||
// anchors, so we hook up listeners via a Svelte action when the wrapper mounts.
|
||||
function attachMentionHandlers(node: HTMLElement) {
|
||||
function onEnter(e: Event) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.matches?.('a.person-mention')) handleMentionEnter(e);
|
||||
}
|
||||
function onLeave(e: Event) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.matches?.('a.person-mention')) handleMentionLeave(e);
|
||||
}
|
||||
function onClick(e: MouseEvent) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.matches?.('a.person-mention')) handleMentionClick(e);
|
||||
}
|
||||
// mouseenter does not bubble — capture it.
|
||||
node.addEventListener('mouseenter', onEnter, true);
|
||||
node.addEventListener('mouseleave', onLeave, true);
|
||||
node.addEventListener('click', onClick);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('mouseenter', onEnter, true);
|
||||
node.removeEventListener('mouseleave', onLeave, true);
|
||||
node.removeEventListener('click', onClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="px-6 py-8" use:attachMentionHandlers>
|
||||
<article class="px-6 py-8">
|
||||
{#each sorted as block (block.id)}
|
||||
<div
|
||||
class="-mx-2 mb-6 cursor-pointer rounded-sm px-2 py-1 font-serif text-[16px] leading-[1.85] text-ink transition-colors hover:bg-turquoise/10"
|
||||
@@ -195,25 +22,19 @@ function attachMentionHandlers(node: HTMLElement) {
|
||||
onclick={() => onParagraphClick(block.annotationId)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId);
|
||||
}}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId); }}
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderTranscriptionBody escapes all HTML before injecting mention links; mirrors CommentMessage.svelte -->
|
||||
{@html renderBlockHtml(block)}
|
||||
{#each splitByMarkers(block.text) as segment, i (i)}
|
||||
{#if segment.type === 'marker'}
|
||||
<em data-marker class="text-ink-2 italic">{segment.text}</em>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</article>
|
||||
|
||||
{#if activeCard}
|
||||
<PersonHoverCard
|
||||
personId={activeCard.personId}
|
||||
cardId={activeCard.cardId}
|
||||
position={activeCard.position}
|
||||
state={activeCard.state}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes flash {
|
||||
0% {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscriptionReadView from './TranscriptionReadView.svelte';
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
@@ -12,10 +12,7 @@ const blocks: TranscriptionBlockData[] = [
|
||||
text: 'First paragraph text.',
|
||||
label: null,
|
||||
sortOrder: 1,
|
||||
version: 1,
|
||||
source: 'MANUAL',
|
||||
reviewed: false,
|
||||
mentionedPersons: []
|
||||
version: 1
|
||||
},
|
||||
{
|
||||
id: 'b2',
|
||||
@@ -24,10 +21,7 @@ const blocks: TranscriptionBlockData[] = [
|
||||
text: 'Second paragraph text.',
|
||||
label: null,
|
||||
sortOrder: 2,
|
||||
version: 1,
|
||||
source: 'MANUAL',
|
||||
reviewed: false,
|
||||
mentionedPersons: []
|
||||
version: 1
|
||||
}
|
||||
];
|
||||
|
||||
@@ -55,10 +49,7 @@ describe('TranscriptionReadView', () => {
|
||||
text: 'Text before [unleserlich] text after',
|
||||
label: null,
|
||||
sortOrder: 1,
|
||||
version: 1,
|
||||
source: 'MANUAL',
|
||||
reviewed: false,
|
||||
mentionedPersons: []
|
||||
version: 1
|
||||
}
|
||||
],
|
||||
onParagraphClick: () => {}
|
||||
@@ -80,10 +71,7 @@ describe('TranscriptionReadView', () => {
|
||||
text: 'Some [...] text',
|
||||
label: null,
|
||||
sortOrder: 1,
|
||||
version: 1,
|
||||
source: 'MANUAL',
|
||||
reviewed: false,
|
||||
mentionedPersons: []
|
||||
version: 1
|
||||
}
|
||||
],
|
||||
onParagraphClick: () => {}
|
||||
@@ -152,241 +140,3 @@ describe('TranscriptionReadView', () => {
|
||||
expect(paragraphs.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TranscriptionReadView — person-mention rendering', () => {
|
||||
const PERSON_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
const mentionBlock: TranscriptionBlockData = {
|
||||
id: 'b1',
|
||||
annotationId: 'ann-1',
|
||||
documentId: 'doc-1',
|
||||
text: 'Brief an @Auguste Raddatz vom Mai',
|
||||
label: null,
|
||||
sortOrder: 1,
|
||||
version: 1,
|
||||
source: 'MANUAL',
|
||||
reviewed: false,
|
||||
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste Raddatz' }]
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Default: any /api/persons/{id} call returns 404 unless a test overrides it.
|
||||
// Tests that need loaded data stub fetch themselves.
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders a person mention as an anchor link with the person URL', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector(`a.person-mention[data-person-id="${PERSON_ID}"]`)!;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link.getAttribute('href')).toBe(`/persons/${PERSON_ID}`);
|
||||
expect(link.textContent).toBe('Auguste Raddatz');
|
||||
});
|
||||
|
||||
it('strips the @ trigger from the rendered link text (read mode)', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const block = document.querySelector('[data-block-id="b1"]')!;
|
||||
expect(block.textContent).not.toContain('@Auguste Raddatz');
|
||||
expect(block.textContent).toContain('Auguste Raddatz');
|
||||
});
|
||||
|
||||
it('renders mention link AND [unleserlich] marker correctly when both occur in the same block (B19b)', async () => {
|
||||
const block: TranscriptionBlockData = {
|
||||
...mentionBlock,
|
||||
text: 'Hallo @Auguste Raddatz [unleserlich] Marie'
|
||||
};
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [block],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
// Mention rendered as an anchor
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link.textContent).toBe('Auguste Raddatz');
|
||||
|
||||
// Marker rendered as <em data-marker>
|
||||
const marker = document.querySelector('[data-marker]')!;
|
||||
expect(marker).not.toBeNull();
|
||||
expect(marker.textContent).toBe('[unleserlich]');
|
||||
|
||||
// Marker text is NOT inside the anchor — they are siblings, not nested
|
||||
expect(link.contains(marker)).toBe(false);
|
||||
|
||||
// No double-escape — text content reads cleanly
|
||||
const blockEl = document.querySelector('[data-block-id="b1"]')!;
|
||||
expect(blockEl.textContent).not.toContain('&');
|
||||
expect(blockEl.textContent).not.toContain('<');
|
||||
});
|
||||
|
||||
it('does not render mention link for plain text without the @ trigger', async () => {
|
||||
const plain: TranscriptionBlockData = {
|
||||
...mentionBlock,
|
||||
text: 'Auguste Raddatz war hier',
|
||||
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste Raddatz' }]
|
||||
};
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [plain],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention');
|
||||
expect(link).toBeNull();
|
||||
});
|
||||
|
||||
it('escapes HTML in the block text — no stored XSS via raw text', async () => {
|
||||
const xss: TranscriptionBlockData = {
|
||||
...mentionBlock,
|
||||
text: '<img src=x onerror=alert(1)>',
|
||||
mentionedPersons: []
|
||||
};
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [xss],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
// No raw <img> tag in DOM
|
||||
expect(document.querySelector('[data-block-id="b1"] img')).toBeNull();
|
||||
// The escaped text is visible
|
||||
const block = document.querySelector('[data-block-id="b1"]')!;
|
||||
expect(block.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
|
||||
it('triggers fetch for the person on mention mouseenter (B15.5 cache, single call)', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
json: vi.fn()
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const personFetches = fetchMock.mock.calls.filter((c) =>
|
||||
String(c[0]).includes(`/api/persons/${PERSON_ID}`)
|
||||
);
|
||||
expect(personFetches.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('deduplicates fetches for the same personId across multiple mouseenter events (B15.5)', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
json: vi.fn()
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
// Two blocks both mention the same person
|
||||
const block2: TranscriptionBlockData = { ...mentionBlock, id: 'b2', annotationId: 'ann-2' };
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock, block2],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const links = document.querySelectorAll('a.person-mention');
|
||||
links.forEach((link) => link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })));
|
||||
// Plus a re-hover on the first
|
||||
links[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const personFetches = fetchMock.mock.calls.filter(
|
||||
(c) => String(c[0]) === `/api/persons/${PERSON_ID}`
|
||||
);
|
||||
expect(personFetches.length).toBe(1);
|
||||
});
|
||||
|
||||
it('mounts the hover card on mouseenter when the fetch loads', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((url: string) => {
|
||||
if (url.endsWith('/relationships')) {
|
||||
return Promise.resolve({ status: 200, ok: true, json: () => Promise.resolve([]) });
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: PERSON_ID,
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Auguste Raddatz',
|
||||
personType: 'PERSON',
|
||||
familyMember: true,
|
||||
birthYear: 1882,
|
||||
deathYear: 1944
|
||||
})
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||
expect(card).not.toBeNull();
|
||||
});
|
||||
|
||||
it('unmounts the hover card on mouseleave', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
link.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||
expect(card).toBeNull();
|
||||
});
|
||||
|
||||
it('degrades to plain unlinked text when the person fetch returns 404', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
|
||||
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// 404 → no card mounted
|
||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||
expect(card).toBeNull();
|
||||
// Anchor is marked as deleted so subsequent hovers/clicks treat it as plain text
|
||||
const stillLink = document.querySelector('a.person-mention')!;
|
||||
expect(stillLink.getAttribute('data-person-deleted')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ export type ErrorCode =
|
||||
| 'PERSON_NOT_FOUND'
|
||||
| 'ALIAS_NOT_FOUND'
|
||||
| 'INVALID_PERSON_TYPE'
|
||||
| 'PERSON_RENAME_CONFLICT'
|
||||
| 'DOCUMENT_NOT_FOUND'
|
||||
| 'DOCUMENT_NO_FILE'
|
||||
| 'FILE_NOT_FOUND'
|
||||
@@ -39,9 +38,6 @@ export type ErrorCode =
|
||||
| 'TAG_NOT_FOUND'
|
||||
| 'TAG_MERGE_SELF'
|
||||
| 'TAG_MERGE_INVALID_TARGET'
|
||||
| 'RELATIONSHIP_NOT_FOUND'
|
||||
| 'CIRCULAR_RELATIONSHIP'
|
||||
| 'DUPLICATE_RELATIONSHIP'
|
||||
| 'MISSING_CREDENTIALS'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
@@ -80,8 +76,6 @@ 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':
|
||||
@@ -142,12 +136,6 @@ 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,22 +132,6 @@ 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;
|
||||
@@ -228,22 +212,6 @@ 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;
|
||||
@@ -644,22 +612,6 @@ 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;
|
||||
@@ -900,22 +852,6 @@ 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;
|
||||
@@ -948,22 +884,6 @@ 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;
|
||||
@@ -1124,22 +1044,6 @@ 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;
|
||||
@@ -1428,22 +1332,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons/{id}/relationships/{relId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["deleteRelationship"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons/{id}/aliases/{aliasId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1542,8 +1430,6 @@ export interface components {
|
||||
color?: string;
|
||||
};
|
||||
PersonUpdateDTO: {
|
||||
/** @enum {string} */
|
||||
personType: "PERSON" | "INSTITUTION" | "GROUP" | "UNKNOWN" | "SKIP";
|
||||
title?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
@@ -1568,7 +1454,6 @@ export interface components {
|
||||
birthYear?: number;
|
||||
/** Format: int32 */
|
||||
deathYear?: number;
|
||||
familyMember: boolean;
|
||||
readonly displayName: string;
|
||||
};
|
||||
DocumentUpdateDTO: {
|
||||
@@ -1577,8 +1462,6 @@ export interface components {
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
documentLocation?: string;
|
||||
archiveBox?: string;
|
||||
archiveFolder?: string;
|
||||
transcription?: string;
|
||||
summary?: string;
|
||||
/** Format: uuid */
|
||||
@@ -1627,15 +1510,9 @@ 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 */
|
||||
@@ -1645,7 +1522,6 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
text?: string;
|
||||
mentionedPersons: components["schemas"]["PersonMention"][];
|
||||
label?: string;
|
||||
/** Format: int32 */
|
||||
sortOrder: number;
|
||||
@@ -1685,42 +1561,6 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
targetId: string;
|
||||
};
|
||||
CreateRelationshipRequest: {
|
||||
/** Format: uuid */
|
||||
relatedPersonId: string;
|
||||
/** @enum {string} */
|
||||
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
|
||||
/** Format: int32 */
|
||||
fromYear?: number;
|
||||
/** Format: int32 */
|
||||
toYear?: number;
|
||||
notes?: string;
|
||||
};
|
||||
RelationshipDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: uuid */
|
||||
personId: string;
|
||||
/** Format: uuid */
|
||||
relatedPersonId: string;
|
||||
personDisplayName: string;
|
||||
/** Format: int32 */
|
||||
personBirthYear?: number;
|
||||
/** Format: int32 */
|
||||
personDeathYear?: number;
|
||||
relatedPersonDisplayName: string;
|
||||
/** Format: int32 */
|
||||
relatedPersonBirthYear?: number;
|
||||
/** Format: int32 */
|
||||
relatedPersonDeathYear?: number;
|
||||
/** @enum {string} */
|
||||
relationType: "PARENT_OF" | "SPOUSE_OF" | "SIBLING_OF" | "FRIEND" | "COLLEAGUE" | "EMPLOYER" | "DOCTOR" | "NEIGHBOR" | "OTHER";
|
||||
/** Format: int32 */
|
||||
fromYear?: number;
|
||||
/** Format: int32 */
|
||||
toYear?: number;
|
||||
notes?: string;
|
||||
};
|
||||
PersonNameAliasDTO: {
|
||||
lastName: string;
|
||||
firstName?: string;
|
||||
@@ -1820,7 +1660,6 @@ export interface components {
|
||||
height?: number;
|
||||
text?: string;
|
||||
label?: string;
|
||||
mentionedPersons?: components["schemas"]["PersonMention"][];
|
||||
};
|
||||
CreateCommentDTO: {
|
||||
content?: string;
|
||||
@@ -1969,9 +1808,6 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
count: number;
|
||||
};
|
||||
FamilyMemberPatchDTO: {
|
||||
familyMember?: boolean;
|
||||
};
|
||||
NotificationDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -2076,38 +1912,15 @@ export interface components {
|
||||
displayName?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
personType?: string;
|
||||
/** Format: int32 */
|
||||
birthYear?: number;
|
||||
/** Format: int32 */
|
||||
deathYear?: number;
|
||||
familyMember?: boolean;
|
||||
notes?: 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;
|
||||
alias?: string;
|
||||
notes?: string;
|
||||
personType?: string;
|
||||
};
|
||||
SenderModel: {
|
||||
/** Format: uuid */
|
||||
@@ -2208,10 +2021,6 @@ export interface components {
|
||||
empty?: boolean;
|
||||
unsorted?: boolean;
|
||||
};
|
||||
NetworkDTO: {
|
||||
nodes: components["schemas"]["PersonNodeDTO"][];
|
||||
edges: components["schemas"]["RelationshipDTO"][];
|
||||
};
|
||||
DocumentVersionSummary: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -2322,7 +2131,7 @@ export interface components {
|
||||
};
|
||||
ActivityFeedItemDTO: {
|
||||
/** @enum {string} */
|
||||
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED";
|
||||
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED";
|
||||
actor?: components["schemas"]["ActivityActorDTO"];
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
@@ -2772,28 +2581,6 @@ 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;
|
||||
@@ -2958,54 +2745,6 @@ 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;
|
||||
@@ -3752,32 +3491,6 @@ 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;
|
||||
@@ -4176,28 +3889,6 @@ 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;
|
||||
@@ -4244,29 +3935,6 @@ 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;
|
||||
@@ -4482,26 +4150,6 @@ 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;
|
||||
@@ -4822,7 +4470,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" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED")[];
|
||||
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED")[];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -4923,27 +4571,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteRelationship: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
relId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
removeAlias: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||
import type { PersonMention } from '$lib/types';
|
||||
|
||||
const mockSaveFn =
|
||||
vi.fn<(blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>>();
|
||||
|
||||
const NO_MENTIONS: PersonMention[] = [];
|
||||
const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise<void>>();
|
||||
|
||||
const { createBlockAutoSave } = await import('../useBlockAutoSave.svelte');
|
||||
|
||||
@@ -26,25 +22,25 @@ describe('createBlockAutoSave', () => {
|
||||
|
||||
it('debounce coalesces multiple changes — saves once after 1500ms', async () => {
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
as.handleTextChange('block-1', 'text 1', NO_MENTIONS);
|
||||
as.handleTextChange('block-1', 'text 2', NO_MENTIONS);
|
||||
as.handleTextChange('block-1', 'text 3', NO_MENTIONS);
|
||||
as.handleTextChange('block-1', 'text 1');
|
||||
as.handleTextChange('block-1', 'text 2');
|
||||
as.handleTextChange('block-1', 'text 3');
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
expect(mockSaveFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3', NO_MENTIONS);
|
||||
expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3');
|
||||
});
|
||||
|
||||
it('handles concurrent blocks independently', async () => {
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
as.handleTextChange('block-1', 'hello', NO_MENTIONS);
|
||||
as.handleTextChange('block-2', 'world', NO_MENTIONS);
|
||||
as.handleTextChange('block-1', 'hello');
|
||||
as.handleTextChange('block-2', 'world');
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
expect(mockSaveFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('sets save state to saving then saved on success', async () => {
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||
as.handleTextChange('block-1', 'text');
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(as.getSaveState('block-1')).toBe('saving');
|
||||
await Promise.resolve();
|
||||
@@ -54,7 +50,7 @@ describe('createBlockAutoSave', () => {
|
||||
it('sets save state to error on save failure', async () => {
|
||||
mockSaveFn.mockRejectedValue(new Error('save failed'));
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||
as.handleTextChange('block-1', 'text');
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
expect(as.getSaveState('block-1')).toBe('error');
|
||||
});
|
||||
@@ -63,49 +59,24 @@ describe('createBlockAutoSave', () => {
|
||||
mockSaveFn.mockRejectedValueOnce(new Error('first fails'));
|
||||
mockSaveFn.mockResolvedValueOnce(undefined);
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
as.handleTextChange('block-1', 'original', NO_MENTIONS);
|
||||
as.handleTextChange('block-1', 'original');
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
expect(as.getSaveState('block-1')).toBe('error');
|
||||
await as.handleRetry('block-1', 'original', NO_MENTIONS);
|
||||
await as.handleRetry('block-1', 'original');
|
||||
expect(mockSaveFn).toHaveBeenCalledTimes(2);
|
||||
expect(as.getSaveState('block-1')).toBe('saved');
|
||||
});
|
||||
|
||||
it('preserves the in-flight text + mentionedPersons across a save failure (B12)', async () => {
|
||||
// Hold the second saveFn so we can observe the saving→saved transition
|
||||
// (Tester #5506 §5).
|
||||
let resolveSecond!: () => void;
|
||||
mockSaveFn.mockRejectedValueOnce(new Error('boom'));
|
||||
mockSaveFn.mockReturnValueOnce(new Promise<void>((r) => (resolveSecond = r)));
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
|
||||
const mentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }];
|
||||
as.handleTextChange('block-1', '@Auguste Raddatz hi', mentions);
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
expect(as.getSaveState('block-1')).toBe('error');
|
||||
|
||||
// Retry without re-passing the data — the hook resends the preserved payload.
|
||||
const retryPromise = as.handleRetry('block-1', 'should-not-be-used', []);
|
||||
// Yield once so executeSave runs synchronously up to the saveFn await.
|
||||
await Promise.resolve();
|
||||
expect(as.getSaveState('block-1')).toBe('saving');
|
||||
expect(mockSaveFn).toHaveBeenLastCalledWith('block-1', '@Auguste Raddatz hi', mentions);
|
||||
|
||||
resolveSecond();
|
||||
await retryPromise;
|
||||
expect(as.getSaveState('block-1')).toBe('saved');
|
||||
});
|
||||
|
||||
it('clearBlock removes all state for a block', () => {
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||
as.handleTextChange('block-1', 'text');
|
||||
as.clearBlock('block-1');
|
||||
expect(as.getSaveState('block-1')).toBe('idle');
|
||||
});
|
||||
|
||||
it('destroy clears all pending timers so no save occurs', async () => {
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||
as.handleTextChange('block-1', 'text');
|
||||
as.destroy();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(mockSaveFn).not.toHaveBeenCalled();
|
||||
@@ -130,8 +101,8 @@ describe('flushOnUnload', () => {
|
||||
|
||||
it('sends a PUT request with keepalive:true for each pending block', () => {
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
as.handleTextChange('block-1', 'hello', NO_MENTIONS);
|
||||
as.handleTextChange('block-2', 'world', NO_MENTIONS);
|
||||
as.handleTextChange('block-1', 'hello');
|
||||
as.handleTextChange('block-2', 'world');
|
||||
as.flushOnUnload();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
@@ -140,7 +111,7 @@ describe('flushOnUnload', () => {
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
keepalive: true,
|
||||
body: JSON.stringify({ text: 'hello', mentionedPersons: [] })
|
||||
body: JSON.stringify({ text: 'hello' })
|
||||
})
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
@@ -148,7 +119,7 @@ describe('flushOnUnload', () => {
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
keepalive: true,
|
||||
body: JSON.stringify({ text: 'world', mentionedPersons: [] })
|
||||
body: JSON.stringify({ text: 'world' })
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -156,7 +127,7 @@ describe('flushOnUnload', () => {
|
||||
it('does not call navigator.sendBeacon', () => {
|
||||
const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||
as.handleTextChange('block-1', 'text');
|
||||
as.flushOnUnload();
|
||||
|
||||
expect(sendBeaconSpy).not.toHaveBeenCalled();
|
||||
@@ -171,7 +142,7 @@ describe('flushOnUnload', () => {
|
||||
|
||||
it('cancels the debounce timer so saveFn is not also called', async () => {
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||
as.handleTextChange('block-1', 'text');
|
||||
as.flushOnUnload();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
@@ -180,26 +151,13 @@ describe('flushOnUnload', () => {
|
||||
|
||||
it('does not send fetch if debounce already fired and pendingTexts is empty', async () => {
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
as.handleTextChange('block-1', 'text', NO_MENTIONS);
|
||||
as.handleTextChange('block-1', 'text');
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
// debounce has fired; pendingTexts should be empty now
|
||||
mockFetch.mockClear();
|
||||
|
||||
as.flushOnUnload();
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes the pending mentionedPersons sidecar alongside text', () => {
|
||||
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
|
||||
const mentions: PersonMention[] = [{ personId: 'p-1', displayName: 'Auguste Raddatz' }];
|
||||
as.handleTextChange('block-1', '@Auguste Raddatz', mentions);
|
||||
as.flushOnUnload();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/documents/doc-1/transcription-blocks/block-1',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ text: '@Auguste Raddatz', mentionedPersons: mentions })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,7 @@ function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
|
||||
sortOrder,
|
||||
version: 1,
|
||||
source: 'MANUAL',
|
||||
reviewed: false,
|
||||
mentionedPersons: []
|
||||
reviewed: false
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import type { PersonMention } from '$lib/types';
|
||||
|
||||
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
||||
|
||||
type Options = {
|
||||
saveFn: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>;
|
||||
saveFn: (blockId: string, text: string) => Promise<void>;
|
||||
documentId: string;
|
||||
};
|
||||
|
||||
@@ -12,7 +11,6 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
||||
const saveStates = new SvelteMap<string, SaveState>();
|
||||
const debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
|
||||
const pendingTexts = new SvelteMap<string, string>();
|
||||
const pendingMentions = new SvelteMap<string, PersonMention[]>();
|
||||
const fadeTimers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
function getSaveState(blockId: string): SaveState {
|
||||
@@ -27,19 +25,14 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
||||
const text = pendingTexts.get(blockId);
|
||||
if (text === undefined) return;
|
||||
|
||||
const mentions = pendingMentions.get(blockId) ?? [];
|
||||
pendingTexts.delete(blockId);
|
||||
pendingMentions.delete(blockId);
|
||||
setSaveState(blockId, 'saving');
|
||||
|
||||
try {
|
||||
await saveFn(blockId, text, mentions);
|
||||
await saveFn(blockId, text);
|
||||
setSaveState(blockId, 'saved');
|
||||
scheduleSavedFade(blockId);
|
||||
} catch {
|
||||
// Preserve in-flight payload so the user can retry without re-typing.
|
||||
pendingTexts.set(blockId, text);
|
||||
pendingMentions.set(blockId, mentions);
|
||||
setSaveState(blockId, 'error');
|
||||
}
|
||||
}
|
||||
@@ -76,13 +69,8 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleTextChange(
|
||||
blockId: string,
|
||||
text: string,
|
||||
mentionedPersons: PersonMention[]
|
||||
): void {
|
||||
function handleTextChange(blockId: string, text: string): void {
|
||||
pendingTexts.set(blockId, text);
|
||||
pendingMentions.set(blockId, mentionedPersons);
|
||||
scheduleDebounce(blockId);
|
||||
}
|
||||
|
||||
@@ -93,37 +81,29 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetry(
|
||||
blockId: string,
|
||||
currentText: string,
|
||||
currentMentions: PersonMention[]
|
||||
): Promise<void> {
|
||||
const text = pendingTexts.get(blockId) ?? currentText;
|
||||
const mentions = pendingMentions.get(blockId) ?? currentMentions;
|
||||
async function handleRetry(blockId: string, currentText: string): Promise<void> {
|
||||
const pending = pendingTexts.get(blockId);
|
||||
const text = pending ?? currentText;
|
||||
pendingTexts.set(blockId, text);
|
||||
pendingMentions.set(blockId, mentions);
|
||||
await executeSave(blockId);
|
||||
}
|
||||
|
||||
function clearBlock(blockId: string): void {
|
||||
clearDebounce(blockId);
|
||||
pendingTexts.delete(blockId);
|
||||
pendingMentions.delete(blockId);
|
||||
saveStates.delete(blockId);
|
||||
}
|
||||
|
||||
function flushOnUnload(): void {
|
||||
for (const [blockId, text] of pendingTexts) {
|
||||
const mentions = pendingMentions.get(blockId) ?? [];
|
||||
clearDebounce(blockId);
|
||||
void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, mentionedPersons: mentions }),
|
||||
body: JSON.stringify({ text }),
|
||||
keepalive: true
|
||||
});
|
||||
pendingTexts.delete(blockId);
|
||||
pendingMentions.delete(blockId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,6 @@ export type Comment = {
|
||||
|
||||
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||
|
||||
export type PersonMention = {
|
||||
personId: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export type TranscriptionBlockData = {
|
||||
id: string;
|
||||
annotationId: string;
|
||||
@@ -52,7 +47,6 @@ export type TranscriptionBlockData = {
|
||||
version: number;
|
||||
source: 'MANUAL' | 'OCR';
|
||||
reviewed: boolean;
|
||||
mentionedPersons: PersonMention[];
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { BlockConflictResolvedError, mergeBlockOnConflict } from './blockConflictMerge';
|
||||
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
|
||||
|
||||
const baseBlock: TranscriptionBlockData = {
|
||||
id: 'b1',
|
||||
annotationId: 'a1',
|
||||
documentId: 'd1',
|
||||
text: 'old text from server',
|
||||
label: null,
|
||||
sortOrder: 0,
|
||||
version: 7,
|
||||
source: 'MANUAL',
|
||||
reviewed: false,
|
||||
mentionedPersons: []
|
||||
};
|
||||
|
||||
describe('mergeBlockOnConflict', () => {
|
||||
it('keeps the local unsaved text — never overwritten by server text (B12b)', () => {
|
||||
const merged = mergeBlockOnConflict({
|
||||
serverBlock: { ...baseBlock, text: 'server-side text' },
|
||||
localText: 'transcriber unsaved input',
|
||||
localMentions: []
|
||||
});
|
||||
expect(merged.text).toBe('transcriber unsaved input');
|
||||
});
|
||||
|
||||
it('takes server-side displayName for personIds present on both sides (rename win)', () => {
|
||||
const localMentions: PersonMention[] = [
|
||||
{ personId: 'p-aug', displayName: 'Auguste Raddatz' } // stale: server renamed her
|
||||
];
|
||||
const serverMentions: PersonMention[] = [
|
||||
{ personId: 'p-aug', displayName: 'Augusta Raddatz' } // post-rename
|
||||
];
|
||||
const merged = mergeBlockOnConflict({
|
||||
serverBlock: { ...baseBlock, mentionedPersons: serverMentions },
|
||||
localText: '@Augusta Raddatz',
|
||||
localMentions
|
||||
});
|
||||
expect(merged.mentionedPersons).toEqual([
|
||||
{ personId: 'p-aug', displayName: 'Augusta Raddatz' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps local-only mentions added since last save', () => {
|
||||
const localMentions: PersonMention[] = [
|
||||
{ personId: 'p-anna', displayName: 'Anna Schmidt' } // typed since last save
|
||||
];
|
||||
const merged = mergeBlockOnConflict({
|
||||
serverBlock: { ...baseBlock, mentionedPersons: [] },
|
||||
localText: '@Anna Schmidt',
|
||||
localMentions
|
||||
});
|
||||
expect(merged.mentionedPersons).toContainEqual({
|
||||
personId: 'p-anna',
|
||||
displayName: 'Anna Schmidt'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a union of personIds when local and server diverge', () => {
|
||||
const localMentions: PersonMention[] = [{ personId: 'p-anna', displayName: 'Anna Schmidt' }];
|
||||
const serverMentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Augusta Raddatz' }];
|
||||
const merged = mergeBlockOnConflict({
|
||||
serverBlock: { ...baseBlock, mentionedPersons: serverMentions },
|
||||
localText: '@Augusta Raddatz und @Anna Schmidt',
|
||||
localMentions
|
||||
});
|
||||
expect(merged.mentionedPersons).toHaveLength(2);
|
||||
expect(merged.mentionedPersons).toContainEqual({
|
||||
personId: 'p-aug',
|
||||
displayName: 'Augusta Raddatz'
|
||||
});
|
||||
expect(merged.mentionedPersons).toContainEqual({
|
||||
personId: 'p-anna',
|
||||
displayName: 'Anna Schmidt'
|
||||
});
|
||||
});
|
||||
|
||||
it('carries server version forward so the next save sends the latest revision', () => {
|
||||
const merged = mergeBlockOnConflict({
|
||||
serverBlock: { ...baseBlock, version: 42 },
|
||||
localText: 'x',
|
||||
localMentions: []
|
||||
});
|
||||
expect(merged.version).toBe(42);
|
||||
});
|
||||
|
||||
it('carries server-only mention array through when local has none', () => {
|
||||
const merged = mergeBlockOnConflict({
|
||||
serverBlock: {
|
||||
...baseBlock,
|
||||
mentionedPersons: [
|
||||
{ personId: 'p-aug', displayName: 'Augusta Raddatz' },
|
||||
{ personId: 'p-anna', displayName: 'Anna Schmidt' }
|
||||
]
|
||||
},
|
||||
localText: 'x',
|
||||
localMentions: []
|
||||
});
|
||||
expect(merged.mentionedPersons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('carries other server fields (sortOrder, reviewed, updatedAt) forward', () => {
|
||||
const merged = mergeBlockOnConflict({
|
||||
serverBlock: {
|
||||
...baseBlock,
|
||||
sortOrder: 9,
|
||||
reviewed: true,
|
||||
updatedAt: '2026-04-29T10:00:00Z'
|
||||
},
|
||||
localText: 'x',
|
||||
localMentions: []
|
||||
});
|
||||
expect(merged.sortOrder).toBe(9);
|
||||
expect(merged.reviewed).toBe(true);
|
||||
expect(merged.updatedAt).toBe('2026-04-29T10:00:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BlockConflictResolvedError', () => {
|
||||
it('is an Error with code = CONFLICT_RESOLVED', () => {
|
||||
const err = new BlockConflictResolvedError('block-1');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.code).toBe('CONFLICT_RESOLVED');
|
||||
expect(err.name).toBe('BlockConflictResolvedError');
|
||||
expect(err.message).toContain('block-1');
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Sentinel thrown by saveBlockWithConflictRetry after a 409 rename-mid-edit
|
||||
* has been merged into local state. Surfaces to the autosave hook as an
|
||||
* error (so the UI shows the retry indicator), but distinguishable from a
|
||||
* genuine network failure via the code. Carries the merged block snapshot
|
||||
* on its `merged` property so the caller can update local state without
|
||||
* a second roundtrip.
|
||||
*/
|
||||
export class BlockConflictResolvedError extends Error {
|
||||
readonly code = 'CONFLICT_RESOLVED' as const;
|
||||
merged?: TranscriptionBlockData;
|
||||
constructor(blockId: string) {
|
||||
super(
|
||||
`Block ${blockId} was rebased onto the latest server snapshot — retry to save the merged result`
|
||||
);
|
||||
this.name = 'BlockConflictResolvedError';
|
||||
}
|
||||
}
|
||||
|
||||
type MergeArgs = {
|
||||
serverBlock: TranscriptionBlockData;
|
||||
localText: string;
|
||||
localMentions: PersonMention[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a 409-Conflict from the server by combining the latest server
|
||||
* snapshot with the transcriber's unsaved local edits (B12b).
|
||||
*
|
||||
* Rules:
|
||||
* - The transcriber's typed text always wins — never overwrite their input.
|
||||
* - Server is the source of truth for the displayName of any person it
|
||||
* knows about; renames that just landed on the server replace stale local
|
||||
* names by personId.
|
||||
* - Local-only mentions added since the last save are preserved.
|
||||
* - All non-mention fields (version, sortOrder, reviewed, updatedAt, ...)
|
||||
* come from the server snapshot so the next save sends the current
|
||||
* revision and matches the latest persisted state.
|
||||
*/
|
||||
export function mergeBlockOnConflict(args: MergeArgs): TranscriptionBlockData {
|
||||
const serverIds = new Set(args.serverBlock.mentionedPersons.map((m) => m.personId));
|
||||
const localOnly = args.localMentions.filter((m) => !serverIds.has(m.personId));
|
||||
return {
|
||||
...args.serverBlock,
|
||||
text: args.localText,
|
||||
mentionedPersons: [...args.serverBlock.mentionedPersons, ...localOnly]
|
||||
};
|
||||
}
|
||||
@@ -1,47 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
detectMention,
|
||||
escapeHtml,
|
||||
extractContent,
|
||||
renderBody,
|
||||
renderTranscriptionBody
|
||||
} from './mention';
|
||||
import type { MentionDTO, PersonMention } from '$lib/types';
|
||||
|
||||
// ─── escapeHtml ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes ampersand', () => {
|
||||
expect(escapeHtml('AT&T')).toBe('AT&T');
|
||||
});
|
||||
|
||||
it('escapes less-than and greater-than', () => {
|
||||
expect(escapeHtml('<script>')).toBe('<script>');
|
||||
});
|
||||
|
||||
it('escapes double quote', () => {
|
||||
expect(escapeHtml('say "hi"')).toBe('say "hi"');
|
||||
});
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(escapeHtml('')).toBe('');
|
||||
});
|
||||
|
||||
it('escapes ampersand before other entities to avoid double-encoding', () => {
|
||||
expect(escapeHtml('a&<b')).toBe('a&<b');
|
||||
});
|
||||
|
||||
it('escapes apostrophe to '', () => {
|
||||
expect(escapeHtml("d'Artagnan")).toBe('d'Artagnan');
|
||||
});
|
||||
|
||||
it('does not collapse already-encoded entities (re-escapes the &)', () => {
|
||||
// escapeHtml is idempotent by composition: the second pass re-escapes
|
||||
// the & that was added by the first. Pin the property so the helper
|
||||
// can't be "cleverly" optimised to skip it.
|
||||
expect(escapeHtml('&')).toBe('&amp;');
|
||||
});
|
||||
});
|
||||
import { detectMention, extractContent, renderBody } from './mention';
|
||||
import type { MentionDTO } from '$lib/types';
|
||||
|
||||
// ─── detectMention ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -167,144 +126,3 @@ describe('renderBody', () => {
|
||||
expect(result).not.toContain('\n');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── renderTranscriptionBody ──────────────────────────────────────────────────
|
||||
|
||||
describe('renderTranscriptionBody', () => {
|
||||
const auguste: PersonMention = {
|
||||
personId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
displayName: 'Auguste Raddatz'
|
||||
};
|
||||
const hans: PersonMention = {
|
||||
personId: '550e8400-e29b-41d4-a716-446655440001',
|
||||
displayName: 'Hans'
|
||||
};
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(renderTranscriptionBody('', [])).toBe('');
|
||||
});
|
||||
|
||||
it('returns escaped plain text when no mentions', () => {
|
||||
expect(renderTranscriptionBody('Hello world', [])).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('escapes < and > in plain block text', () => {
|
||||
const result = renderTranscriptionBody('<script>alert(1)</script>', []);
|
||||
expect(result).toBe('<script>alert(1)</script>');
|
||||
expect(result).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes & in plain block text', () => {
|
||||
expect(renderTranscriptionBody('AT&T', [])).toBe('AT&T');
|
||||
});
|
||||
|
||||
it('replaces @DisplayName with anchor link to /persons/{personId}', () => {
|
||||
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
|
||||
expect(result).toContain(`<a href="/persons/${auguste.personId}"`);
|
||||
expect(result).toContain('class="person-mention"');
|
||||
expect(result).toContain(`data-person-id="${auguste.personId}"`);
|
||||
expect(result).toContain('>Auguste Raddatz</a>');
|
||||
});
|
||||
|
||||
it('strips the @ prefix from rendered link text (read mode)', () => {
|
||||
const result = renderTranscriptionBody('Hallo @Auguste Raddatz!', [auguste]);
|
||||
// The anchor body is the bare display name — no leading @
|
||||
expect(result).not.toMatch(/>@Auguste Raddatz</);
|
||||
expect(result).toMatch(/>Auguste Raddatz</);
|
||||
});
|
||||
|
||||
it('removes the trigger @ from the surrounding text (no orphan @ before the link)', () => {
|
||||
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
|
||||
// No bare @ remains where the mention was
|
||||
expect(result).not.toMatch(/@<a/);
|
||||
});
|
||||
|
||||
it('replaces all occurrences of the same mention', () => {
|
||||
const result = renderTranscriptionBody('@Auguste Raddatz und @Auguste Raddatz', [auguste]);
|
||||
const anchorCount = (result.match(/<a /g) ?? []).length;
|
||||
expect(anchorCount).toBe(2);
|
||||
});
|
||||
|
||||
it('does not replace plain-text occurrences without the @ trigger', () => {
|
||||
const result = renderTranscriptionBody('Auguste Raddatz war hier', [auguste]);
|
||||
expect(result).not.toContain('<a ');
|
||||
expect(result).toBe('Auguste Raddatz war hier');
|
||||
});
|
||||
|
||||
it('processes longer displayNames first to avoid prefix shadowing', () => {
|
||||
const augusteShort: PersonMention = { personId: 'p-short', displayName: 'Auguste' };
|
||||
const augusteLong: PersonMention = {
|
||||
personId: 'p-long',
|
||||
displayName: 'Auguste Raddatz'
|
||||
};
|
||||
// Sidecar order is short-first; longer match must still win for the long text
|
||||
const result = renderTranscriptionBody('@Auguste Raddatz schreibt @Auguste', [
|
||||
augusteShort,
|
||||
augusteLong
|
||||
]);
|
||||
expect(result).toContain('href="/persons/p-long"');
|
||||
expect(result).toContain('href="/persons/p-short"');
|
||||
// The "Raddatz" suffix must not leak inside the short-name anchor
|
||||
expect(result).not.toMatch(/>Auguste<\/a> Raddatz/);
|
||||
});
|
||||
|
||||
it('does not match @ followed by extra word characters (word boundary)', () => {
|
||||
// Sidecar contains "Hans"; text contains "@HansMüller" — no link.
|
||||
const result = renderTranscriptionBody('Brief an @HansMüller', [hans]);
|
||||
expect(result).not.toContain('<a ');
|
||||
expect(result).toContain('@HansM');
|
||||
});
|
||||
|
||||
it('first-sidecar-wins when two entries share the same displayName', () => {
|
||||
// Two persons named "Hans" — first sidecar entry wins for all occurrences.
|
||||
const hansFirst: PersonMention = { personId: 'p-first', displayName: 'Hans' };
|
||||
const hansSecond: PersonMention = { personId: 'p-second', displayName: 'Hans' };
|
||||
const result = renderTranscriptionBody('@Hans und @Hans', [hansFirst, hansSecond]);
|
||||
expect(result).toContain('href="/persons/p-first"');
|
||||
expect(result).not.toContain('href="/persons/p-second"');
|
||||
const anchorCount = (result.match(/<a /g) ?? []).length;
|
||||
expect(anchorCount).toBe(2);
|
||||
});
|
||||
|
||||
it('escapes HTML in displayName to prevent stored XSS', () => {
|
||||
const xss: PersonMention = {
|
||||
personId: 'p-xss',
|
||||
displayName: '<script>alert(1)</script>'
|
||||
};
|
||||
const result = renderTranscriptionBody('Hi @<script>alert(1)</script> there', [xss]);
|
||||
expect(result).not.toContain('<script>');
|
||||
expect(result).toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes <img onerror=...> payloads in surrounding block text', () => {
|
||||
const result = renderTranscriptionBody('<img src=x onerror=alert(1)> hello', []);
|
||||
expect(result).not.toContain('<img');
|
||||
expect(result).toContain('<img');
|
||||
});
|
||||
|
||||
it('does not double-encode HTML-entity-already-encoded payloads', () => {
|
||||
// `&lt;script&gt;` is already-escaped HTML in the source text.
|
||||
// renderTranscriptionBody must escape the literal & once → `&amp;lt;...`
|
||||
// — never silently decode pre-escaped entities.
|
||||
const result = renderTranscriptionBody('text &lt;script&gt;', []);
|
||||
expect(result).toBe('text &amp;lt;script&amp;gt;');
|
||||
});
|
||||
|
||||
it('escapes quotes in displayName so they cannot break the href attribute', () => {
|
||||
const tricky: PersonMention = {
|
||||
personId: 'p-quote',
|
||||
displayName: 'O"Brien'
|
||||
};
|
||||
const result = renderTranscriptionBody('@O"Brien', [tricky]);
|
||||
// The raw `"` from the displayName must never appear inside the rendered link
|
||||
// — it would terminate the attribute value early and let an attacker craft
|
||||
// arbitrary attributes on the anchor. It must arrive at the browser as ".
|
||||
expect(result).toMatch(/>O"Brien<\/a>/);
|
||||
expect(result).not.toMatch(/>O"Brien<\/a>/);
|
||||
});
|
||||
|
||||
it('renders nothing when mentionedPersons is undefined-empty and no @ triggers', () => {
|
||||
const result = renderTranscriptionBody('Plain old transcription text.', []);
|
||||
expect(result).toBe('Plain old transcription text.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MentionDTO, PersonMention } from '$lib/types';
|
||||
import type { MentionDTO } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Given the current textarea value and cursor position, returns the
|
||||
@@ -44,68 +44,6 @@ export function extractContent(
|
||||
return { content: text, mentionedUserIds: [...seen] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes the five HTML-special characters that can break out of text content
|
||||
* or attribute values. & must be escaped first to avoid double-encoding.
|
||||
*
|
||||
* Includes the apostrophe so the helper is safe in single-quoted attribute
|
||||
* values too — the renderTranscriptionBody anchor template in PR-B2 uses
|
||||
* double quotes today, but a future template change shouldn't open a
|
||||
* stored-XSS hole (Sina #5505 action item).
|
||||
*/
|
||||
export function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function escapeRegExp(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a transcription block's text segment as safe HTML for read mode.
|
||||
*
|
||||
* Rules:
|
||||
* 1. The full text is HTML-escaped first (defense against stored XSS).
|
||||
* 2. For each entry in `mentionedPersons`, every `@DisplayName` occurrence is
|
||||
* replaced with `<a href="/persons/{personId}" class="person-mention" …>DisplayName</a>`.
|
||||
* The `@` prefix is stripped from the rendered link text — it is an editor
|
||||
* affordance, not part of the historical text (issue #362).
|
||||
* 3. Longest displayNames are processed first so a short prefix in the sidecar
|
||||
* cannot shadow a longer match in the text (e.g. `@Auguste` vs `@Auguste Raddatz`).
|
||||
* 4. Word-boundary lookahead prevents `@Hans` from matching `@HansMüller`.
|
||||
* 5. First-sidecar-wins for entries that share a displayName (deterministic
|
||||
* rule per Felix decision OQ-1, comment #5339).
|
||||
*/
|
||||
export function renderTranscriptionBody(text: string, mentionedPersons: PersonMention[]): string {
|
||||
if (!text) return '';
|
||||
let escaped = escapeHtml(text);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const unique: PersonMention[] = [];
|
||||
for (const mention of mentionedPersons) {
|
||||
if (seen.has(mention.displayName)) continue;
|
||||
seen.add(mention.displayName);
|
||||
unique.push(mention);
|
||||
}
|
||||
|
||||
const sorted = [...unique].sort((a, b) => b.displayName.length - a.displayName.length);
|
||||
|
||||
for (const mention of sorted) {
|
||||
const escapedDisplayName = escapeHtml(mention.displayName);
|
||||
const escapedPersonId = escapeHtml(mention.personId);
|
||||
const pattern = new RegExp(`@${escapeRegExp(escapedDisplayName)}(?![\\p{L}\\p{N}])`, 'gu');
|
||||
const link = `<a href="/persons/${escapedPersonId}" class="person-mention" data-person-id="${escapedPersonId}">${escapedDisplayName}</a>`;
|
||||
escaped = escaped.replace(pattern, link);
|
||||
}
|
||||
|
||||
return escaped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a comment body as safe HTML:
|
||||
* 1. Escapes all HTML-special characters in the raw content
|
||||
@@ -113,11 +51,19 @@ export function renderTranscriptionBody(text: string, mentionedPersons: PersonMe
|
||||
* 3. Converts newlines to <br>
|
||||
*/
|
||||
export function renderBody(content: string, mentions: MentionDTO[]): string {
|
||||
let escaped = escapeHtml(content);
|
||||
let escaped = content
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
|
||||
for (const mention of mentions) {
|
||||
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
|
||||
const escapedDisplayName = escapeHtml(displayName);
|
||||
const escapedDisplayName = displayName
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
const span = `<span class="mention" data-user-id="${mention.id}">@${escapedDisplayName}</span>`;
|
||||
escaped = escaped.replaceAll(`@${escapedDisplayName}`, span);
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectPersonMention } from './personMention';
|
||||
|
||||
describe('detectPersonMention', () => {
|
||||
it('returns null when text has no @', () => {
|
||||
expect(detectPersonMention('hello world', 11)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when @ is preceded by a non-whitespace character (email pattern)', () => {
|
||||
expect(detectPersonMention('user@example', 12)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns query for @ at the very start of string', () => {
|
||||
expect(detectPersonMention('@Aug', 4)).toBe('Aug');
|
||||
});
|
||||
|
||||
it('returns empty string immediately after @', () => {
|
||||
expect(detectPersonMention('@', 1)).toBe('');
|
||||
});
|
||||
|
||||
it('returns single-word query', () => {
|
||||
expect(detectPersonMention('hi @Auguste', 11)).toBe('Auguste');
|
||||
});
|
||||
|
||||
it('keeps the trigger active when the query has a trailing space', () => {
|
||||
expect(detectPersonMention('hi @Auguste ', 12)).toBe('Auguste ');
|
||||
});
|
||||
|
||||
it('returns multi-word query (spaces allowed)', () => {
|
||||
expect(detectPersonMention('hi @Auguste Raddatz', 19)).toBe('Auguste Raddatz');
|
||||
});
|
||||
|
||||
it('returns single-character query', () => {
|
||||
expect(detectPersonMention('@M', 2)).toBe('M');
|
||||
});
|
||||
|
||||
it('returns null when the query crosses a newline', () => {
|
||||
expect(detectPersonMention('@Aug\nfoo', 8)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when a second @ appears in the query (next mention starts)', () => {
|
||||
expect(detectPersonMention('@Aug@bar', 8)).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the most recent @ when separated by whitespace', () => {
|
||||
// '@Aug @Bert' with cursor at end — the second @ is the trigger.
|
||||
expect(detectPersonMention('@Aug @Bert', 10)).toBe('Bert');
|
||||
});
|
||||
|
||||
it('returns the query when the cursor sits exactly at a newline boundary', () => {
|
||||
// '@Aug\nfoo' with cursor at index 4 — right at the newline before it
|
||||
// is consumed. The query is still 'Aug' because nothing past the cursor
|
||||
// counts.
|
||||
expect(detectPersonMention('@Aug\nfoo', 4)).toBe('Aug');
|
||||
});
|
||||
|
||||
it('returns null when cursor is before the @', () => {
|
||||
expect(detectPersonMention('@Hans', 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the most recent @ in the text', () => {
|
||||
// cursor is just after the second @ + a few chars
|
||||
expect(detectPersonMention('hi @Anna and @Bert', 18)).toBe('Bert');
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Given the current textarea value and cursor position, returns the
|
||||
* @-person-mention query being typed (the text after the last triggering @),
|
||||
* or null if no person-mention is active.
|
||||
*
|
||||
* Rules — distinct from comment-mentions in `mention.ts`:
|
||||
* - @ must be at the start of the string or preceded by whitespace
|
||||
* - The query may contain spaces (historical persons commonly have multi-word
|
||||
* display names — "Auguste Raddatz", "Maria von Müller-Schultz")
|
||||
* - The query stops at a newline or at a second @ (the next mention starts)
|
||||
*/
|
||||
export function detectPersonMention(text: string, cursorPos: number): string | null {
|
||||
const before = text.slice(0, cursorPos);
|
||||
const atIndex = before.lastIndexOf('@');
|
||||
if (atIndex === -1) return null;
|
||||
|
||||
if (atIndex > 0 && !/\s/.test(before[atIndex - 1])) return null;
|
||||
|
||||
const query = before.slice(atIndex + 1);
|
||||
if (query.includes('\n') || query.includes('@')) return null;
|
||||
|
||||
return query;
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry';
|
||||
import { BlockConflictResolvedError } from './blockConflictMerge';
|
||||
import type { PersonMention } from '$lib/types';
|
||||
|
||||
const DOC = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
const BLK = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
|
||||
const SERVER_BLOCK_AFTER_RENAME = {
|
||||
id: BLK,
|
||||
annotationId: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
|
||||
documentId: DOC,
|
||||
text: 'old text from server',
|
||||
label: null,
|
||||
sortOrder: 0,
|
||||
version: 7,
|
||||
source: 'MANUAL' as const,
|
||||
reviewed: false,
|
||||
mentionedPersons: [{ personId: 'p-aug', displayName: 'Augusta Raddatz' }]
|
||||
};
|
||||
|
||||
function mkResponse(status: number, body?: unknown): Response {
|
||||
return new Response(body === undefined ? null : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
describe('saveBlockWithConflictRetry', () => {
|
||||
it('returns the server-saved block on a successful PUT', async () => {
|
||||
const updated = { ...SERVER_BLOCK_AFTER_RENAME, text: 'persisted text' };
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(mkResponse(200, updated));
|
||||
|
||||
const result = await saveBlockWithConflictRetry({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
documentId: DOC,
|
||||
blockId: BLK,
|
||||
text: 'persisted text',
|
||||
mentionedPersons: []
|
||||
});
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
`/api/documents/${DOC}/transcription-blocks/${BLK}`,
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ text: 'persisted text', mentionedPersons: [] })
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws BlockConflictResolvedError carrying the merged block on 409', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mkResponse(409))
|
||||
.mockResolvedValueOnce(mkResponse(200, SERVER_BLOCK_AFTER_RENAME));
|
||||
|
||||
const localMentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }];
|
||||
|
||||
await expect(
|
||||
saveBlockWithConflictRetry({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
documentId: DOC,
|
||||
blockId: BLK,
|
||||
text: 'transcriber unsaved input',
|
||||
mentionedPersons: localMentions
|
||||
})
|
||||
).rejects.toThrow(BlockConflictResolvedError);
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
// First call PUT, second is the GET refetch.
|
||||
expect(fetchImpl.mock.calls[0]?.[1]?.method).toBe('PUT');
|
||||
expect(fetchImpl.mock.calls[1]?.[1]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('attaches the merged block to err.merged so callers can update local state', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mkResponse(409))
|
||||
.mockResolvedValueOnce(mkResponse(200, SERVER_BLOCK_AFTER_RENAME));
|
||||
|
||||
const localMentions: PersonMention[] = [
|
||||
{ personId: 'p-aug', displayName: 'Auguste Raddatz' } // stale displayName
|
||||
];
|
||||
try {
|
||||
await saveBlockWithConflictRetry({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
documentId: DOC,
|
||||
blockId: BLK,
|
||||
text: 'transcriber unsaved input',
|
||||
mentionedPersons: localMentions
|
||||
});
|
||||
throw new Error('expected throw');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(BlockConflictResolvedError);
|
||||
const merged = (err as BlockConflictResolvedError).merged!;
|
||||
// Local text wins.
|
||||
expect(merged.text).toBe('transcriber unsaved input');
|
||||
// Server displayName wins for shared personId.
|
||||
expect(merged.mentionedPersons).toEqual([
|
||||
{ personId: 'p-aug', displayName: 'Augusta Raddatz' }
|
||||
]);
|
||||
// Server version carried forward.
|
||||
expect(merged.version).toBe(7);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws BlockConflictResolvedError without merged when refetch fails', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mkResponse(409))
|
||||
.mockResolvedValueOnce(mkResponse(500));
|
||||
|
||||
await expect(
|
||||
saveBlockWithConflictRetry({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
documentId: DOC,
|
||||
blockId: BLK,
|
||||
text: 'x',
|
||||
mentionedPersons: []
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'CONFLICT_RESOLVED', merged: undefined });
|
||||
});
|
||||
|
||||
it('throws Save failed for any other non-OK response', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(mkResponse(500));
|
||||
await expect(
|
||||
saveBlockWithConflictRetry({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
documentId: DOC,
|
||||
blockId: BLK,
|
||||
text: 'x',
|
||||
mentionedPersons: []
|
||||
})
|
||||
).rejects.toThrow('Save failed');
|
||||
});
|
||||
|
||||
it('rejects ids that are not UUIDs (path-injection guard)', async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
await expect(
|
||||
saveBlockWithConflictRetry({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
documentId: DOC,
|
||||
blockId: '../../etc/passwd',
|
||||
text: 'x',
|
||||
mentionedPersons: []
|
||||
})
|
||||
).rejects.toThrow(/Invalid id/);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
|
||||
import { BlockConflictResolvedError, mergeBlockOnConflict } from '$lib/utils/blockConflictMerge';
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
type Args = {
|
||||
fetchImpl: typeof fetch;
|
||||
documentId: string;
|
||||
blockId: string;
|
||||
text: string;
|
||||
mentionedPersons: PersonMention[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists a transcription block edit, with built-in handling for the
|
||||
* rename-mid-edit conflict (B12b).
|
||||
*
|
||||
* - 200/204 → resolves with the server's updated block.
|
||||
* - 409 → refetches the latest server block, merges it with the
|
||||
* transcriber's unsaved input via mergeBlockOnConflict, and
|
||||
* throws BlockConflictResolvedError carrying the merged
|
||||
* snapshot. The caller is responsible for updating local
|
||||
* state with `err.merged` before surfacing the error.
|
||||
* - other → throws Error('Save failed').
|
||||
*
|
||||
* Validates both ids against the UUID pattern before any fetch fires
|
||||
* (Sina #5505 — defence-in-depth path-injection guard).
|
||||
*/
|
||||
export async function saveBlockWithConflictRetry(args: Args): Promise<TranscriptionBlockData> {
|
||||
const { fetchImpl, documentId, blockId, text, mentionedPersons } = args;
|
||||
if (!UUID_RE.test(documentId) || !UUID_RE.test(blockId)) {
|
||||
throw new Error(`Invalid id for save: doc=${documentId} block=${blockId}`);
|
||||
}
|
||||
|
||||
const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
|
||||
const res = await fetchImpl(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, mentionedPersons })
|
||||
});
|
||||
|
||||
if (res.status === 409) {
|
||||
const fresh = await fetchImpl(url);
|
||||
if (!fresh.ok) {
|
||||
throw new BlockConflictResolvedError(blockId);
|
||||
}
|
||||
const serverBlock = (await fresh.json()) as TranscriptionBlockData;
|
||||
const merged = mergeBlockOnConflict({
|
||||
serverBlock,
|
||||
localText: text,
|
||||
localMentions: mentionedPersons
|
||||
});
|
||||
const err = new BlockConflictResolvedError(blockId);
|
||||
(err as BlockConflictResolvedError & { merged: TranscriptionBlockData }).merged = merged;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error('Save failed');
|
||||
return (await res.json()) as TranscriptionBlockData;
|
||||
}
|
||||
@@ -60,13 +60,13 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/stammbaum"
|
||||
href="/briefwechsel"
|
||||
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('/stammbaum')
|
||||
{page.url.pathname.startsWith('/briefwechsel')
|
||||
? 'border-b-2 border-accent text-white'
|
||||
: 'text-white/70 hover:text-white'}"
|
||||
>
|
||||
{m.nav_stammbaum()}
|
||||
{m.nav_conversations()}
|
||||
</a>
|
||||
{#if isAdmin}
|
||||
<a
|
||||
@@ -161,13 +161,13 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/stammbaum"
|
||||
href="/briefwechsel"
|
||||
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('/stammbaum')
|
||||
{page.url.pathname.startsWith('/briefwechsel')
|
||||
? 'bg-accent-bg text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.nav_stammbaum()}
|
||||
{m.nav_conversations()}
|
||||
</a>
|
||||
|
||||
{#if isAdmin}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user