Compare commits
2 Commits
d4f666e981
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c641d704a8 | ||
|
|
69ac183fe8 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -13,7 +13,3 @@ scripts/large-data.sql
|
|||||||
.vitest-attachments
|
.vitest-attachments
|
||||||
**/test-results/
|
**/test-results/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.superpowers/
|
|
||||||
|
|
||||||
# Repo uses npm; yarn.lock is ignored to avoid double-lockfile drift.
|
|
||||||
frontend/yarn.lock
|
|
||||||
|
|||||||
@@ -177,13 +177,6 @@
|
|||||||
<artifactId>imageio-tiff</artifactId>
|
<artifactId>imageio-tiff</artifactId>
|
||||||
<version>3.12.0</version>
|
<version>3.12.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- HTML sanitization for Geschichten rich-text body (defense-in-depth alongside Tiptap on the client) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
|
|
||||||
<artifactId>owasp-java-html-sanitizer</artifactId>
|
|
||||||
<version>20240325.1</version>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
|
|
||||||
import org.raddatz.familienarchiv.model.Geschichte;
|
|
||||||
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
|
||||||
import org.raddatz.familienarchiv.service.GeschichteService;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PatchMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/geschichten")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class GeschichteController {
|
|
||||||
|
|
||||||
private final GeschichteService geschichteService;
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
public List<Geschichte> list(
|
|
||||||
@RequestParam(required = false) GeschichteStatus status,
|
|
||||||
@RequestParam(name = "personId", required = false) List<UUID> personIds,
|
|
||||||
@RequestParam(required = false) UUID documentId,
|
|
||||||
@RequestParam(required = false, defaultValue = "50") int limit) {
|
|
||||||
return geschichteService.list(
|
|
||||||
status,
|
|
||||||
personIds == null ? List.of() : personIds,
|
|
||||||
documentId,
|
|
||||||
limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
|
||||||
public Geschichte getById(@PathVariable UUID id) {
|
|
||||||
return geschichteService.getById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping
|
|
||||||
@RequirePermission(Permission.BLOG_WRITE)
|
|
||||||
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) {
|
|
||||||
Geschichte created = geschichteService.create(dto);
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PatchMapping("/{id}")
|
|
||||||
@RequirePermission(Permission.BLOG_WRITE)
|
|
||||||
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
|
||||||
return geschichteService.update(id, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
|
||||||
@RequirePermission(Permission.BLOG_WRITE)
|
|
||||||
public ResponseEntity<Void> delete(@PathVariable UUID id) {
|
|
||||||
geschichteService.delete(id);
|
|
||||||
return ResponseEntity.noContent().build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import jakarta.validation.ConstraintViolationException;
|
|||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
@@ -48,12 +47,6 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
|
||||||
public ResponseEntity<ErrorResponse> handleMessageNotReadable(HttpMessageNotReadableException ex) {
|
|
||||||
return ResponseEntity.badRequest()
|
|
||||||
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "Invalid request body"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExceptionHandler(ResponseStatusException.class)
|
@ExceptionHandler(ResponseStatusException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
||||||
return ResponseEntity.status(ex.getStatusCode())
|
return ResponseEntity.status(ex.getStatusCode())
|
||||||
|
|||||||
@@ -34,13 +34,11 @@ public class PersonController {
|
|||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@RequirePermission(Permission.READ_ALL)
|
|
||||||
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
|
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
|
||||||
return ResponseEntity.ok(personService.findAll(q));
|
return ResponseEntity.ok(personService.findAll(q));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
@RequirePermission(Permission.READ_ALL)
|
|
||||||
public Person getPerson(@PathVariable UUID id) {
|
public Person getPerson(@PathVariable UUID id) {
|
||||||
return personService.getById(id);
|
return personService.getById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
|
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
|
||||||
@@ -46,7 +45,7 @@ public class TranscriptionBlockController {
|
|||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public TranscriptionBlock createBlock(
|
public TranscriptionBlock createBlock(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
|
@RequestBody CreateTranscriptionBlockDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
UUID userId = requireUserId(authentication);
|
UUID userId = requireUserId(authentication);
|
||||||
return transcriptionService.createBlock(documentId, dto, userId);
|
return transcriptionService.createBlock(documentId, dto, userId);
|
||||||
@@ -57,7 +56,7 @@ public class TranscriptionBlockController {
|
|||||||
public TranscriptionBlock updateBlock(
|
public TranscriptionBlock updateBlock(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID blockId,
|
@PathVariable UUID blockId,
|
||||||
@Valid @RequestBody UpdateTranscriptionBlockDTO dto,
|
@RequestBody UpdateTranscriptionBlockDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
UUID userId = requireUserId(authentication);
|
UUID userId = requireUserId(authentication);
|
||||||
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
|
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import jakarta.validation.constraints.Min;
|
import jakarta.validation.constraints.Min;
|
||||||
import jakarta.validation.constraints.Positive;
|
import jakarta.validation.constraints.Positive;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import org.raddatz.familienarchiv.model.PersonMention;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Builder
|
|
||||||
public class CreateTranscriptionBlockDTO {
|
public class CreateTranscriptionBlockDTO {
|
||||||
@Min(0)
|
@Min(0)
|
||||||
private int pageNumber;
|
private int pageNumber;
|
||||||
@@ -29,8 +22,4 @@ public class CreateTranscriptionBlockDTO {
|
|||||||
private double height;
|
private double height;
|
||||||
private String text;
|
private String text;
|
||||||
private String label;
|
private String label;
|
||||||
|
|
||||||
@Valid
|
|
||||||
@Builder.Default
|
|
||||||
private List<PersonMention> mentionedPersons = new ArrayList<>();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used for both create and update of a Geschichte. All fields are optional;
|
|
||||||
* the service applies whatever is non-null. {@code body} is rich-text HTML and
|
|
||||||
* is sanitised against an allow-list before persistence.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
public class GeschichteUpdateDTO {
|
|
||||||
private String title;
|
|
||||||
private String body;
|
|
||||||
private GeschichteStatus status;
|
|
||||||
private List<UUID> personIds;
|
|
||||||
private List<UUID> documentIds;
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,6 @@ public interface PersonSummaryDTO {
|
|||||||
Integer getBirthYear();
|
Integer getBirthYear();
|
||||||
Integer getDeathYear();
|
Integer getDeathYear();
|
||||||
String getNotes();
|
String getNotes();
|
||||||
boolean isFamilyMember();
|
|
||||||
long getDocumentCount();
|
long getDocumentCount();
|
||||||
|
|
||||||
default String getDisplayName() {
|
default String getDisplayName() {
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import org.raddatz.familienarchiv.model.PersonMention;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Builder
|
|
||||||
public class UpdateTranscriptionBlockDTO {
|
public class UpdateTranscriptionBlockDTO {
|
||||||
private String text;
|
private String text;
|
||||||
private String label;
|
private String label;
|
||||||
|
|
||||||
@Valid
|
|
||||||
@Builder.Default
|
|
||||||
private List<PersonMention> mentionedPersons = new ArrayList<>();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public enum ErrorCode {
|
|||||||
ALIAS_NOT_FOUND,
|
ALIAS_NOT_FOUND,
|
||||||
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
||||||
INVALID_PERSON_TYPE,
|
INVALID_PERSON_TYPE,
|
||||||
|
|
||||||
// --- Documents ---
|
// --- Documents ---
|
||||||
/** A document with the given ID does not exist. 404 */
|
/** A document with the given ID does not exist. 404 */
|
||||||
DOCUMENT_NOT_FOUND,
|
DOCUMENT_NOT_FOUND,
|
||||||
@@ -95,18 +96,6 @@ public enum ErrorCode {
|
|||||||
/** Internal inconsistency: expected training run row was not found after creation. 500 */
|
/** Internal inconsistency: expected training run row was not found after creation. 500 */
|
||||||
OCR_TRAINING_CONFLICT,
|
OCR_TRAINING_CONFLICT,
|
||||||
|
|
||||||
// --- Relationships (Stammbaum) ---
|
|
||||||
/** A relationship row with the given ID does not exist. 404 */
|
|
||||||
RELATIONSHIP_NOT_FOUND,
|
|
||||||
/** Adding this relationship would create a cycle (e.g. reverse PARENT_OF already exists). 409 */
|
|
||||||
CIRCULAR_RELATIONSHIP,
|
|
||||||
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
|
|
||||||
DUPLICATE_RELATIONSHIP,
|
|
||||||
|
|
||||||
// --- Geschichten (Stories) ---
|
|
||||||
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
|
||||||
GESCHICHTE_NOT_FOUND,
|
|
||||||
|
|
||||||
// --- Tags ---
|
// --- Tags ---
|
||||||
/** A tag with the given ID does not exist. 404 */
|
/** A tag with the given ID does not exist. 404 */
|
||||||
TAG_NOT_FOUND,
|
TAG_NOT_FOUND,
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.model;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.*;
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "geschichten")
|
|
||||||
@Data
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
@Builder
|
|
||||||
public class Geschichte {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.UUID)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private UUID id;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private String title;
|
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
private String body;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(nullable = false)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
@Builder.Default
|
|
||||||
private GeschichteStatus status = GeschichteStatus.DRAFT;
|
|
||||||
|
|
||||||
@ManyToOne
|
|
||||||
@JoinColumn(name = "author_id")
|
|
||||||
private AppUser author;
|
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
|
||||||
@JoinTable(name = "geschichten_persons",
|
|
||||||
joinColumns = @JoinColumn(name = "geschichte_id"),
|
|
||||||
inverseJoinColumns = @JoinColumn(name = "person_id"))
|
|
||||||
@Builder.Default
|
|
||||||
private Set<Person> persons = new HashSet<>();
|
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
|
||||||
@JoinTable(name = "geschichten_documents",
|
|
||||||
joinColumns = @JoinColumn(name = "geschichte_id"),
|
|
||||||
inverseJoinColumns = @JoinColumn(name = "document_id"))
|
|
||||||
@Builder.Default
|
|
||||||
private Set<Document> documents = new HashSet<>();
|
|
||||||
|
|
||||||
@CreationTimestamp
|
|
||||||
@Column(updatable = false)
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
@UpdateTimestamp
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private LocalDateTime updatedAt;
|
|
||||||
|
|
||||||
@Column(name = "published_at")
|
|
||||||
private LocalDateTime publishedAt;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.model;
|
|
||||||
|
|
||||||
public enum GeschichteStatus {
|
|
||||||
DRAFT,
|
|
||||||
PUBLISHED
|
|
||||||
}
|
|
||||||
@@ -47,11 +47,6 @@ public class Person {
|
|||||||
private Integer birthYear;
|
private Integer birthYear;
|
||||||
private Integer deathYear;
|
private Integer deathYear;
|
||||||
|
|
||||||
@Column(name = "family_member", nullable = false)
|
|
||||||
@Builder.Default
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
private boolean familyMember = false;
|
|
||||||
|
|
||||||
// Entity-graph navigation for JPA JOIN queries (e.g. DocumentSpecifications.hasText).
|
// Entity-graph navigation for JPA JOIN queries (e.g. DocumentSpecifications.hasText).
|
||||||
// Uses entity relationship rather than cross-domain repository access, avoiding a
|
// Uses entity relationship rather than cross-domain repository access, avoiding a
|
||||||
// separate DB roundtrip while respecting domain boundaries.
|
// separate DB roundtrip while respecting domain boundaries.
|
||||||
|
|||||||
@@ -1,31 +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)
|
|
||||||
// Archival: the text the transcriber typed after @. Never updated on person rename.
|
|
||||||
private String displayName;
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,6 @@ import org.hibernate.annotations.CreationTimestamp;
|
|||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -35,16 +33,6 @@ public class TranscriptionBlock {
|
|||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String text;
|
private String text;
|
||||||
|
|
||||||
// EAGER: mention set is bounded by block text length (typically < 20 entries).
|
|
||||||
// Switching back to LAZY requires callers to be inside an open Hibernate session.
|
|
||||||
@ElementCollection(fetch = FetchType.EAGER)
|
|
||||||
@CollectionTable(
|
|
||||||
name = "transcription_block_mentioned_persons",
|
|
||||||
joinColumns = @JoinColumn(name = "block_id"))
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
@Builder.Default
|
|
||||||
private List<PersonMention> mentionedPersons = new ArrayList<>();
|
|
||||||
|
|
||||||
@Column(length = 200)
|
@Column(length = 200)
|
||||||
private String label;
|
private String label;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
) {}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.repository;
|
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.model.Geschichte;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.repository;
|
|
||||||
|
|
||||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
|
||||||
import jakarta.persistence.criteria.CriteriaQuery;
|
|
||||||
import jakarta.persistence.criteria.Join;
|
|
||||||
import jakarta.persistence.criteria.Predicate;
|
|
||||||
import jakarta.persistence.criteria.Root;
|
|
||||||
import jakarta.persistence.criteria.Subquery;
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
|
||||||
import org.raddatz.familienarchiv.model.Geschichte;
|
|
||||||
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public final class GeschichteSpecifications {
|
|
||||||
|
|
||||||
private GeschichteSpecifications() {}
|
|
||||||
|
|
||||||
public static Specification<Geschichte> hasStatus(GeschichteStatus status) {
|
|
||||||
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds {@code ORDER BY COALESCE(publishedAt, updatedAt) DESC} to the query without contributing
|
|
||||||
* a predicate. Combined into the spec chain via {@code .and(...)}; the {@code conjunction}
|
|
||||||
* acts as a no-op WHERE clause.
|
|
||||||
*/
|
|
||||||
public static Specification<Geschichte> orderByDisplayDateDesc() {
|
|
||||||
return (root, query, cb) -> {
|
|
||||||
// Skip ordering on count queries — JPA forbids orderBy on COUNT projections.
|
|
||||||
if (query != null
|
|
||||||
&& Long.class != query.getResultType()
|
|
||||||
&& long.class != query.getResultType()) {
|
|
||||||
query.orderBy(cb.desc(cb.coalesce(root.get("publishedAt"), root.get("updatedAt"))));
|
|
||||||
}
|
|
||||||
return cb.conjunction();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Specification<Geschichte> hasDocument(UUID documentId) {
|
|
||||||
return (root, query, cb) -> {
|
|
||||||
if (documentId == null) return null;
|
|
||||||
return cb.exists(documentSubquery(root, query, cb, documentId));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}.
|
|
||||||
*
|
|
||||||
* <p>Implemented as one EXISTS subquery per id (canonical Criteria-API idiom for AND across a
|
|
||||||
* many-to-many join). Mirrors {@link DocumentSpecifications#hasTags} which uses the same shape.
|
|
||||||
* Empty / null input returns {@code null} (i.e. no constraint added).
|
|
||||||
*/
|
|
||||||
public static Specification<Geschichte> hasAllPersons(Collection<UUID> personIds) {
|
|
||||||
return (root, query, cb) -> {
|
|
||||||
if (personIds == null || personIds.isEmpty()) return null;
|
|
||||||
List<Predicate> predicates = new ArrayList<>(personIds.size());
|
|
||||||
for (UUID id : personIds) {
|
|
||||||
predicates.add(cb.exists(personSubquery(root, query, cb, id)));
|
|
||||||
}
|
|
||||||
return cb.and(predicates.toArray(new Predicate[0]));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Subquery<UUID> personSubquery(
|
|
||||||
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID personId) {
|
|
||||||
Subquery<UUID> sub = query.subquery(UUID.class);
|
|
||||||
Root<Geschichte> subRoot = sub.from(Geschichte.class);
|
|
||||||
Join<Geschichte, Person> persons = subRoot.join("persons");
|
|
||||||
sub.select(subRoot.get("id"))
|
|
||||||
.where(cb.equal(subRoot.get("id"), root.get("id")),
|
|
||||||
cb.equal(persons.get("id"), personId));
|
|
||||||
return sub;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Subquery<UUID> documentSubquery(
|
|
||||||
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID documentId) {
|
|
||||||
Subquery<UUID> sub = query.subquery(UUID.class);
|
|
||||||
Root<Geschichte> subRoot = sub.from(Geschichte.class);
|
|
||||||
Join<Geschichte, Document> documents = subRoot.join("documents");
|
|
||||||
sub.select(subRoot.get("id"))
|
|
||||||
.where(cb.equal(subRoot.get("id"), root.get("id")),
|
|
||||||
cb.equal(documents.get("id"), documentId));
|
|
||||||
return sub;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,9 +26,6 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
// Hilfsmethode: Alle sortiert laden (für den leeren Status)
|
// Hilfsmethode: Alle sortiert laden (für den leeren Status)
|
||||||
List<Person> findAllByOrderByLastNameAscFirstNameAsc();
|
List<Person> findAllByOrderByLastNameAscFirstNameAsc();
|
||||||
|
|
||||||
// Stammbaum-Knoten: alle Personen mit family_member = true.
|
|
||||||
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
|
||||||
|
|
||||||
// Lookup by full alias string, used during ODS mass import
|
// Lookup by full alias string, used during ODS mass import
|
||||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
Optional<Person> findByAliasIgnoreCase(String alias);
|
||||||
|
|
||||||
@@ -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,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||||
p.family_member AS familyMember,
|
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
FROM persons p
|
FROM persons p
|
||||||
@@ -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,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||||
p.family_member AS familyMember,
|
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
FROM persons p
|
FROM persons p
|
||||||
@@ -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(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, 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
|
ORDER BY p.last_name ASC, p.first_name ASC
|
||||||
""",
|
""",
|
||||||
nativeQuery = true)
|
nativeQuery = true)
|
||||||
|
|||||||
@@ -29,15 +29,6 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
|
|||||||
|
|
||||||
Optional<TranscriptionBlock> findByAnnotationId(UUID annotationId);
|
Optional<TranscriptionBlock> findByAnnotationId(UUID annotationId);
|
||||||
|
|
||||||
@Query("""
|
|
||||||
SELECT DISTINCT b FROM TranscriptionBlock b
|
|
||||||
JOIN FETCH b.mentionedPersons
|
|
||||||
WHERE b.id IN (
|
|
||||||
SELECT bb.id FROM TranscriptionBlock bb JOIN bb.mentionedPersons m WHERE m.personId = :personId
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
List<TranscriptionBlock> findByPersonIdWithMentionsFetched(@Param("personId") UUID personId);
|
|
||||||
|
|
||||||
void deleteByAnnotationId(UUID annotationId);
|
void deleteByAnnotationId(UUID annotationId);
|
||||||
|
|
||||||
int countByDocumentId(UUID documentId);
|
int countByDocumentId(UUID documentId);
|
||||||
@@ -60,25 +51,21 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
|
|||||||
""")
|
""")
|
||||||
List<TranscriptionBlock> findSegmentationBlocks();
|
List<TranscriptionBlock> findSegmentationBlocks();
|
||||||
|
|
||||||
// Uses 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels — aligned with findEligibleKurrentBlocks()
|
|
||||||
// which already used this form (changed from d.scriptType = 'KURRENT' in the original queries).
|
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT COUNT(b) FROM TranscriptionBlock b
|
SELECT COUNT(b) FROM TranscriptionBlock b
|
||||||
JOIN Document d ON d.id = b.documentId
|
JOIN Document d ON d.id = b.documentId
|
||||||
WHERE b.source = 'MANUAL'
|
WHERE b.source = 'MANUAL'
|
||||||
AND d.sender.id = :personId
|
AND d.sender.id = :personId
|
||||||
AND 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels
|
AND d.scriptType = 'HANDWRITING_KURRENT'
|
||||||
""")
|
""")
|
||||||
long countManualKurrentBlocksByPerson(@Param("personId") UUID personId);
|
long countManualKurrentBlocksByPerson(@Param("personId") UUID personId);
|
||||||
|
|
||||||
// Uses 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels — aligned with findEligibleKurrentBlocks()
|
|
||||||
// which already used this form (changed from d.scriptType = 'KURRENT' in the original queries).
|
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT b FROM TranscriptionBlock b
|
SELECT b FROM TranscriptionBlock b
|
||||||
JOIN Document d ON d.id = b.documentId
|
JOIN Document d ON d.id = b.documentId
|
||||||
WHERE b.source = 'MANUAL'
|
WHERE b.source = 'MANUAL'
|
||||||
AND d.sender.id = :personId
|
AND d.sender.id = :personId
|
||||||
AND 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels
|
AND d.scriptType = 'HANDWRITING_KURRENT'
|
||||||
""")
|
""")
|
||||||
List<TranscriptionBlock> findManualKurrentBlocksByPerson(@Param("personId") UUID personId);
|
List<TranscriptionBlock> findManualKurrentBlocksByPerson(@Param("personId") UUID personId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ public enum Permission {
|
|||||||
READ_ALL,
|
READ_ALL,
|
||||||
WRITE_ALL,
|
WRITE_ALL,
|
||||||
ANNOTATE_ALL,
|
ANNOTATE_ALL,
|
||||||
BLOG_WRITE,
|
|
||||||
ADMIN,
|
ADMIN,
|
||||||
ADMIN_USER,
|
ADMIN_USER,
|
||||||
ADMIN_TAG,
|
ADMIN_TAG,
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.owasp.html.HtmlPolicyBuilder;
|
|
||||||
import org.owasp.html.PolicyFactory;
|
|
||||||
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
|
||||||
import org.raddatz.familienarchiv.model.Geschichte;
|
|
||||||
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
|
||||||
import org.raddatz.familienarchiv.repository.GeschichteRepository;
|
|
||||||
import org.raddatz.familienarchiv.repository.GeschichteSpecifications;
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
|
||||||
import org.springframework.data.domain.Sort;
|
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
public class GeschichteService {
|
|
||||||
|
|
||||||
private final GeschichteRepository geschichteRepository;
|
|
||||||
private final PersonService personService;
|
|
||||||
private final DocumentService documentService;
|
|
||||||
private final UserService userService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
|
|
||||||
* already constrains the marks/nodes, but the backend re-sanitises every
|
|
||||||
* save so that an attacker calling the API directly cannot inject more.
|
|
||||||
*/
|
|
||||||
private static final PolicyFactory BODY_SANITIZER = new HtmlPolicyBuilder()
|
|
||||||
.allowElements("p", "br", "strong", "em", "h2", "h3", "ul", "ol", "li")
|
|
||||||
.toFactory();
|
|
||||||
|
|
||||||
private static final int DEFAULT_LIMIT = 50;
|
|
||||||
private static final int MAX_LIMIT = 200;
|
|
||||||
|
|
||||||
// ─── Read API ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public Geschichte getById(UUID id) {
|
|
||||||
Geschichte g = geschichteRepository.findById(id)
|
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
|
||||||
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
|
|
||||||
if (g.getStatus() == GeschichteStatus.DRAFT && !currentUserHasBlogWrite()) {
|
|
||||||
// Use NOT_FOUND, not FORBIDDEN — don't leak DRAFT existence.
|
|
||||||
throw DomainException.notFound(
|
|
||||||
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id);
|
|
||||||
}
|
|
||||||
return g;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
|
|
||||||
* must be associated with every person id supplied. An empty or null list applies no
|
|
||||||
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
|
|
||||||
*/
|
|
||||||
public List<Geschichte> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
|
||||||
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
|
||||||
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
|
||||||
|
|
||||||
Specification<Geschichte> spec = Specification.allOf(
|
|
||||||
GeschichteSpecifications.hasStatus(effective),
|
|
||||||
GeschichteSpecifications.hasAllPersons(personIds),
|
|
||||||
GeschichteSpecifications.hasDocument(documentId),
|
|
||||||
GeschichteSpecifications.orderByDisplayDateDesc()
|
|
||||||
);
|
|
||||||
return geschichteRepository.findAll(spec, Sort.unsorted())
|
|
||||||
.stream()
|
|
||||||
.limit(safeLimit)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Write API ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Geschichte create(GeschichteUpdateDTO dto) {
|
|
||||||
requireTitle(dto.getTitle());
|
|
||||||
Geschichte g = Geschichte.builder()
|
|
||||||
.title(dto.getTitle().trim())
|
|
||||||
.body(sanitize(dto.getBody()))
|
|
||||||
.status(GeschichteStatus.DRAFT)
|
|
||||||
.author(currentUser())
|
|
||||||
.persons(resolvePersons(dto.getPersonIds()))
|
|
||||||
.documents(resolveDocuments(dto.getDocumentIds()))
|
|
||||||
.build();
|
|
||||||
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
|
|
||||||
g.setStatus(GeschichteStatus.PUBLISHED);
|
|
||||||
g.setPublishedAt(LocalDateTime.now());
|
|
||||||
}
|
|
||||||
return geschichteRepository.save(g);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Geschichte update(UUID id, GeschichteUpdateDTO dto) {
|
|
||||||
Geschichte g = geschichteRepository.findById(id)
|
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
|
||||||
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
|
|
||||||
if (dto.getTitle() != null) {
|
|
||||||
requireTitle(dto.getTitle());
|
|
||||||
g.setTitle(dto.getTitle().trim());
|
|
||||||
}
|
|
||||||
if (dto.getBody() != null) {
|
|
||||||
g.setBody(sanitize(dto.getBody()));
|
|
||||||
}
|
|
||||||
if (dto.getPersonIds() != null) {
|
|
||||||
g.setPersons(resolvePersons(dto.getPersonIds()));
|
|
||||||
}
|
|
||||||
if (dto.getDocumentIds() != null) {
|
|
||||||
g.setDocuments(resolveDocuments(dto.getDocumentIds()));
|
|
||||||
}
|
|
||||||
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
|
|
||||||
applyStatusTransition(g, dto.getStatus());
|
|
||||||
}
|
|
||||||
return geschichteRepository.save(g);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void delete(UUID id) {
|
|
||||||
if (!geschichteRepository.existsById(id)) {
|
|
||||||
throw DomainException.notFound(
|
|
||||||
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id);
|
|
||||||
}
|
|
||||||
geschichteRepository.deleteById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── private helpers ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private void applyStatusTransition(Geschichte g, GeschichteStatus next) {
|
|
||||||
g.setStatus(next);
|
|
||||||
if (next == GeschichteStatus.PUBLISHED) {
|
|
||||||
g.setPublishedAt(LocalDateTime.now());
|
|
||||||
} else {
|
|
||||||
g.setPublishedAt(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void requireTitle(String title) {
|
|
||||||
if (title == null || title.trim().isEmpty()) {
|
|
||||||
throw DomainException.badRequest(
|
|
||||||
ErrorCode.VALIDATION_ERROR, "Title is required");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String sanitize(String body) {
|
|
||||||
if (body == null) return null;
|
|
||||||
return BODY_SANITIZER.sanitize(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Set<Person> resolvePersons(List<UUID> ids) {
|
|
||||||
if (ids == null || ids.isEmpty()) return new HashSet<>();
|
|
||||||
return new LinkedHashSet<>(personService.getAllById(ids));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Set<Document> resolveDocuments(List<UUID> ids) {
|
|
||||||
if (ids == null || ids.isEmpty()) return new HashSet<>();
|
|
||||||
Set<Document> out = new LinkedHashSet<>();
|
|
||||||
for (UUID id : ids) {
|
|
||||||
out.add(documentService.getDocumentById(id));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private AppUser currentUser() {
|
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
if (auth == null || !auth.isAuthenticated()) {
|
|
||||||
throw DomainException.unauthorized("Authentication required");
|
|
||||||
}
|
|
||||||
return userService.findByEmail(auth.getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean currentUserHasBlogWrite() {
|
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
if (auth == null || !auth.isAuthenticated()) return false;
|
|
||||||
return auth.getAuthorities().stream()
|
|
||||||
.anyMatch(a -> Permission.BLOG_WRITE.name().equals(a.getAuthority()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -57,17 +58,6 @@ public class PersonService {
|
|||||||
return personRepository.findAllById(ids);
|
return personRepository.findAllById(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Person> findAllFamilyMembers() {
|
|
||||||
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Person setFamilyMember(UUID personId, boolean familyMember) {
|
|
||||||
Person person = getById(personId);
|
|
||||||
person.setFamilyMember(familyMember);
|
|
||||||
return personRepository.save(person);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Optional<Person> findByName(String firstName, String lastName) {
|
public Optional<Person> findByName(String firstName, String lastName) {
|
||||||
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,8 +134,6 @@ public class TranscriptionService {
|
|||||||
if (dto.getLabel() != null) {
|
if (dto.getLabel() != null) {
|
||||||
block.setLabel(dto.getLabel());
|
block.setLabel(dto.getLabel());
|
||||||
}
|
}
|
||||||
block.getMentionedPersons().clear();
|
|
||||||
block.getMentionedPersons().addAll(dto.getMentionedPersons());
|
|
||||||
block.setUpdatedBy(userId);
|
block.setUpdatedBy(userId);
|
||||||
|
|
||||||
TranscriptionBlock saved = blockRepository.save(block);
|
TranscriptionBlock saved = blockRepository.save(block);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
-- Geschichten: blog-like family memory stories linked to persons and documents (issue #381).
|
|
||||||
-- BLOG_WRITE permission gates authoring; DRAFT stories are never returned to readers.
|
|
||||||
|
|
||||||
CREATE TABLE geschichten (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
body TEXT,
|
|
||||||
status VARCHAR(32) NOT NULL,
|
|
||||||
author_id UUID REFERENCES users (id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL,
|
|
||||||
updated_at TIMESTAMP NOT NULL,
|
|
||||||
published_at TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE geschichten_persons (
|
|
||||||
geschichte_id UUID NOT NULL REFERENCES geschichten (id) ON DELETE CASCADE,
|
|
||||||
person_id UUID NOT NULL REFERENCES persons (id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (geschichte_id, person_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE geschichten_documents (
|
|
||||||
geschichte_id UUID NOT NULL REFERENCES geschichten (id) ON DELETE CASCADE,
|
|
||||||
document_id UUID NOT NULL REFERENCES documents (id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (geschichte_id, document_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Index page query: WHERE status = 'PUBLISHED' ORDER BY published_at DESC.
|
|
||||||
CREATE INDEX idx_geschichten_published
|
|
||||||
ON geschichten (published_at DESC)
|
|
||||||
WHERE status = 'PUBLISHED';
|
|
||||||
|
|
||||||
-- Reverse-lookup indexes for the ?personId / ?documentId filters.
|
|
||||||
CREATE INDEX idx_geschichten_persons_person ON geschichten_persons (person_id);
|
|
||||||
CREATE INDEX idx_geschichten_documents_document ON geschichten_documents (document_id);
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
-- Grant BLOG_WRITE to every existing group that already holds WRITE_ALL.
|
|
||||||
-- Without this, the Geschichten feature ships dark to production: no group
|
|
||||||
-- has BLOG_WRITE, so the editor controls are invisible and "+ Neue Geschichte"
|
|
||||||
-- is never rendered. The natural mapping is "groups that can already write
|
|
||||||
-- documents and tags can also author family stories." Admins can revoke or
|
|
||||||
-- re-assign via the group editor afterwards.
|
|
||||||
|
|
||||||
INSERT INTO group_permissions (group_id, permission)
|
|
||||||
SELECT DISTINCT gp.group_id, 'BLOG_WRITE'
|
|
||||||
FROM group_permissions gp
|
|
||||||
WHERE gp.permission = 'WRITE_ALL'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM group_permissions existing
|
|
||||||
WHERE existing.group_id = gp.group_id
|
|
||||||
AND existing.permission = 'BLOG_WRITE'
|
|
||||||
);
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
|
||||||
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
|
||||||
import org.raddatz.familienarchiv.model.Geschichte;
|
|
||||||
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
|
||||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
|
||||||
import org.raddatz.familienarchiv.service.GeschichteService;
|
|
||||||
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.time.LocalDateTime;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
||||||
|
|
||||||
@WebMvcTest(GeschichteController.class)
|
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
|
||||||
class GeschichteControllerTest {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
MockMvc mockMvc;
|
|
||||||
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
GeschichteService geschichteService;
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
CustomUserDetailsService customUserDetailsService;
|
|
||||||
|
|
||||||
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void list_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/geschichten"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void list_returns200_forReader() throws Exception {
|
|
||||||
when(geschichteService.list(any(), any(), any(), anyInt()))
|
|
||||||
.thenReturn(List.of(published(UUID.randomUUID(), "Story A")));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/geschichten"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$[0].title").value("Story A"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void list_passesSinglePersonIdFilterToServiceAsListOfOne() throws Exception {
|
|
||||||
UUID personId = UUID.randomUUID();
|
|
||||||
when(geschichteService.list(any(), eq(List.of(personId)), any(), anyInt()))
|
|
||||||
.thenReturn(List.of());
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/geschichten").param("personId", personId.toString()))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
verify(geschichteService).list(any(), eq(List.of(personId)), any(), anyInt());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void list_passesRepeatedPersonIdParamsAsListForAndFilter() throws Exception {
|
|
||||||
UUID a = UUID.randomUUID();
|
|
||||||
UUID b = UUID.randomUUID();
|
|
||||||
when(geschichteService.list(any(), eq(List.of(a, b)), any(), anyInt()))
|
|
||||||
.thenReturn(List.of());
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/geschichten")
|
|
||||||
.param("personId", a.toString())
|
|
||||||
.param("personId", b.toString()))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void getById_returns200_whenFound() throws Exception {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(geschichteService.getById(id)).thenReturn(published(id, "Hello"));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.id").value(id.toString()))
|
|
||||||
.andExpect(jsonPath("$.title").value("Hello"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(geschichteService.getById(id))
|
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
|
||||||
.andExpect(status().isNotFound())
|
|
||||||
.andExpect(jsonPath("$.code").value("GESCHICHTE_NOT_FOUND"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── POST /api/geschichten ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/geschichten")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"title\":\"x\"}"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void create_returns403_whenLackingBlogWrite() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/geschichten")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"title\":\"x\"}"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "BLOG_WRITE")
|
|
||||||
void create_returns201_withBlogWrite() throws Exception {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
|
|
||||||
.thenReturn(draft(id, "New"));
|
|
||||||
|
|
||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
|
||||||
dto.setTitle("New");
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/geschichten")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(dto)))
|
|
||||||
.andExpect(status().isCreated())
|
|
||||||
.andExpect(jsonPath("$.id").value(id.toString()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── PATCH /api/geschichten/{id} ─────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void update_returns403_whenLackingBlogWrite() throws Exception {
|
|
||||||
mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{}"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "BLOG_WRITE")
|
|
||||||
void update_returns200_withBlogWrite() throws Exception {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
|
||||||
.thenReturn(published(id, "Updated"));
|
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/geschichten/{id}", id)
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"status\":\"PUBLISHED\"}"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.status").value("PUBLISHED"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── DELETE /api/geschichten/{id} ────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void delete_returns403_whenLackingBlogWrite() throws Exception {
|
|
||||||
mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "BLOG_WRITE")
|
|
||||||
void delete_returns204_withBlogWrite() throws Exception {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
|
|
||||||
mockMvc.perform(delete("/api/geschichten/{id}", id))
|
|
||||||
.andExpect(status().isNoContent());
|
|
||||||
|
|
||||||
verify(geschichteService).delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private Geschichte published(UUID id, String title) {
|
|
||||||
return Geschichte.builder()
|
|
||||||
.id(id)
|
|
||||||
.title(title)
|
|
||||||
.body("<p>x</p>")
|
|
||||||
.status(GeschichteStatus.PUBLISHED)
|
|
||||||
.publishedAt(LocalDateTime.now())
|
|
||||||
.createdAt(LocalDateTime.now())
|
|
||||||
.updatedAt(LocalDateTime.now())
|
|
||||||
.persons(new HashSet<>())
|
|
||||||
.documents(new HashSet<>())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Geschichte draft(UUID id, String title) {
|
|
||||||
return Geschichte.builder()
|
|
||||||
.id(id)
|
|
||||||
.title(title)
|
|
||||||
.status(GeschichteStatus.DRAFT)
|
|
||||||
.createdAt(LocalDateTime.now())
|
|
||||||
.updatedAt(LocalDateTime.now())
|
|
||||||
.persons(new HashSet<>())
|
|
||||||
.documents(new HashSet<>())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -57,13 +57,6 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void getPersons_returns403_whenMissingReadAllPermission() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/persons"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void getPersons_returns200_withEmptyList() throws Exception {
|
void getPersons_returns200_withEmptyList() throws Exception {
|
||||||
when(personService.findAll(null)).thenReturn(Collections.emptyList());
|
when(personService.findAll(null)).thenReturn(Collections.emptyList());
|
||||||
mockMvc.perform(get("/api/persons"))
|
mockMvc.perform(get("/api/persons"))
|
||||||
@@ -71,7 +64,7 @@ class PersonControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser
|
||||||
void getPersons_delegatesQueryParam_toService() throws Exception {
|
void getPersons_delegatesQueryParam_toService() throws Exception {
|
||||||
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
|
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
|
||||||
when(personService.findAll("Hans")).thenReturn(List.of(dto));
|
when(personService.findAll("Hans")).thenReturn(List.of(dto));
|
||||||
@@ -92,7 +85,6 @@ class PersonControllerTest {
|
|||||||
public Integer getBirthYear() { return null; }
|
public Integer getBirthYear() { return null; }
|
||||||
public Integer getDeathYear() { return null; }
|
public Integer getDeathYear() { return null; }
|
||||||
public String getNotes() { return null; }
|
public String getNotes() { return null; }
|
||||||
public boolean isFamilyMember() { return false; }
|
|
||||||
public long getDocumentCount() { return 0; }
|
public long getDocumentCount() { return 0; }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -107,13 +99,6 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void getPerson_returns403_whenMissingReadAllPermission() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/persons/{id}", UUID.randomUUID()))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void getPerson_returns200_whenFound() throws Exception {
|
void getPerson_returns200_whenFound() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build();
|
Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build();
|
||||||
|
|||||||
@@ -183,36 +183,6 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void createBlock_returns400_whenMentionedPersonDisplayNameExceeds200Chars() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
|
||||||
String longName = "A".repeat(201);
|
|
||||||
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
|
||||||
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
|
|
||||||
+ "\",\"displayName\":\"" + longName + "\"}]}";
|
|
||||||
|
|
||||||
mockMvc.perform(post(URL_BASE)
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(body))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void createBlock_returns400_whenMentionedPersonPersonIdIsNull() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
|
||||||
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
|
||||||
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
|
||||||
|
|
||||||
mockMvc.perform(post(URL_BASE)
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(body))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── PUT /api/documents/{id}/transcription-blocks/{blockId} ─────────────
|
// ─── PUT /api/documents/{id}/transcription-blocks/{blockId} ─────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -251,34 +221,6 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(jsonPath("$.label").value("Anrede"));
|
.andExpect(jsonPath("$.label").value("Anrede"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void updateBlock_returns400_whenMentionedPersonDisplayNameExceeds200Chars() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
|
||||||
String longName = "A".repeat(201);
|
|
||||||
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
|
|
||||||
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
|
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(body))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void updateBlock_returns400_whenMentionedPersonPersonIdIsNull() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
|
||||||
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(body))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updateBlock_returns404_whenBlockDoesNotExist() throws Exception {
|
void updateBlock_returns404_whenBlockDoesNotExist() throws Exception {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,14 +5,13 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
import org.raddatz.familienarchiv.model.*;
|
import org.raddatz.familienarchiv.model.*;
|
||||||
|
import java.util.Set;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -25,7 +24,6 @@ class TrainingBlockQueryTest {
|
|||||||
@Autowired TranscriptionBlockRepository blockRepository;
|
@Autowired TranscriptionBlockRepository blockRepository;
|
||||||
@Autowired DocumentRepository documentRepository;
|
@Autowired DocumentRepository documentRepository;
|
||||||
@Autowired AnnotationRepository annotationRepository;
|
@Autowired AnnotationRepository annotationRepository;
|
||||||
@Autowired PersonRepository personRepository;
|
|
||||||
|
|
||||||
private UUID kurrentDocId;
|
private UUID kurrentDocId;
|
||||||
private UUID typewriterDocId;
|
private UUID typewriterDocId;
|
||||||
@@ -38,7 +36,7 @@ class TrainingBlockQueryTest {
|
|||||||
.title("Kurrent Brief")
|
.title("Kurrent Brief")
|
||||||
.originalFilename("kurrent.pdf")
|
.originalFilename("kurrent.pdf")
|
||||||
.status(DocumentStatus.UPLOADED)
|
.status(DocumentStatus.UPLOADED)
|
||||||
.trainingLabels(kurrentLabels())
|
.trainingLabels(new java.util.HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
|
||||||
.build());
|
.build());
|
||||||
kurrentDocId = kurrentDoc.getId();
|
kurrentDocId = kurrentDoc.getId();
|
||||||
|
|
||||||
@@ -113,115 +111,8 @@ class TrainingBlockQueryTest {
|
|||||||
assertThat(result).hasSize(2);
|
assertThat(result).hasSize(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── sender-based queries ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findManualKurrentBlocksByPerson_includesBlockFromKurrentLabelledDocument() {
|
|
||||||
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
|
|
||||||
Document doc = documentRepository.save(Document.builder()
|
|
||||||
.title("Brief von Karl")
|
|
||||||
.originalFilename("karl.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.sender(sender)
|
|
||||||
.trainingLabels(kurrentLabels())
|
|
||||||
.build());
|
|
||||||
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
|
|
||||||
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, false));
|
|
||||||
|
|
||||||
List<TranscriptionBlock> result = blockRepository.findManualKurrentBlocksByPerson(sender.getId());
|
|
||||||
|
|
||||||
assertThat(result).hasSize(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findManualKurrentBlocksByPerson_excludesDocumentWithoutKurrentLabel() {
|
|
||||||
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
|
|
||||||
Document doc = documentRepository.save(Document.builder()
|
|
||||||
.title("Brief von Karl")
|
|
||||||
.originalFilename("karl.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.sender(sender)
|
|
||||||
.build());
|
|
||||||
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
|
|
||||||
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, false));
|
|
||||||
|
|
||||||
List<TranscriptionBlock> result = blockRepository.findManualKurrentBlocksByPerson(sender.getId());
|
|
||||||
|
|
||||||
assertThat(result).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findManualKurrentBlocksByPerson_excludesOcrBlocks() {
|
|
||||||
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
|
|
||||||
Document doc = documentRepository.save(Document.builder()
|
|
||||||
.title("Brief von Karl")
|
|
||||||
.originalFilename("karl.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.sender(sender)
|
|
||||||
.trainingLabels(kurrentLabels())
|
|
||||||
.build());
|
|
||||||
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
|
|
||||||
blockRepository.save(block(doc.getId(), annId, BlockSource.OCR, false));
|
|
||||||
|
|
||||||
List<TranscriptionBlock> result = blockRepository.findManualKurrentBlocksByPerson(sender.getId());
|
|
||||||
|
|
||||||
assertThat(result).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findManualKurrentBlocksByPerson_excludesOtherSender() {
|
|
||||||
Person karl = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
|
|
||||||
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Test").build());
|
|
||||||
Document doc = documentRepository.save(Document.builder()
|
|
||||||
.title("Brief von Karl")
|
|
||||||
.originalFilename("karl.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.sender(karl)
|
|
||||||
.trainingLabels(kurrentLabels())
|
|
||||||
.build());
|
|
||||||
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
|
|
||||||
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, false));
|
|
||||||
|
|
||||||
List<TranscriptionBlock> result = blockRepository.findManualKurrentBlocksByPerson(anna.getId());
|
|
||||||
|
|
||||||
assertThat(result).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void countManualKurrentBlocksByPerson_matchesFindResult() {
|
|
||||||
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
|
|
||||||
Document doc = documentRepository.save(Document.builder()
|
|
||||||
.title("Brief von Karl")
|
|
||||||
.originalFilename("karl.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.sender(sender)
|
|
||||||
.trainingLabels(kurrentLabels())
|
|
||||||
.build());
|
|
||||||
UUID annId = annotationRepository.save(annotation(doc.getId())).getId();
|
|
||||||
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, false));
|
|
||||||
blockRepository.save(block(doc.getId(), annId, BlockSource.MANUAL, true));
|
|
||||||
|
|
||||||
long count = blockRepository.countManualKurrentBlocksByPerson(sender.getId());
|
|
||||||
List<TranscriptionBlock> found = blockRepository.findManualKurrentBlocksByPerson(sender.getId());
|
|
||||||
|
|
||||||
assertThat(count).isEqualTo(found.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void countManualKurrentBlocksByPerson_returnsZeroWhenNoBlocksMatch() {
|
|
||||||
Person sender = personRepository.save(Person.builder().firstName("Karl").lastName("Test").build());
|
|
||||||
|
|
||||||
long count = blockRepository.countManualKurrentBlocksByPerson(sender.getId());
|
|
||||||
|
|
||||||
assertThat(count).isZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static Set<TrainingLabel> kurrentLabels() {
|
|
||||||
return new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION));
|
|
||||||
}
|
|
||||||
|
|
||||||
private DocumentAnnotation annotation(UUID docId) {
|
private DocumentAnnotation annotation(UUID docId) {
|
||||||
return DocumentAnnotation.builder()
|
return DocumentAnnotation.builder()
|
||||||
.documentId(docId)
|
.documentId(docId)
|
||||||
|
|||||||
@@ -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,178 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
|
||||||
import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO;
|
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
|
||||||
import org.raddatz.familienarchiv.model.Geschichte;
|
|
||||||
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
|
||||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
|
||||||
import org.raddatz.familienarchiv.repository.GeschichteRepository;
|
|
||||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.context.annotation.Import;
|
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
import org.springframework.test.annotation.DirtiesContext;
|
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
|
||||||
@ActiveProfiles("test")
|
|
||||||
@Import(PostgresContainerConfig.class)
|
|
||||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
|
||||||
class GeschichteServiceIntegrationTest {
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
S3Client s3Client;
|
|
||||||
|
|
||||||
@Autowired GeschichteService geschichteService;
|
|
||||||
@Autowired GeschichteRepository geschichteRepository;
|
|
||||||
@Autowired PersonRepository personRepository;
|
|
||||||
@Autowired AppUserRepository appUserRepository;
|
|
||||||
|
|
||||||
AppUser writer;
|
|
||||||
AppUser reader;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void seed() {
|
|
||||||
writer = appUserRepository.save(AppUser.builder()
|
|
||||||
.email("writer-int@test")
|
|
||||||
.password("hash")
|
|
||||||
.build());
|
|
||||||
reader = appUserRepository.save(AppUser.builder()
|
|
||||||
.email("reader-int@test")
|
|
||||||
.password("hash")
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void clear() {
|
|
||||||
SecurityContextHolder.clearContext();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_then_publish_then_read_then_delete_full_lifecycle() {
|
|
||||||
// Create as writer
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
|
|
||||||
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("Raddatz").build());
|
|
||||||
|
|
||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
|
||||||
dto.setTitle("Erinnerung an Opa Franz");
|
|
||||||
dto.setBody("<p>Ich erinnere mich, wie er <strong>jeden Sonntag</strong> sang.</p>"
|
|
||||||
+ "<script>alert('xss')</script>");
|
|
||||||
dto.setPersonIds(List.of(franz.getId()));
|
|
||||||
|
|
||||||
Geschichte created = geschichteService.create(dto);
|
|
||||||
|
|
||||||
assertThat(created.getId()).isNotNull();
|
|
||||||
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
|
||||||
assertThat(created.getBody())
|
|
||||||
.contains("<strong>jeden Sonntag</strong>")
|
|
||||||
.doesNotContain("<script>");
|
|
||||||
|
|
||||||
// Reader cannot see DRAFT in list
|
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
|
||||||
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
|
|
||||||
|
|
||||||
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
|
|
||||||
UUID draftId = created.getId();
|
|
||||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
|
|
||||||
.hasMessageContaining("not found");
|
|
||||||
|
|
||||||
// Publish as writer
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
|
|
||||||
publishDto.setStatus(GeschichteStatus.PUBLISHED);
|
|
||||||
Geschichte publishedGesch = geschichteService.update(draftId, publishDto);
|
|
||||||
assertThat(publishedGesch.getPublishedAt()).isNotNull();
|
|
||||||
|
|
||||||
// Reader can now see and fetch it
|
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
|
||||||
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
|
|
||||||
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
|
|
||||||
Geschichte fetched = geschichteService.getById(draftId);
|
|
||||||
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
|
|
||||||
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
|
|
||||||
|
|
||||||
// Delete as writer; join rows go with it
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
geschichteService.delete(draftId);
|
|
||||||
assertThat(geschichteRepository.findById(draftId)).isEmpty();
|
|
||||||
|
|
||||||
// The Person itself is untouched (cascade only flows from Geschichte to join table)
|
|
||||||
assertThat(personRepository.findById(franz.getId())).isPresent();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void list_filters_with_AND_semantics_when_multiple_personIds_given() {
|
|
||||||
// Three published stories, persons overlap so we can prove AND-not-OR:
|
|
||||||
// story_AB: about A and B
|
|
||||||
// story_AC: about A and C
|
|
||||||
// story_A: about A only
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
|
|
||||||
Person a = personRepository.save(Person.builder().firstName("Anna").lastName("A").build());
|
|
||||||
Person b = personRepository.save(Person.builder().firstName("Bertha").lastName("B").build());
|
|
||||||
Person c = personRepository.save(Person.builder().firstName("Carl").lastName("C").build());
|
|
||||||
|
|
||||||
UUID storyAB = publishedStoryWithPersons("Anna & Bertha", List.of(a.getId(), b.getId()));
|
|
||||||
UUID storyAC = publishedStoryWithPersons("Anna & Carl", List.of(a.getId(), c.getId()));
|
|
||||||
UUID storyA = publishedStoryWithPersons("Anna alone", List.of(a.getId()));
|
|
||||||
|
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
|
||||||
|
|
||||||
// No filter → all three
|
|
||||||
assertThat(geschichteService.list(null, List.of(), null, 50))
|
|
||||||
.extracting(Geschichte::getId)
|
|
||||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
|
||||||
|
|
||||||
// Single filter (Anna) → all three
|
|
||||||
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
|
|
||||||
.extracting(Geschichte::getId)
|
|
||||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
|
||||||
|
|
||||||
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
|
|
||||||
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
|
|
||||||
.extracting(Geschichte::getId)
|
|
||||||
.containsExactly(storyAB);
|
|
||||||
|
|
||||||
// AND: Bertha AND Carl → none (no story has both)
|
|
||||||
assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), null, 50))
|
|
||||||
.isEmpty();
|
|
||||||
|
|
||||||
// AND: Anna AND Bertha AND Carl → none
|
|
||||||
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), null, 50))
|
|
||||||
.isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
private UUID publishedStoryWithPersons(String title, List<UUID> personIds) {
|
|
||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
|
||||||
dto.setTitle(title);
|
|
||||||
dto.setBody("<p>body</p>");
|
|
||||||
dto.setPersonIds(personIds);
|
|
||||||
dto.setStatus(GeschichteStatus.PUBLISHED);
|
|
||||||
return geschichteService.create(dto).getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void authenticateAs(AppUser user, Permission... permissions) {
|
|
||||||
var authorities = java.util.Arrays.stream(permissions)
|
|
||||||
.map(p -> new SimpleGrantedAuthority(p.name()))
|
|
||||||
.toList();
|
|
||||||
SecurityContextHolder.getContext().setAuthentication(
|
|
||||||
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,441 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
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.dto.GeschichteUpdateDTO;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
|
||||||
import org.raddatz.familienarchiv.model.Geschichte;
|
|
||||||
import org.raddatz.familienarchiv.model.GeschichteStatus;
|
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
|
||||||
import org.raddatz.familienarchiv.repository.GeschichteRepository;
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
|
||||||
import org.springframework.data.domain.Sort;
|
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
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.ArgumentMatchers.eq;
|
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class GeschichteServiceTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
GeschichteRepository geschichteRepository;
|
|
||||||
@Mock
|
|
||||||
PersonService personService;
|
|
||||||
@Mock
|
|
||||||
DocumentService documentService;
|
|
||||||
@Mock
|
|
||||||
UserService userService;
|
|
||||||
|
|
||||||
@InjectMocks
|
|
||||||
GeschichteService geschichteService;
|
|
||||||
|
|
||||||
AppUser writer;
|
|
||||||
AppUser reader;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
SecurityContextHolder.clearContext();
|
|
||||||
writer = AppUser.builder().id(UUID.randomUUID()).email("writer@test").build();
|
|
||||||
reader = AppUser.builder().id(UUID.randomUUID()).email("reader@test").build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void tearDown() {
|
|
||||||
SecurityContextHolder.clearContext();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── getById ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getById_throws_NOT_FOUND_for_draft_when_user_lacks_BLOG_WRITE() {
|
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Geschichte draft = draft(id);
|
|
||||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft));
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> geschichteService.getById(id))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting("code")
|
|
||||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getById_returns_draft_when_user_has_BLOG_WRITE() {
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Geschichte draft = draft(id);
|
|
||||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft));
|
|
||||||
|
|
||||||
Geschichte result = geschichteService.getById(id);
|
|
||||||
|
|
||||||
assertThat(result).isSameAs(draft);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getById_returns_published_to_anyone_authenticated() {
|
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Geschichte published = published(id);
|
|
||||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(published));
|
|
||||||
|
|
||||||
Geschichte result = geschichteService.getById(id);
|
|
||||||
|
|
||||||
assertThat(result).isSameAs(published);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getById_throws_NOT_FOUND_when_id_unknown() {
|
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> geschichteService.getById(id))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting("code")
|
|
||||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── list ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
|
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
|
||||||
.thenReturn(List.of(published(UUID.randomUUID())));
|
|
||||||
|
|
||||||
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
|
|
||||||
|
|
||||||
// Status pinning lives inside the Specification; we assert end-to-end behaviour
|
|
||||||
// in GeschichteServiceIntegrationTest. Here we just confirm the service routes
|
|
||||||
// through the spec-aware repository method.
|
|
||||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
|
||||||
.thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID())));
|
|
||||||
|
|
||||||
List<Geschichte> out = geschichteService.list(null, List.of(), null, 50);
|
|
||||||
|
|
||||||
assertThat(out).hasSize(2);
|
|
||||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void list_invokes_repository_findAll_when_filtering_by_single_personId() {
|
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
|
||||||
UUID personId = UUID.randomUUID();
|
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
|
||||||
.thenReturn(List.of());
|
|
||||||
|
|
||||||
geschichteService.list(null, List.of(personId), null, 50);
|
|
||||||
|
|
||||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() {
|
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
|
||||||
UUID a = UUID.randomUUID();
|
|
||||||
UUID b = UUID.randomUUID();
|
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
|
||||||
.thenReturn(List.of());
|
|
||||||
|
|
||||||
geschichteService.list(null, List.of(a, b), null, 50);
|
|
||||||
|
|
||||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void list_filters_by_documentId() {
|
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
|
||||||
UUID documentId = UUID.randomUUID();
|
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
|
||||||
.thenReturn(List.of());
|
|
||||||
|
|
||||||
geschichteService.list(null, List.of(), documentId, 50);
|
|
||||||
|
|
||||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
|
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
|
||||||
.thenReturn(List.of(published(UUID.randomUUID())));
|
|
||||||
|
|
||||||
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
|
|
||||||
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
|
|
||||||
|
|
||||||
assertThat(out).hasSizeLessThanOrEqualTo(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── create ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_sets_status_to_DRAFT_by_default() {
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
|
||||||
when(geschichteRepository.save(any(Geschichte.class)))
|
|
||||||
.thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
|
||||||
dto.setTitle("My Story");
|
|
||||||
dto.setBody("<p>plain text</p>");
|
|
||||||
|
|
||||||
Geschichte saved = geschichteService.create(dto);
|
|
||||||
|
|
||||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
|
||||||
assertThat(saved.getPublishedAt()).isNull();
|
|
||||||
assertThat(saved.getAuthor()).isSameAs(writer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_sanitizes_body_HTML_dropping_disallowed_tags() {
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
|
||||||
when(geschichteRepository.save(any(Geschichte.class)))
|
|
||||||
.thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
|
||||||
dto.setTitle("XSS attempt");
|
|
||||||
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
|
|
||||||
|
|
||||||
Geschichte saved = geschichteService.create(dto);
|
|
||||||
|
|
||||||
assertThat(saved.getBody())
|
|
||||||
.contains("<p>safe</p>")
|
|
||||||
.doesNotContain("<script>")
|
|
||||||
.doesNotContain("onerror")
|
|
||||||
.doesNotContain("<img");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_keeps_allowed_tags_strong_em_h2_h3_ul_ol_li() {
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
|
||||||
when(geschichteRepository.save(any(Geschichte.class)))
|
|
||||||
.thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
|
||||||
dto.setTitle("Rich");
|
|
||||||
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
|
|
||||||
+ "<ul><li>one</li></ul><ol><li>first</li></ol>");
|
|
||||||
|
|
||||||
Geschichte saved = geschichteService.create(dto);
|
|
||||||
|
|
||||||
assertThat(saved.getBody())
|
|
||||||
.contains("<h2>Heading</h2>")
|
|
||||||
.contains("<strong>bold</strong>")
|
|
||||||
.contains("<em>italic</em>")
|
|
||||||
.contains("<ul>")
|
|
||||||
.contains("<ol>")
|
|
||||||
.contains("<li>one</li>");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_resolves_personIds_via_PersonService() {
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
|
||||||
UUID personId = UUID.randomUUID();
|
|
||||||
Person person = Person.builder().id(personId).build();
|
|
||||||
when(personService.getAllById(List.of(personId))).thenReturn(List.of(person));
|
|
||||||
when(geschichteRepository.save(any(Geschichte.class)))
|
|
||||||
.thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
|
||||||
dto.setTitle("Linked");
|
|
||||||
dto.setPersonIds(List.of(personId));
|
|
||||||
|
|
||||||
Geschichte saved = geschichteService.create(dto);
|
|
||||||
|
|
||||||
assertThat(saved.getPersons()).containsExactly(person);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_resolves_documentIds_via_DocumentService() {
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
Document doc = Document.builder().id(docId).build();
|
|
||||||
when(documentService.getDocumentById(docId)).thenReturn(doc);
|
|
||||||
when(geschichteRepository.save(any(Geschichte.class)))
|
|
||||||
.thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
|
||||||
dto.setTitle("Linked doc");
|
|
||||||
dto.setDocumentIds(List.of(docId));
|
|
||||||
|
|
||||||
Geschichte saved = geschichteService.create(dto);
|
|
||||||
|
|
||||||
assertThat(saved.getDocuments()).containsExactly(doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void create_throws_BAD_REQUEST_when_title_blank() {
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
|
|
||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
|
||||||
dto.setTitle(" ");
|
|
||||||
dto.setBody("<p>x</p>");
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> geschichteService.create(dto))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting("code")
|
|
||||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── update ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void update_sets_publishedAt_when_status_transitions_to_PUBLISHED() {
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Geschichte existing = draft(id);
|
|
||||||
existing.setPublishedAt(null);
|
|
||||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
|
||||||
when(geschichteRepository.save(any(Geschichte.class)))
|
|
||||||
.thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
|
||||||
dto.setStatus(GeschichteStatus.PUBLISHED);
|
|
||||||
|
|
||||||
Geschichte saved = geschichteService.update(id, dto);
|
|
||||||
|
|
||||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
|
|
||||||
assertThat(saved.getPublishedAt()).isNotNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void update_clears_publishedAt_when_status_transitions_back_to_DRAFT() {
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Geschichte existing = published(id);
|
|
||||||
existing.setPublishedAt(LocalDateTime.now().minusDays(1));
|
|
||||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
|
||||||
when(geschichteRepository.save(any(Geschichte.class)))
|
|
||||||
.thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
|
||||||
dto.setStatus(GeschichteStatus.DRAFT);
|
|
||||||
|
|
||||||
Geschichte saved = geschichteService.update(id, dto);
|
|
||||||
|
|
||||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
|
||||||
assertThat(saved.getPublishedAt()).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void update_sanitizes_body_on_save() {
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft(id)));
|
|
||||||
when(geschichteRepository.save(any(Geschichte.class)))
|
|
||||||
.thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
|
||||||
dto.setBody("<p>ok</p><script>alert(1)</script>");
|
|
||||||
|
|
||||||
Geschichte saved = geschichteService.update(id, dto);
|
|
||||||
|
|
||||||
assertThat(saved.getBody()).doesNotContain("<script>").contains("<p>ok</p>");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void update_throws_NOT_FOUND_when_geschichte_unknown() {
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> geschichteService.update(id, new GeschichteUpdateDTO()))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting("code")
|
|
||||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── delete ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void delete_calls_repository_deleteById() {
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(geschichteRepository.existsById(id)).thenReturn(true);
|
|
||||||
|
|
||||||
geschichteService.delete(id);
|
|
||||||
|
|
||||||
verify(geschichteRepository).deleteById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void delete_throws_NOT_FOUND_when_unknown() {
|
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(geschichteRepository.existsById(id)).thenReturn(false);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> geschichteService.delete(id))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting("code")
|
|
||||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
|
||||||
verify(geschichteRepository, never()).deleteById(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private void authenticateAs(AppUser user, Permission... permissions) {
|
|
||||||
var authorities = List.of(permissions).stream()
|
|
||||||
.map(p -> new SimpleGrantedAuthority(p.name()))
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
SecurityContextHolder.getContext().setAuthentication(
|
|
||||||
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Geschichte draft(UUID id) {
|
|
||||||
return Geschichte.builder()
|
|
||||||
.id(id)
|
|
||||||
.title("Draft")
|
|
||||||
.body("<p>body</p>")
|
|
||||||
.status(GeschichteStatus.DRAFT)
|
|
||||||
.persons(new HashSet<>())
|
|
||||||
.documents(new HashSet<>())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Geschichte published(UUID id) {
|
|
||||||
return Geschichte.builder()
|
|
||||||
.id(id)
|
|
||||||
.title("Published")
|
|
||||||
.body("<p>body</p>")
|
|
||||||
.status(GeschichteStatus.PUBLISHED)
|
|
||||||
.publishedAt(LocalDateTime.now().minusHours(1))
|
|
||||||
.persons(new HashSet<>())
|
|
||||||
.documents(new HashSet<>())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,10 +24,7 @@ import java.util.UUID;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.Mockito.*;
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class PersonServiceTest {
|
class PersonServiceTest {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import org.raddatz.familienarchiv.model.BlockSource;
|
|||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.PersonMention;
|
|
||||||
import org.raddatz.familienarchiv.model.ScriptType;
|
import org.raddatz.familienarchiv.model.ScriptType;
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
||||||
@@ -99,9 +98,7 @@ class TranscriptionServiceTest {
|
|||||||
return b;
|
return b;
|
||||||
});
|
});
|
||||||
|
|
||||||
CreateTranscriptionBlockDTO dto = CreateTranscriptionBlockDTO.builder()
|
CreateTranscriptionBlockDTO dto = new CreateTranscriptionBlockDTO(1, 0.1, 0.2, 0.3, 0.4, "hello", null);
|
||||||
.pageNumber(1).x(0.1).y(0.2).width(0.3).height(0.4)
|
|
||||||
.text("hello").build();
|
|
||||||
|
|
||||||
TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId);
|
TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId);
|
||||||
|
|
||||||
@@ -171,7 +168,7 @@ class TranscriptionServiceTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
|
|
||||||
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder().text("new text").build();
|
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("new text", null);
|
||||||
|
|
||||||
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId);
|
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId);
|
||||||
|
|
||||||
@@ -192,7 +189,7 @@ class TranscriptionServiceTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
|
|
||||||
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder().text("text").label("Anrede").build();
|
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("text", "Anrede");
|
||||||
|
|
||||||
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
|
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
|
||||||
|
|
||||||
@@ -211,65 +208,11 @@ class TranscriptionServiceTest {
|
|||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
|
|
||||||
TranscriptionBlock result = transcriptionService.updateBlock(
|
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);
|
assertThat(result.getSource()).isEqualTo(BlockSource.MANUAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateBlock_replacesMentionedPersonsFromDto() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID blockId = UUID.randomUUID();
|
|
||||||
UUID personId = UUID.randomUUID();
|
|
||||||
|
|
||||||
TranscriptionBlock block = TranscriptionBlock.builder()
|
|
||||||
.id(blockId).documentId(docId).text("old").build();
|
|
||||||
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
|
|
||||||
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
when(documentService.getDocumentById(any())).thenReturn(
|
|
||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
|
||||||
|
|
||||||
PersonMention mention = new PersonMention(personId, "Auguste");
|
|
||||||
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder()
|
|
||||||
.text("@Auguste text")
|
|
||||||
.mentionedPersons(List.of(mention))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
|
|
||||||
|
|
||||||
assertThat(result.getMentionedPersons())
|
|
||||||
.containsExactly(mention);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateBlock_clearsPriorMentions_beforeApplyingDto() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID blockId = UUID.randomUUID();
|
|
||||||
|
|
||||||
PersonMention prior = new PersonMention(UUID.randomUUID(), "Heinrich");
|
|
||||||
PersonMention incoming = new PersonMention(UUID.randomUUID(), "Auguste");
|
|
||||||
|
|
||||||
TranscriptionBlock block = TranscriptionBlock.builder()
|
|
||||||
.id(blockId).documentId(docId).text("old").build();
|
|
||||||
block.getMentionedPersons().add(prior);
|
|
||||||
|
|
||||||
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
|
|
||||||
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
when(documentService.getDocumentById(any())).thenReturn(
|
|
||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
|
||||||
|
|
||||||
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder()
|
|
||||||
.text("@Auguste text")
|
|
||||||
.mentionedPersons(List.of(incoming))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
|
|
||||||
|
|
||||||
assertThat(result.getMentionedPersons())
|
|
||||||
.containsExactly(incoming)
|
|
||||||
.doesNotContain(prior);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateBlock_triggersTraining_whenKurrentSenderPresent() {
|
void updateBlock_triggersTraining_whenKurrentSenderPresent() {
|
||||||
UUID docId = UUID.randomUUID();
|
UUID docId = UUID.randomUUID();
|
||||||
@@ -283,7 +226,7 @@ class TranscriptionServiceTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).sender(sender).build());
|
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).sender(sender).build());
|
||||||
|
|
||||||
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("text").build(), UUID.randomUUID());
|
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null), UUID.randomUUID());
|
||||||
|
|
||||||
verify(senderModelService).checkAndTriggerTraining(senderId);
|
verify(senderModelService).checkAndTriggerTraining(senderId);
|
||||||
}
|
}
|
||||||
@@ -299,7 +242,7 @@ class TranscriptionServiceTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).build());
|
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).build());
|
||||||
|
|
||||||
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("text").build(), UUID.randomUUID());
|
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null), UUID.randomUUID());
|
||||||
|
|
||||||
verify(senderModelService, never()).checkAndTriggerTraining(any());
|
verify(senderModelService, never()).checkAndTriggerTraining(any());
|
||||||
}
|
}
|
||||||
@@ -534,7 +477,7 @@ class TranscriptionServiceTest {
|
|||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
when(annotationRepository.findById(annotId)).thenReturn(Optional.of(annotation));
|
when(annotationRepository.findById(annotId)).thenReturn(Optional.of(annotation));
|
||||||
|
|
||||||
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new text").build(), userId);
|
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("new text", null), userId);
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
|
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
|
||||||
@@ -559,7 +502,7 @@ class TranscriptionServiceTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(
|
when(documentService.getDocumentById(any())).thenReturn(
|
||||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||||
|
|
||||||
transcriptionService.updateBlock(docId, blockId, 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());
|
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
@@ -11,7 +11,6 @@ bun.lockb
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
/.svelte-kit/
|
/.svelte-kit/
|
||||||
/.svelte-kit-backup/
|
/.svelte-kit-backup/
|
||||||
/.svelte-kit.old/
|
|
||||||
|
|
||||||
# Generated files
|
# Generated files
|
||||||
/.svelte-kit-backup/
|
/.svelte-kit-backup/
|
||||||
@@ -23,6 +22,5 @@ bun.lockb
|
|||||||
|
|
||||||
# Test artifacts
|
# Test artifacts
|
||||||
/test-results/
|
/test-results/
|
||||||
/test-results.locked/
|
|
||||||
/e2e/.auth/
|
/e2e/.auth/
|
||||||
/coverage/
|
/coverage/
|
||||||
|
|||||||
@@ -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,151 +0,0 @@
|
|||||||
import AxeBuilder from '@axe-core/playwright';
|
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal Geschichten coverage. The deeper a11y / visual-regression suite is
|
|
||||||
* tracked separately; this file proves the core writer + reader journey works
|
|
||||||
* end-to-end against the real stack.
|
|
||||||
*
|
|
||||||
* Pre-requisite: V59 has granted BLOG_WRITE to the Administrators group, so
|
|
||||||
* the seeded admin user can author. The auth.setup project handles login.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
|
|
||||||
|
|
||||||
test.describe('Geschichten — writer + reader journey', () => {
|
|
||||||
test('admin can create a draft, publish it, and see it on the index', async ({ page }) => {
|
|
||||||
const title = `E2E story ${stamp()}`;
|
|
||||||
|
|
||||||
// Land on the index — empty state or pre-existing demo data is fine
|
|
||||||
await page.goto('/geschichten');
|
|
||||||
await page.waitForSelector('[data-hydrated]');
|
|
||||||
await expect(page.getByRole('heading', { name: 'Geschichten', level: 1 })).toBeVisible();
|
|
||||||
|
|
||||||
// Click "Neue Geschichte" — visible because admin has BLOG_WRITE
|
|
||||||
await page.getByRole('link', { name: 'Neue Geschichte' }).click();
|
|
||||||
await page.waitForURL('/geschichten/new');
|
|
||||||
|
|
||||||
// Fill in title — the body editor is Tiptap and harder to script reliably
|
|
||||||
await page.getByPlaceholder('Titel der Geschichte').fill(title);
|
|
||||||
|
|
||||||
// Save as draft and verify we land on the detail page
|
|
||||||
await page.getByRole('button', { name: 'Entwurf speichern' }).click();
|
|
||||||
await page.waitForURL(/\/geschichten\/[^/]+$/);
|
|
||||||
|
|
||||||
// Capture the new id from the URL
|
|
||||||
const detailUrl = page.url();
|
|
||||||
const id = detailUrl.split('/').pop();
|
|
||||||
expect(id).toBeTruthy();
|
|
||||||
|
|
||||||
// Publish from the edit page
|
|
||||||
await page.getByRole('link', { name: 'Bearbeiten' }).click();
|
|
||||||
await page.waitForURL(/\/edit$/);
|
|
||||||
await page.getByRole('button', { name: 'Veröffentlichen' }).click();
|
|
||||||
await page.waitForURL(detailUrl);
|
|
||||||
|
|
||||||
// Index now shows the published story
|
|
||||||
await page.goto('/geschichten');
|
|
||||||
await expect(page.getByRole('link', { name: title })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reader is taken to a story detail when clicking a card', async ({ page }) => {
|
|
||||||
await page.goto('/geschichten');
|
|
||||||
await page.waitForSelector('[data-hydrated]');
|
|
||||||
|
|
||||||
// Use the first story link in the list (demo data exists; if not, the
|
|
||||||
// previous test seeded one). The link wraps the whole card.
|
|
||||||
const firstStory = page.locator('a[href^="/geschichten/"]').filter({ hasText: /.+/ }).first();
|
|
||||||
await expect(firstStory).toBeVisible();
|
|
||||||
await firstStory.click();
|
|
||||||
|
|
||||||
await page.waitForURL(/\/geschichten\/[^/]+$/);
|
|
||||||
await expect(page.locator('article')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('multi-person filter: chips, URL params, and AND removal work end-to-end', async ({
|
|
||||||
page
|
|
||||||
}) => {
|
|
||||||
await page.goto('/geschichten');
|
|
||||||
await page.waitForSelector('[data-hydrated]');
|
|
||||||
|
|
||||||
// We need two distinct persons to filter by, but we don't want to couple this
|
|
||||||
// test to specific seed names. Strategy: type a single broadly-occurring vowel
|
|
||||||
// ("e" is present in the vast majority of German names), open the listbox,
|
|
||||||
// and pick whichever option matches the predicate.
|
|
||||||
//
|
|
||||||
// option DOM ids encode the person id as `${listboxId}-option-${personId}`,
|
|
||||||
// so we can identify the *first different* option without knowing the seed.
|
|
||||||
const PROBE = 'e';
|
|
||||||
|
|
||||||
async function openPicker() {
|
|
||||||
await page.getByRole('button', { name: /Person wählen/ }).click();
|
|
||||||
const input = page.getByRole('combobox', { name: /Person wählen/ });
|
|
||||||
await input.fill(PROBE);
|
|
||||||
// Wait for the listbox to be populated.
|
|
||||||
await expect(page.getByRole('option').first()).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickFirstOption(): Promise<string> {
|
|
||||||
const opt = page.getByRole('option').first();
|
|
||||||
const optId = (await opt.getAttribute('id')) ?? '';
|
|
||||||
const personId = optId.split('-option-')[1] ?? '';
|
|
||||||
expect(personId).not.toEqual('');
|
|
||||||
await opt.click();
|
|
||||||
return personId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickFirstOptionDifferentFrom(excludeId: string): Promise<string> {
|
|
||||||
// Iterate through visible options and return the first whose person id != excludeId.
|
|
||||||
const optionCount = await page.getByRole('option').count();
|
|
||||||
for (let i = 0; i < optionCount; i++) {
|
|
||||||
const candidate = page.getByRole('option').nth(i);
|
|
||||||
const optId = (await candidate.getAttribute('id')) ?? '';
|
|
||||||
const personId = optId.split('-option-')[1] ?? '';
|
|
||||||
if (personId && personId !== excludeId) {
|
|
||||||
await candidate.click();
|
|
||||||
return personId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`Expected at least two distinct persons matching "${PROBE}" in the seed, found only one.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await openPicker();
|
|
||||||
const firstId = await pickFirstOption();
|
|
||||||
await page.waitForURL(/personId=/);
|
|
||||||
const firstIds = new URL(page.url()).searchParams.getAll('personId');
|
|
||||||
expect(firstIds).toEqual([firstId]);
|
|
||||||
|
|
||||||
await openPicker();
|
|
||||||
const secondId = await pickFirstOptionDifferentFrom(firstId);
|
|
||||||
await page.waitForURL((url) => url.searchParams.getAll('personId').length === 2);
|
|
||||||
const secondIds = new URL(page.url()).searchParams.getAll('personId');
|
|
||||||
expect(secondIds).toEqual([firstId, secondId]);
|
|
||||||
expect(secondId).not.toEqual(firstId);
|
|
||||||
|
|
||||||
// Two chips visible — find them by their remove-aria-label pattern
|
|
||||||
const chipButtons = page.getByRole('button', { name: /aus Filter entfernen/ });
|
|
||||||
await expect(chipButtons).toHaveCount(2);
|
|
||||||
|
|
||||||
// Remove the first chip — URL drops to one param, only the second id remains
|
|
||||||
await chipButtons.first().click();
|
|
||||||
await page.waitForURL((url) => url.searchParams.getAll('personId').length === 1);
|
|
||||||
const finalIds = new URL(page.url()).searchParams.getAll('personId');
|
|
||||||
expect(finalIds).toEqual([secondId]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('AxeBuilder finds no critical violations on the index', async ({ page }) => {
|
|
||||||
await page.goto('/geschichten');
|
|
||||||
await page.waitForSelector('[data-hydrated]');
|
|
||||||
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
|
|
||||||
|
|
||||||
// Filter to non-deferred severity. We don't gate the whole PR on a clean
|
|
||||||
// AxeBuilder run yet — Sara's review tracks the broader a11y backlog —
|
|
||||||
// but any "serious" or "critical" finding from this scan would block merge.
|
|
||||||
const blocking = results.violations.filter(
|
|
||||||
(v) => v.impact === 'serious' || v.impact === 'critical'
|
|
||||||
);
|
|
||||||
expect(blocking, JSON.stringify(blocking, null, 2)).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,163 +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.
|
|
||||||
// Sara #3: timestamp the title so a previous run that crashed in beforeAll
|
|
||||||
// (and therefore skipped afterAll cleanup) cannot collide with this one.
|
|
||||||
const uniqueSuffix = Date.now();
|
|
||||||
const docRes = await request.post('/api/documents', {
|
|
||||||
multipart: {
|
|
||||||
title: `E2E Person Mention Read ${uniqueSuffix}`,
|
|
||||||
documentDate: '1945-05-08'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
|
|
||||||
const doc = await docRes.json();
|
|
||||||
docId = doc.id;
|
|
||||||
docHref = `${baseURL}/documents/${docId}`;
|
|
||||||
|
|
||||||
await request.put(`/api/documents/${docId}`, {
|
|
||||||
multipart: {
|
|
||||||
title: doc.title as string,
|
|
||||||
documentDate: '1945-05-08',
|
|
||||||
file: {
|
|
||||||
name: 'minimal.pdf',
|
|
||||||
mimeType: 'application/pdf',
|
|
||||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Annotation to anchor the block on the page.
|
|
||||||
const annRes = await request.post(`/api/documents/${docId}/annotations`, {
|
|
||||||
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.5, height: 0.1, color: '#00C7B1' }
|
|
||||||
});
|
|
||||||
if (!annRes.ok()) throw new Error(`Create annotation failed: ${annRes.status()}`);
|
|
||||||
|
|
||||||
// 4. Block text contains @Auguste Raddatz; sidecar links it to personId.
|
|
||||||
const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
|
||||||
data: {
|
|
||||||
pageNumber: 1,
|
|
||||||
x: 0.1,
|
|
||||||
y: 0.1,
|
|
||||||
width: 0.5,
|
|
||||||
height: 0.1,
|
|
||||||
text: 'Brief an @Auguste Raddatz vom Mai 1944',
|
|
||||||
label: null,
|
|
||||||
mentionedPersons: [{ personId, displayName: 'Auguste Raddatz' }]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async ({ request }) => {
|
|
||||||
if (docId) await request.delete(`/api/documents/${docId}`);
|
|
||||||
if (personId) await request.delete(`/api/persons/${personId}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders the @mention as an underlined anchor link to /persons/{id}', async ({ page }) => {
|
|
||||||
await page.goto(docHref);
|
|
||||||
await page.getByRole('button', { name: 'Transkription' }).click();
|
|
||||||
|
|
||||||
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
|
||||||
await expect(link).toBeVisible({ timeout: 5000 });
|
|
||||||
await expect(link).toHaveAttribute('href', `/persons/${personId}`);
|
|
||||||
// The @ trigger is stripped from the rendered text per spec
|
|
||||||
await expect(link).toHaveText('Auguste Raddatz');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('B20: desktop hover mounts the hover card with loaded person data', async ({ page }) => {
|
|
||||||
await page.goto(docHref);
|
|
||||||
await page.getByRole('button', { name: 'Transkription' }).click();
|
|
||||||
|
|
||||||
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
|
||||||
await link.hover();
|
|
||||||
|
|
||||||
const card = page.getByTestId('person-hover-card');
|
|
||||||
await expect(card).toBeVisible({ timeout: 5000 });
|
|
||||||
// Loaded state: person displayName is rendered inside the card
|
|
||||||
await expect(page.getByTestId('person-hover-card-name')).toHaveText('Auguste Raddatz');
|
|
||||||
// Footer link points to /persons/{id}
|
|
||||||
await expect(card.locator(`a[href="/persons/${personId}"]`)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('B20: hover card unmounts on mouseleave', async ({ page }) => {
|
|
||||||
await page.goto(docHref);
|
|
||||||
await page.getByRole('button', { name: 'Transkription' }).click();
|
|
||||||
|
|
||||||
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
|
||||||
await link.hover();
|
|
||||||
await expect(page.getByTestId('person-hover-card')).toBeVisible();
|
|
||||||
|
|
||||||
// Move pointer away
|
|
||||||
await page.mouse.move(0, 0);
|
|
||||||
await expect(page.getByTestId('person-hover-card')).toBeHidden({ timeout: 2000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('B21: touch-device tap navigates without showing the hover card', async ({ browser }) => {
|
|
||||||
const context = await browser.newContext({
|
|
||||||
...devices['Pixel 7'],
|
|
||||||
storageState: STORAGE_STATE
|
|
||||||
});
|
|
||||||
const touchPage = await context.newPage();
|
|
||||||
try {
|
|
||||||
await touchPage.goto(docHref);
|
|
||||||
await touchPage.getByRole('button', { name: 'Transkription' }).click();
|
|
||||||
|
|
||||||
const link = touchPage.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
|
||||||
await expect(link).toBeVisible({ timeout: 5000 });
|
|
||||||
|
|
||||||
// Sara #2: assert no card *before* the tap so the test actually proves
|
|
||||||
// the touch device suppression worked, not just that we navigated away.
|
|
||||||
await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0);
|
|
||||||
|
|
||||||
await link.tap();
|
|
||||||
// The card never mounted — the tap navigated directly per spec.
|
|
||||||
await expect(touchPage).toHaveURL(new RegExp(`/persons/${personId}`));
|
|
||||||
} finally {
|
|
||||||
await context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -12,7 +12,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
|||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
includeIgnoreFile(gitignorePath),
|
includeIgnoreFile(gitignorePath),
|
||||||
{ ignores: ['src/paraglide/**', '.svelte-kit.old/**'] },
|
{ ignores: ['src/paraglide/**'] },
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...ts.configs.recommended,
|
...ts.configs.recommended,
|
||||||
...svelte.configs.recommended,
|
...svelte.configs.recommended,
|
||||||
|
|||||||
@@ -228,7 +228,6 @@
|
|||||||
"admin_perm_read_all": "Nur lesen",
|
"admin_perm_read_all": "Nur lesen",
|
||||||
"admin_perm_annotate_all": "Lesen & Annotieren",
|
"admin_perm_annotate_all": "Lesen & Annotieren",
|
||||||
"admin_perm_write_all": "Lesen & Schreiben",
|
"admin_perm_write_all": "Lesen & Schreiben",
|
||||||
"admin_perm_blog_write": "Geschichten schreiben",
|
|
||||||
"admin_perm_admin": "Vollzugriff (Admin)",
|
"admin_perm_admin": "Vollzugriff (Admin)",
|
||||||
"admin_perm_admin_user": "Benutzer verwalten",
|
"admin_perm_admin_user": "Benutzer verwalten",
|
||||||
"admin_perm_admin_tag": "Schlagworte verwalten",
|
"admin_perm_admin_tag": "Schlagworte verwalten",
|
||||||
@@ -421,15 +420,6 @@
|
|||||||
"notification_unread": "ungelesen",
|
"notification_unread": "ungelesen",
|
||||||
"mention_btn_label": "Person erwähnen",
|
"mention_btn_label": "Person erwähnen",
|
||||||
"mention_popup_empty": "Keine Nutzer gefunden",
|
"mention_popup_empty": "Keine Nutzer gefunden",
|
||||||
"person_mention_open_link": "Zur Person",
|
|
||||||
"person_mention_hover_hint": "Klick öffnet Seite",
|
|
||||||
"person_mention_load_error": "Person konnte nicht geladen werden.",
|
|
||||||
"person_mention_loading": "Lade Person…",
|
|
||||||
"person_mention_popup_empty": "Keine Personen gefunden",
|
|
||||||
"person_mention_btn_label": "Person verlinken",
|
|
||||||
"person_mention_create_new": "Neue Person anlegen",
|
|
||||||
"transcription_editor_aria_label": "Transkriptionstext",
|
|
||||||
"person_born_name_prefix": "geb.",
|
|
||||||
"page_title_home": "Archiv",
|
"page_title_home": "Archiv",
|
||||||
"page_title_persons": "Personen",
|
"page_title_persons": "Personen",
|
||||||
"page_title_admin": "Administration",
|
"page_title_admin": "Administration",
|
||||||
@@ -503,7 +493,7 @@
|
|||||||
"doc_details_more_receivers": "+{count} weitere",
|
"doc_details_more_receivers": "+{count} weitere",
|
||||||
"transcription_mode_label": "Transkribieren",
|
"transcription_mode_label": "Transkribieren",
|
||||||
"transcription_mode_stop": "Fertig",
|
"transcription_mode_stop": "Fertig",
|
||||||
"transcription_block_placeholder": "Text eingeben — mit @Name eine Person aus dem Archiv verknüpfen",
|
"transcription_block_placeholder": "Text hier eingeben...",
|
||||||
"transcription_block_save_saving": "Speichere...",
|
"transcription_block_save_saving": "Speichere...",
|
||||||
"transcription_block_save_saved": "Gespeichert",
|
"transcription_block_save_saved": "Gespeichert",
|
||||||
"transcription_block_save_error": "Nicht gespeichert",
|
"transcription_block_save_error": "Nicht gespeichert",
|
||||||
@@ -917,133 +907,5 @@
|
|||||||
"bulk_edit_loading": "Dokumente werden geladen…",
|
"bulk_edit_loading": "Dokumente werden geladen…",
|
||||||
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
|
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
|
||||||
"bulk_edit_topbar_title": "Massenbearbeitung",
|
"bulk_edit_topbar_title": "Massenbearbeitung",
|
||||||
"bulk_edit_count_pill": "{count} werden bearbeitet",
|
"bulk_edit_count_pill": "{count} werden bearbeitet"
|
||||||
|
|
||||||
"nav_stammbaum": "Stammbaum",
|
|
||||||
"nav_geschichten": "Geschichten",
|
|
||||||
|
|
||||||
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
|
|
||||||
|
|
||||||
"geschichten_index_title": "Geschichten",
|
|
||||||
"geschichten_new_button": "Neue Geschichte",
|
|
||||||
"geschichten_filter_all_pill": "Alle",
|
|
||||||
"geschichten_filter_choose_person": "Person wählen",
|
|
||||||
"geschichten_filter_aria_label": "Person filtern",
|
|
||||||
"geschichten_filter_remove_chip": "{name} aus Filter entfernen",
|
|
||||||
"geschichten_filter_and_hint": "Es werden nur Geschichten gezeigt, in denen alle ausgewählten Personen vorkommen.",
|
|
||||||
"geschichten_empty_for_person": "Keine Geschichten für {name} gefunden.",
|
|
||||||
"geschichten_empty_for_persons": "Keine Geschichten für {names} gefunden.",
|
|
||||||
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
|
|
||||||
"geschichten_back_to_index": "Zurück zu Geschichten",
|
|
||||||
"geschichten_published_on": "veröffentlicht am {date}",
|
|
||||||
"geschichten_persons_section": "Personen in dieser Geschichte",
|
|
||||||
"geschichten_documents_section": "Erwähnte Dokumente",
|
|
||||||
"geschichten_card_heading": "Geschichten",
|
|
||||||
"geschichten_card_write_action": "+ Geschichte schreiben",
|
|
||||||
"geschichten_card_attach_action": "+ Geschichte anhängen",
|
|
||||||
"geschichten_card_show_all_for_person": "Alle Geschichten zu {name}",
|
|
||||||
"geschichten_card_show_all": "Alle anzeigen",
|
|
||||||
|
|
||||||
"geschichte_editor_title_placeholder": "Titel der Geschichte",
|
|
||||||
"geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…",
|
|
||||||
"geschichte_editor_status_draft": "ENTWURF",
|
|
||||||
"geschichte_editor_status_published": "VERÖFFENTLICHT",
|
|
||||||
"geschichte_editor_status_draft_hint": "Noch nicht öffentlich sichtbar.",
|
|
||||||
"geschichte_editor_status_published_hint": "Öffentlich sichtbar für alle Leser.",
|
|
||||||
"geschichte_editor_save_hint_draft": "Alle Änderungen werden als Entwurf gespeichert.",
|
|
||||||
"geschichte_editor_save_hint_published": "Änderungen sind sofort live.",
|
|
||||||
"geschichte_editor_save_draft": "Entwurf speichern",
|
|
||||||
"geschichte_editor_publish": "Veröffentlichen",
|
|
||||||
"geschichte_editor_save": "Speichern",
|
|
||||||
"geschichte_editor_unpublish": "Zurück zu Entwurf",
|
|
||||||
"geschichte_editor_title_required": "Bitte gib einen Titel ein.",
|
|
||||||
"geschichte_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?",
|
|
||||||
"geschichte_editor_personen_heading": "Personen",
|
|
||||||
"geschichte_editor_personen_hint": "Welche historischen Personen kommen in dieser Geschichte vor?",
|
|
||||||
"geschichte_editor_dokumente_heading": "Dokumente",
|
|
||||||
"geschichte_editor_dokumente_hint": "Welche Briefe oder Dokumente sind Teil dieser Geschichte?",
|
|
||||||
"geschichte_editor_search_person": "Person suchen…",
|
|
||||||
"geschichte_editor_search_document": "Dokument suchen…",
|
|
||||||
"geschichte_editor_toolbar_bold": "Fett (Strg+B)",
|
|
||||||
"geschichte_editor_toolbar_italic": "Kursiv (Strg+I)",
|
|
||||||
"geschichte_editor_toolbar_h2": "Überschrift",
|
|
||||||
"geschichte_editor_toolbar_h3": "Unterüberschrift",
|
|
||||||
"geschichte_editor_toolbar_ul": "Aufzählung",
|
|
||||||
"geschichte_editor_toolbar_ol": "Nummerierte Liste",
|
|
||||||
|
|
||||||
"geschichte_delete_confirm_title": "Geschichte löschen?",
|
|
||||||
"geschichte_delete_confirm_body": "Diese Aktion kann nicht rückgängig gemacht werden. Die Geschichte wird dauerhaft gelöscht und aus allen verlinkten Personen- und Dokumentseiten entfernt.",
|
|
||||||
|
|
||||||
"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."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,7 +228,6 @@
|
|||||||
"admin_perm_read_all": "Read only",
|
"admin_perm_read_all": "Read only",
|
||||||
"admin_perm_annotate_all": "Read & Annotate",
|
"admin_perm_annotate_all": "Read & Annotate",
|
||||||
"admin_perm_write_all": "Read & Write",
|
"admin_perm_write_all": "Read & Write",
|
||||||
"admin_perm_blog_write": "Write stories",
|
|
||||||
"admin_perm_admin": "Full access (Admin)",
|
"admin_perm_admin": "Full access (Admin)",
|
||||||
"admin_perm_admin_user": "Manage users",
|
"admin_perm_admin_user": "Manage users",
|
||||||
"admin_perm_admin_tag": "Manage tags",
|
"admin_perm_admin_tag": "Manage tags",
|
||||||
@@ -421,15 +420,6 @@
|
|||||||
"notification_unread": "unread",
|
"notification_unread": "unread",
|
||||||
"mention_btn_label": "Mention person",
|
"mention_btn_label": "Mention person",
|
||||||
"mention_popup_empty": "No users found",
|
"mention_popup_empty": "No users found",
|
||||||
"person_mention_open_link": "Open person",
|
|
||||||
"person_mention_hover_hint": "Click opens the page",
|
|
||||||
"person_mention_load_error": "Could not load person.",
|
|
||||||
"person_mention_loading": "Loading person…",
|
|
||||||
"person_mention_popup_empty": "No persons found",
|
|
||||||
"person_mention_btn_label": "Link person",
|
|
||||||
"person_mention_create_new": "Create new person",
|
|
||||||
"transcription_editor_aria_label": "Transcription text",
|
|
||||||
"person_born_name_prefix": "née",
|
|
||||||
"page_title_home": "Archive",
|
"page_title_home": "Archive",
|
||||||
"page_title_persons": "Persons",
|
"page_title_persons": "Persons",
|
||||||
"page_title_admin": "Administration",
|
"page_title_admin": "Administration",
|
||||||
@@ -503,7 +493,7 @@
|
|||||||
"doc_details_more_receivers": "+{count} more",
|
"doc_details_more_receivers": "+{count} more",
|
||||||
"transcription_mode_label": "Transcribe",
|
"transcription_mode_label": "Transcribe",
|
||||||
"transcription_mode_stop": "Done",
|
"transcription_mode_stop": "Done",
|
||||||
"transcription_block_placeholder": "Type text — use @name to link a person from the archive",
|
"transcription_block_placeholder": "Type text here...",
|
||||||
"transcription_block_save_saving": "Saving...",
|
"transcription_block_save_saving": "Saving...",
|
||||||
"transcription_block_save_saved": "Saved",
|
"transcription_block_save_saved": "Saved",
|
||||||
"transcription_block_save_error": "Not saved",
|
"transcription_block_save_error": "Not saved",
|
||||||
@@ -917,133 +907,5 @@
|
|||||||
"bulk_edit_loading": "Loading documents…",
|
"bulk_edit_loading": "Loading documents…",
|
||||||
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
|
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
|
||||||
"bulk_edit_topbar_title": "Bulk edit",
|
"bulk_edit_topbar_title": "Bulk edit",
|
||||||
"bulk_edit_count_pill": "{count} will be edited",
|
"bulk_edit_count_pill": "{count} will be edited"
|
||||||
|
|
||||||
"nav_stammbaum": "Family tree",
|
|
||||||
"nav_geschichten": "Stories",
|
|
||||||
|
|
||||||
"error_geschichte_not_found": "The story was not found.",
|
|
||||||
|
|
||||||
"geschichten_index_title": "Stories",
|
|
||||||
"geschichten_new_button": "New story",
|
|
||||||
"geschichten_filter_all_pill": "All",
|
|
||||||
"geschichten_filter_choose_person": "Choose person",
|
|
||||||
"geschichten_filter_aria_label": "Filter by person",
|
|
||||||
"geschichten_filter_remove_chip": "Remove {name} from filter",
|
|
||||||
"geschichten_filter_and_hint": "Only stories that include every selected person are shown.",
|
|
||||||
"geschichten_empty_for_person": "No stories found for {name}.",
|
|
||||||
"geschichten_empty_for_persons": "No stories found for {names}.",
|
|
||||||
"geschichten_empty_no_filter": "There are no published stories yet.",
|
|
||||||
"geschichten_back_to_index": "Back to stories",
|
|
||||||
"geschichten_published_on": "published on {date}",
|
|
||||||
"geschichten_persons_section": "People in this story",
|
|
||||||
"geschichten_documents_section": "Referenced documents",
|
|
||||||
"geschichten_card_heading": "Stories",
|
|
||||||
"geschichten_card_write_action": "+ Write a story",
|
|
||||||
"geschichten_card_attach_action": "+ Attach a story",
|
|
||||||
"geschichten_card_show_all_for_person": "All stories about {name}",
|
|
||||||
"geschichten_card_show_all": "Show all",
|
|
||||||
|
|
||||||
"geschichte_editor_title_placeholder": "Story title",
|
|
||||||
"geschichte_editor_body_placeholder": "Write your story here…",
|
|
||||||
"geschichte_editor_status_draft": "DRAFT",
|
|
||||||
"geschichte_editor_status_published": "PUBLISHED",
|
|
||||||
"geschichte_editor_status_draft_hint": "Not yet visible to readers.",
|
|
||||||
"geschichte_editor_status_published_hint": "Visible to all readers.",
|
|
||||||
"geschichte_editor_save_hint_draft": "All changes are saved as a draft.",
|
|
||||||
"geschichte_editor_save_hint_published": "Changes go live immediately.",
|
|
||||||
"geschichte_editor_save_draft": "Save draft",
|
|
||||||
"geschichte_editor_publish": "Publish",
|
|
||||||
"geschichte_editor_save": "Save",
|
|
||||||
"geschichte_editor_unpublish": "Back to draft",
|
|
||||||
"geschichte_editor_title_required": "Please enter a title.",
|
|
||||||
"geschichte_editor_unsaved_changes": "You have unsaved changes — leave anyway?",
|
|
||||||
"geschichte_editor_personen_heading": "People",
|
|
||||||
"geschichte_editor_personen_hint": "Which historical persons appear in this story?",
|
|
||||||
"geschichte_editor_dokumente_heading": "Documents",
|
|
||||||
"geschichte_editor_dokumente_hint": "Which letters or documents are part of this story?",
|
|
||||||
"geschichte_editor_search_person": "Search person…",
|
|
||||||
"geschichte_editor_search_document": "Search document…",
|
|
||||||
"geschichte_editor_toolbar_bold": "Bold (Ctrl+B)",
|
|
||||||
"geschichte_editor_toolbar_italic": "Italic (Ctrl+I)",
|
|
||||||
"geschichte_editor_toolbar_h2": "Heading",
|
|
||||||
"geschichte_editor_toolbar_h3": "Subheading",
|
|
||||||
"geschichte_editor_toolbar_ul": "Bulleted list",
|
|
||||||
"geschichte_editor_toolbar_ol": "Numbered list",
|
|
||||||
|
|
||||||
"geschichte_delete_confirm_title": "Delete story?",
|
|
||||||
"geschichte_delete_confirm_body": "This action cannot be undone. The story will be permanently deleted and removed from all linked person and document pages.",
|
|
||||||
|
|
||||||
"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."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,7 +228,6 @@
|
|||||||
"admin_perm_read_all": "Solo lectura",
|
"admin_perm_read_all": "Solo lectura",
|
||||||
"admin_perm_annotate_all": "Leer y anotar",
|
"admin_perm_annotate_all": "Leer y anotar",
|
||||||
"admin_perm_write_all": "Leer y escribir",
|
"admin_perm_write_all": "Leer y escribir",
|
||||||
"admin_perm_blog_write": "Escribir historias",
|
|
||||||
"admin_perm_admin": "Acceso completo (Admin)",
|
"admin_perm_admin": "Acceso completo (Admin)",
|
||||||
"admin_perm_admin_user": "Gestionar usuarios",
|
"admin_perm_admin_user": "Gestionar usuarios",
|
||||||
"admin_perm_admin_tag": "Gestionar etiquetas",
|
"admin_perm_admin_tag": "Gestionar etiquetas",
|
||||||
@@ -421,15 +420,6 @@
|
|||||||
"notification_unread": "no leído",
|
"notification_unread": "no leído",
|
||||||
"mention_btn_label": "Mencionar persona",
|
"mention_btn_label": "Mencionar persona",
|
||||||
"mention_popup_empty": "No se encontraron usuarios",
|
"mention_popup_empty": "No se encontraron usuarios",
|
||||||
"person_mention_open_link": "Ir a la persona",
|
|
||||||
"person_mention_hover_hint": "Clic abre la página",
|
|
||||||
"person_mention_load_error": "No se pudo cargar la persona.",
|
|
||||||
"person_mention_loading": "Cargando persona…",
|
|
||||||
"person_mention_popup_empty": "No se encontraron personas",
|
|
||||||
"person_mention_btn_label": "Vincular persona",
|
|
||||||
"person_mention_create_new": "Crear nueva persona",
|
|
||||||
"transcription_editor_aria_label": "Texto de transcripción",
|
|
||||||
"person_born_name_prefix": "n.",
|
|
||||||
"page_title_home": "Archivo",
|
"page_title_home": "Archivo",
|
||||||
"page_title_persons": "Personas",
|
"page_title_persons": "Personas",
|
||||||
"page_title_admin": "Administración",
|
"page_title_admin": "Administración",
|
||||||
@@ -503,7 +493,7 @@
|
|||||||
"doc_details_more_receivers": "+{count} más",
|
"doc_details_more_receivers": "+{count} más",
|
||||||
"transcription_mode_label": "Transcribir",
|
"transcription_mode_label": "Transcribir",
|
||||||
"transcription_mode_stop": "Listo",
|
"transcription_mode_stop": "Listo",
|
||||||
"transcription_block_placeholder": "Escriba el texto — use @nombre para vincular a una persona del archivo",
|
"transcription_block_placeholder": "Escriba el texto aquí...",
|
||||||
"transcription_block_save_saving": "Guardando...",
|
"transcription_block_save_saving": "Guardando...",
|
||||||
"transcription_block_save_saved": "Guardado",
|
"transcription_block_save_saved": "Guardado",
|
||||||
"transcription_block_save_error": "No guardado",
|
"transcription_block_save_error": "No guardado",
|
||||||
@@ -917,133 +907,5 @@
|
|||||||
"bulk_edit_loading": "Cargando documentos…",
|
"bulk_edit_loading": "Cargando documentos…",
|
||||||
"bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
|
"bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
|
||||||
"bulk_edit_topbar_title": "Edición masiva",
|
"bulk_edit_topbar_title": "Edición masiva",
|
||||||
"bulk_edit_count_pill": "Se editarán {count}",
|
"bulk_edit_count_pill": "Se editarán {count}"
|
||||||
|
|
||||||
"nav_stammbaum": "Árbol genealógico",
|
|
||||||
"nav_geschichten": "Historias",
|
|
||||||
|
|
||||||
"error_geschichte_not_found": "No se encontró la historia.",
|
|
||||||
|
|
||||||
"geschichten_index_title": "Historias",
|
|
||||||
"geschichten_new_button": "Nueva historia",
|
|
||||||
"geschichten_filter_all_pill": "Todas",
|
|
||||||
"geschichten_filter_choose_person": "Elegir persona",
|
|
||||||
"geschichten_filter_aria_label": "Filtrar por persona",
|
|
||||||
"geschichten_filter_remove_chip": "Quitar {name} del filtro",
|
|
||||||
"geschichten_filter_and_hint": "Solo se muestran las historias que incluyen a todas las personas seleccionadas.",
|
|
||||||
"geschichten_empty_for_person": "No hay historias para {name}.",
|
|
||||||
"geschichten_empty_for_persons": "No hay historias para {names}.",
|
|
||||||
"geschichten_empty_no_filter": "Aún no hay historias publicadas.",
|
|
||||||
"geschichten_back_to_index": "Volver a Historias",
|
|
||||||
"geschichten_published_on": "publicada el {date}",
|
|
||||||
"geschichten_persons_section": "Personas en esta historia",
|
|
||||||
"geschichten_documents_section": "Documentos mencionados",
|
|
||||||
"geschichten_card_heading": "Historias",
|
|
||||||
"geschichten_card_write_action": "+ Escribir historia",
|
|
||||||
"geschichten_card_attach_action": "+ Adjuntar historia",
|
|
||||||
"geschichten_card_show_all_for_person": "Todas las historias sobre {name}",
|
|
||||||
"geschichten_card_show_all": "Mostrar todas",
|
|
||||||
|
|
||||||
"geschichte_editor_title_placeholder": "Título de la historia",
|
|
||||||
"geschichte_editor_body_placeholder": "Escribe tu historia aquí…",
|
|
||||||
"geschichte_editor_status_draft": "BORRADOR",
|
|
||||||
"geschichte_editor_status_published": "PUBLICADA",
|
|
||||||
"geschichte_editor_status_draft_hint": "Aún no visible para lectores.",
|
|
||||||
"geschichte_editor_status_published_hint": "Visible para todos los lectores.",
|
|
||||||
"geschichte_editor_save_hint_draft": "Los cambios se guardan como borrador.",
|
|
||||||
"geschichte_editor_save_hint_published": "Los cambios se publican inmediatamente.",
|
|
||||||
"geschichte_editor_save_draft": "Guardar borrador",
|
|
||||||
"geschichte_editor_publish": "Publicar",
|
|
||||||
"geschichte_editor_save": "Guardar",
|
|
||||||
"geschichte_editor_unpublish": "Volver a borrador",
|
|
||||||
"geschichte_editor_title_required": "Por favor ingresa un título.",
|
|
||||||
"geschichte_editor_unsaved_changes": "Tienes cambios no guardados — ¿salir igualmente?",
|
|
||||||
"geschichte_editor_personen_heading": "Personas",
|
|
||||||
"geschichte_editor_personen_hint": "¿Qué personas históricas aparecen en esta historia?",
|
|
||||||
"geschichte_editor_dokumente_heading": "Documentos",
|
|
||||||
"geschichte_editor_dokumente_hint": "¿Qué cartas o documentos forman parte de esta historia?",
|
|
||||||
"geschichte_editor_search_person": "Buscar persona…",
|
|
||||||
"geschichte_editor_search_document": "Buscar documento…",
|
|
||||||
"geschichte_editor_toolbar_bold": "Negrita (Ctrl+B)",
|
|
||||||
"geschichte_editor_toolbar_italic": "Cursiva (Ctrl+I)",
|
|
||||||
"geschichte_editor_toolbar_h2": "Encabezado",
|
|
||||||
"geschichte_editor_toolbar_h3": "Subencabezado",
|
|
||||||
"geschichte_editor_toolbar_ul": "Lista con viñetas",
|
|
||||||
"geschichte_editor_toolbar_ol": "Lista numerada",
|
|
||||||
|
|
||||||
"geschichte_delete_confirm_title": "¿Eliminar historia?",
|
|
||||||
"geschichte_delete_confirm_body": "Esta acción no se puede deshacer. La historia se eliminará permanentemente y se quitará de todas las páginas de personas y documentos vinculados.",
|
|
||||||
|
|
||||||
"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."
|
|
||||||
}
|
}
|
||||||
|
|||||||
1057
frontend/package-lock.json
generated
1057
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,11 +21,7 @@
|
|||||||
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
|
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/core": "3.22.5",
|
|
||||||
"@tiptap/extension-mention": "3.22.5",
|
|
||||||
"@tiptap/starter-kit": "3.22.5",
|
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"isomorphic-dompurify": "^3.12.0",
|
|
||||||
"openapi-fetch": "^0.13.5",
|
"openapi-fetch": "^0.13.5",
|
||||||
"pdfjs-dist": "^5.5.207"
|
"pdfjs-dist": "^5.5.207"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,16 +3,9 @@ import { m } from '$lib/paraglide/messages.js';
|
|||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
||||||
import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
|
import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
|
||||||
import RelationshipPill from '$lib/components/RelationshipPill.svelte';
|
|
||||||
|
|
||||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||||
type Tag = { id: string; name: string };
|
type Tag = { id: string; name: string };
|
||||||
type GeschichteSummary = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
publishedAt?: string;
|
|
||||||
author?: { firstName?: string; lastName?: string; email: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentDate: string | null;
|
documentDate: string | null;
|
||||||
@@ -21,43 +14,11 @@ type Props = {
|
|||||||
sender: Person | null;
|
sender: Person | null;
|
||||||
receivers: Person[];
|
receivers: Person[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
|
||||||
geschichten?: GeschichteSummary[];
|
|
||||||
documentId?: string;
|
|
||||||
canBlogWrite?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let { documentDate, location, status, sender, receivers, tags }: Props = $props();
|
||||||
documentDate,
|
|
||||||
location,
|
|
||||||
status,
|
|
||||||
sender,
|
|
||||||
receivers,
|
|
||||||
tags,
|
|
||||||
inferredRelationship = null,
|
|
||||||
geschichten = [],
|
|
||||||
documentId,
|
|
||||||
canBlogWrite = false
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const VISIBLE_RECEIVER_LIMIT = 5;
|
const VISIBLE_RECEIVER_LIMIT = 5;
|
||||||
const VISIBLE_GESCHICHTEN_LIMIT = 3;
|
|
||||||
const showGeschichtenColumn = $derived(geschichten.length > 0 || canBlogWrite);
|
|
||||||
const visibleGeschichten = $derived(geschichten.slice(0, VISIBLE_GESCHICHTEN_LIMIT));
|
|
||||||
const hasGeschichtenOverflow = $derived(geschichten.length >= VISIBLE_GESCHICHTEN_LIMIT);
|
|
||||||
const gridClass = $derived(showGeschichtenColumn ? 'lg:grid-cols-4' : 'lg:grid-cols-3');
|
|
||||||
|
|
||||||
function formatGeschichteAuthor(g: GeschichteSummary): string {
|
|
||||||
const a = g.author;
|
|
||||||
if (!a) return '';
|
|
||||||
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
|
||||||
return full || a.email || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatGeschichteDate(g: GeschichteSummary): string {
|
|
||||||
if (!g.publishedAt) return '';
|
|
||||||
return formatDate(g.publishedAt.slice(0, 10), 'short');
|
|
||||||
}
|
|
||||||
|
|
||||||
const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—');
|
const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—');
|
||||||
const displayLocation = $derived(location ?? '—');
|
const displayLocation = $derived(location ?? '—');
|
||||||
@@ -76,7 +37,7 @@ function getFullName(person: Person): string {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet personCard(person: Person, relationLabel: string | null = null)}
|
{#snippet personCard(person: Person)}
|
||||||
<a
|
<a
|
||||||
href="/persons/{person.id}"
|
href="/persons/{person.id}"
|
||||||
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
|
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
|
||||||
@@ -88,15 +49,12 @@ function getFullName(person: Person): string {
|
|||||||
>
|
>
|
||||||
{getInitials(person.displayName)}
|
{getInitials(person.displayName)}
|
||||||
</span>
|
</span>
|
||||||
<span class="min-w-0 truncate font-serif text-sm text-ink">{getFullName(person)}</span>
|
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
|
||||||
{#if relationLabel}
|
|
||||||
<RelationshipPill label={relationLabel} />
|
|
||||||
{/if}
|
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div class="border-b border-line p-6">
|
<div class="border-b border-line p-6">
|
||||||
<div class="grid grid-cols-1 gap-6 {gridClass}">
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<!-- Column 1: Details -->
|
<!-- Column 1: Details -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
@@ -130,7 +88,7 @@ function getFullName(person: Person): string {
|
|||||||
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
|
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
|
||||||
{m.doc_details_field_sender()}
|
{m.doc_details_field_sender()}
|
||||||
</p>
|
</p>
|
||||||
{@render personCard(sender, inferredRelationship?.labelFromA ?? null)}
|
{@render personCard(sender)}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if receivers.length > 0}
|
{#if receivers.length > 0}
|
||||||
@@ -139,16 +97,8 @@ function getFullName(person: Person): string {
|
|||||||
{m.doc_details_field_receivers()}
|
{m.doc_details_field_receivers()}
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
{#each displayedReceivers as receiver, i (receiver.id)}
|
{#each displayedReceivers as receiver (receiver.id)}
|
||||||
{@render personCard(
|
{@render personCard(receiver)}
|
||||||
receiver,
|
|
||||||
// Badge only shown when there is exactly one receiver — with multiple
|
|
||||||
// receivers the inferred label is computed from the sender's viewpoint
|
|
||||||
// and cannot be attributed to a specific receiver.
|
|
||||||
i === 0 && receivers.length === 1
|
|
||||||
? (inferredRelationship?.labelFromB ?? null)
|
|
||||||
: null
|
|
||||||
)}
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if hiddenReceiverCount > 0 && !showAllReceivers}
|
{#if hiddenReceiverCount > 0 && !showAllReceivers}
|
||||||
@@ -188,51 +138,5 @@ function getFullName(person: Person): string {
|
|||||||
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_tags()}</p>
|
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_tags()}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Column 4: Geschichten (visible when stories exist or user can author) -->
|
|
||||||
{#if showGeschichtenColumn}
|
|
||||||
<div>
|
|
||||||
<div class="mb-4 flex items-center justify-between">
|
|
||||||
<h2 class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
|
||||||
{m.geschichten_card_heading()}
|
|
||||||
</h2>
|
|
||||||
{#if canBlogWrite && documentId}
|
|
||||||
<a
|
|
||||||
href="/geschichten/new?documentId={documentId}"
|
|
||||||
class="font-sans text-xs font-medium text-ink-2 hover:text-ink"
|
|
||||||
>
|
|
||||||
{m.geschichten_card_attach_action()}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if geschichten.length === 0}
|
|
||||||
<p class="font-serif text-sm text-ink-3">—</p>
|
|
||||||
{:else}
|
|
||||||
<ul class="space-y-2 font-serif text-sm">
|
|
||||||
{#each visibleGeschichten as g (g.id)}
|
|
||||||
<li>
|
|
||||||
<a href="/geschichten/{g.id}" class="block text-ink hover:underline">
|
|
||||||
{g.title}
|
|
||||||
</a>
|
|
||||||
<p class="font-sans text-xs text-ink-3">
|
|
||||||
{formatGeschichteAuthor(g)}
|
|
||||||
{#if formatGeschichteDate(g)}· {formatGeschichteDate(g)}{/if}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{#if hasGeschichtenOverflow && documentId}
|
|
||||||
<a
|
|
||||||
href="/geschichten?documentId={documentId}"
|
|
||||||
class="mt-3 inline-flex font-sans text-xs font-medium text-ink hover:underline"
|
|
||||||
>
|
|
||||||
{m.geschichten_card_show_all()} →
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,25 +81,6 @@ describe('DocumentMetadataDrawer — persons column', () => {
|
|||||||
renderDrawer({ sender: null, receivers: [] });
|
renderDrawer({ sender: null, receivers: [] });
|
||||||
await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument();
|
await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders inferred relationship pills inline next to sender and receiver', async () => {
|
|
||||||
renderDrawer({
|
|
||||||
receivers: [receivers[0]],
|
|
||||||
inferredRelationship: { labelFromA: 'Elternteil', labelFromB: 'Kind' }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sender link contains its pill, receiver link contains its pill.
|
|
||||||
const senderLink = page.getByRole('link', { name: /Karl Müller.*Elternteil/i });
|
|
||||||
await expect.element(senderLink).toBeInTheDocument();
|
|
||||||
const receiverLink = page.getByRole('link', { name: /Anna Schmidt.*Kind/i });
|
|
||||||
await expect.element(receiverLink).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits the pills when no inferred relationship is provided', async () => {
|
|
||||||
renderDrawer();
|
|
||||||
const elternteil = page.getByText('Elternteil');
|
|
||||||
expect(await elternteil.elements()).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Tags column ─────────────────────────────────────────────────────────────
|
// ─── Tags column ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { components } from '$lib/generated/api';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import { clickOutside } from '$lib/actions/clickOutside';
|
|
||||||
import { formatDate } from '$lib/utils/date';
|
|
||||||
|
|
||||||
type Document = components['schemas']['Document'];
|
|
||||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
selectedDocuments?: Document[];
|
|
||||||
placeholder?: string;
|
|
||||||
hiddenInputName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
selectedDocuments = $bindable([]),
|
|
||||||
placeholder = m.geschichte_editor_search_document(),
|
|
||||||
hiddenInputName = 'documentIds'
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let searchTerm = $state('');
|
|
||||||
let results: Document[] = $state([]);
|
|
||||||
let showDropdown = $state(false);
|
|
||||||
let loading = $state(false);
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
|
||||||
let inputEl: HTMLInputElement;
|
|
||||||
let dropdownStyle = $state('');
|
|
||||||
|
|
||||||
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() {
|
|
||||||
showDropdown = true;
|
|
||||||
clearTimeout(debounceTimer);
|
|
||||||
debounceTimer = setTimeout(async () => {
|
|
||||||
if (searchTerm.length < 1) {
|
|
||||||
results = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loading = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
|
|
||||||
if (res.ok) {
|
|
||||||
const body: { items: DocumentSearchItem[] } = await res.json();
|
|
||||||
const docs = body.items.map((it) => it.document);
|
|
||||||
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
results = [];
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectDocument(doc: Document) {
|
|
||||||
selectedDocuments = [...selectedDocuments, doc];
|
|
||||||
searchTerm = '';
|
|
||||||
showDropdown = false;
|
|
||||||
results = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeDocument(id: string | undefined) {
|
|
||||||
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDocLabel(doc: Document): string {
|
|
||||||
if (doc.documentDate) return `${doc.title} · ${formatDate(doc.documentDate, 'short')}`;
|
|
||||||
return doc.title;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
|
||||||
|
|
||||||
{#each selectedDocuments as doc (doc.id)}
|
|
||||||
<input type="hidden" name={hiddenInputName} value={doc.id} />
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
|
||||||
<div
|
|
||||||
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
|
|
||||||
>
|
|
||||||
{#each selectedDocuments as doc (doc.id)}
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
|
|
||||||
>
|
|
||||||
{formatDocLabel(doc)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => removeDocument(doc.id)}
|
|
||||||
class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"
|
|
||||||
aria-label={m.comp_multiselect_remove()}
|
|
||||||
>
|
|
||||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<input
|
|
||||||
bind:this={inputEl}
|
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
bind:value={searchTerm}
|
|
||||||
oninput={handleInput}
|
|
||||||
onfocus={() => {
|
|
||||||
updateDropdownPosition();
|
|
||||||
showDropdown = true;
|
|
||||||
}}
|
|
||||||
placeholder={placeholder}
|
|
||||||
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showDropdown && (results.length > 0 || loading)}
|
|
||||||
<div
|
|
||||||
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 sm:text-sm"
|
|
||||||
>
|
|
||||||
{#if loading}
|
|
||||||
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
|
|
||||||
{:else}
|
|
||||||
{#each results as doc (doc.id)}
|
|
||||||
<div
|
|
||||||
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
|
|
||||||
onclick={() => selectDocument(doc)}
|
|
||||||
onkeydown={(e) => e.key === 'Enter' && selectDocument(doc)}
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
{formatDocLabel(doc)}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
|
||||||
import { page, userEvent } from 'vitest/browser';
|
|
||||||
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
|
|
||||||
|
|
||||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
|
||||||
|
|
||||||
const docFactory = (id: string, title: string, date = '1880-01-01') => ({
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
documentDate: date,
|
|
||||||
originalFilename: `${title}.pdf`,
|
|
||||||
status: 'UPLOADED',
|
|
||||||
metadataComplete: false,
|
|
||||||
scriptType: 'UNKNOWN' as const,
|
|
||||||
createdAt: '2024-01-01T00:00:00',
|
|
||||||
updatedAt: '2024-01-01T00:00:00'
|
|
||||||
});
|
|
||||||
|
|
||||||
function mockSearchResponse(items: ReturnType<typeof docFactory>[]) {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: vi.fn().mockResolvedValue({ items: items.map((document) => ({ document })) })
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DocumentMultiSelect — rendering', () => {
|
|
||||||
it('renders an empty chip-input by default', async () => {
|
|
||||||
render(DocumentMultiSelect);
|
|
||||||
await expect.element(page.getByPlaceholder('Dokument suchen…')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders pre-selected documents as chips with their date', async () => {
|
|
||||||
render(DocumentMultiSelect, {
|
|
||||||
selectedDocuments: [docFactory('d1', 'Brief vom 1. Mai', '1882-05-01')]
|
|
||||||
});
|
|
||||||
await expect.element(page.getByText(/Brief vom 1\. Mai/)).toBeInTheDocument();
|
|
||||||
await expect.element(page.getByText(/01\.05\.1882/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits a hidden documentIds input for each pre-selected document', async () => {
|
|
||||||
render(DocumentMultiSelect, {
|
|
||||||
selectedDocuments: [docFactory('d1', 'A'), docFactory('d2', 'B')]
|
|
||||||
});
|
|
||||||
const inputs = document.querySelectorAll<HTMLInputElement>(
|
|
||||||
'input[type="hidden"][name="documentIds"]'
|
|
||||||
);
|
|
||||||
expect(inputs).toHaveLength(2);
|
|
||||||
expect([inputs[0].value, inputs[1].value].sort()).toEqual(['d1', 'd2']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DocumentMultiSelect — search and select', () => {
|
|
||||||
it('queries /api/documents/search after debounce and shows results', async () => {
|
|
||||||
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
|
|
||||||
render(DocumentMultiSelect);
|
|
||||||
|
|
||||||
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
|
|
||||||
await waitForDebounce();
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringMatching(/^\/api\/documents\/search\?q=Eug/)
|
|
||||||
);
|
|
||||||
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('adds a chip when a search result is clicked', async () => {
|
|
||||||
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
|
|
||||||
render(DocumentMultiSelect);
|
|
||||||
|
|
||||||
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
|
|
||||||
await waitForDebounce();
|
|
||||||
await userEvent.click(page.getByText(/Brief von Eugenie/));
|
|
||||||
|
|
||||||
// After selection the search field clears and the chip is rendered
|
|
||||||
const hidden = document.querySelector<HTMLInputElement>(
|
|
||||||
'input[type="hidden"][name="documentIds"]'
|
|
||||||
);
|
|
||||||
expect(hidden?.value).toBe('d1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides already-selected documents from new search results', async () => {
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: vi.fn().mockResolvedValue({
|
|
||||||
items: [
|
|
||||||
{ document: docFactory('d1', 'Already attached') },
|
|
||||||
{ document: docFactory('d2', 'Not attached') }
|
|
||||||
]
|
|
||||||
})
|
|
||||||
});
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
render(DocumentMultiSelect, {
|
|
||||||
selectedDocuments: [docFactory('d1', 'Already attached')]
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'attached');
|
|
||||||
await waitForDebounce();
|
|
||||||
|
|
||||||
// "Not attached" appears in the dropdown; "Already attached" only as the chip.
|
|
||||||
const matches = await page.getByText(/Already attached/).all();
|
|
||||||
expect(matches.length).toBe(1); // chip only, not in dropdown
|
|
||||||
await expect.element(page.getByText(/Not attached/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DocumentMultiSelect — remove', () => {
|
|
||||||
it('removes a chip when its × button is clicked', async () => {
|
|
||||||
render(DocumentMultiSelect, {
|
|
||||||
selectedDocuments: [docFactory('d1', 'Brief A')]
|
|
||||||
});
|
|
||||||
await userEvent.click(page.getByLabelText('Entfernen'));
|
|
||||||
expect(
|
|
||||||
document.querySelector<HTMLInputElement>('input[type="hidden"][name="documentIds"]')
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -25,32 +25,14 @@ type Doc = {
|
|||||||
tags?: Tag[] | null;
|
tags?: Tag[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GeschichteSummary = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
publishedAt?: string;
|
|
||||||
author?: { firstName?: string; lastName?: string; email: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
doc: Doc;
|
doc: Doc;
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
transcribeMode: boolean;
|
transcribeMode: boolean;
|
||||||
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
|
||||||
geschichten?: GeschichteSummary[];
|
|
||||||
canBlogWrite?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let { doc, canWrite, fileUrl, transcribeMode = $bindable() }: Props = $props();
|
||||||
doc,
|
|
||||||
canWrite,
|
|
||||||
fileUrl,
|
|
||||||
transcribeMode = $bindable(),
|
|
||||||
inferredRelationship = null,
|
|
||||||
geschichten = [],
|
|
||||||
canBlogWrite = false
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let detailsOpen = $state(false);
|
let detailsOpen = $state(false);
|
||||||
|
|
||||||
@@ -293,10 +275,6 @@ let mobileMenuOpen = $state(false);
|
|||||||
sender={doc.sender ?? null}
|
sender={doc.sender ?? null}
|
||||||
receivers={doc.receivers ? [...doc.receivers] : []}
|
receivers={doc.receivers ? [...doc.receivers] : []}
|
||||||
tags={doc.tags ? [...doc.tags] : []}
|
tags={doc.tags ? [...doc.tags] : []}
|
||||||
inferredRelationship={inferredRelationship}
|
|
||||||
geschichten={geschichten}
|
|
||||||
documentId={doc.id}
|
|
||||||
canBlogWrite={canBlogWrite}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,329 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
import { beforeNavigate } from '$app/navigation';
|
|
||||||
import { Editor } from '@tiptap/core';
|
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import type { components } from '$lib/generated/api';
|
|
||||||
import PersonMultiSelect from './PersonMultiSelect.svelte';
|
|
||||||
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
|
|
||||||
|
|
||||||
type Geschichte = components['schemas']['Geschichte'];
|
|
||||||
type Person = components['schemas']['Person'];
|
|
||||||
type Document = components['schemas']['Document'];
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
geschichte?: Geschichte | null;
|
|
||||||
initialPersons?: Person[];
|
|
||||||
initialDocuments?: Document[];
|
|
||||||
onSubmit: (payload: {
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
status: 'DRAFT' | 'PUBLISHED';
|
|
||||||
personIds: string[];
|
|
||||||
documentIds: string[];
|
|
||||||
}) => Promise<void>;
|
|
||||||
submitting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
geschichte = null,
|
|
||||||
initialPersons = [],
|
|
||||||
initialDocuments = [],
|
|
||||||
onSubmit,
|
|
||||||
submitting = false
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// Initial-state snapshot from incoming props. The editor owns these values
|
|
||||||
// after mount; the parent should re-mount the component with a different
|
|
||||||
// `geschichte` to reset (consistent with how form components in this codebase
|
|
||||||
// behave — see DocumentEdit page).
|
|
||||||
let title = $state(geschichte?.title ?? '');
|
|
||||||
let body = $state(geschichte?.body ?? '');
|
|
||||||
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
|
|
||||||
let selectedPersons: Person[] = $state(
|
|
||||||
geschichte?.persons ? Array.from(geschichte.persons) : initialPersons
|
|
||||||
);
|
|
||||||
let selectedDocuments: Document[] = $state(
|
|
||||||
geschichte?.documents ? Array.from(geschichte.documents) : initialDocuments
|
|
||||||
);
|
|
||||||
|
|
||||||
let dirty = $state(false);
|
|
||||||
let titleTouched = $state(false);
|
|
||||||
|
|
||||||
const titleEmpty = $derived(title.trim().length === 0);
|
|
||||||
const isDraft = $derived(status === 'DRAFT');
|
|
||||||
const showTitleError = $derived(titleEmpty && titleTouched);
|
|
||||||
|
|
||||||
let editorEl: HTMLDivElement;
|
|
||||||
let editor: Editor | null = null;
|
|
||||||
let toolbarVersion = $state(0);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
editor = new Editor({
|
|
||||||
element: editorEl,
|
|
||||||
content: body,
|
|
||||||
extensions: [
|
|
||||||
StarterKit.configure({
|
|
||||||
heading: { levels: [2, 3] },
|
|
||||||
code: false,
|
|
||||||
codeBlock: false,
|
|
||||||
blockquote: false,
|
|
||||||
strike: false,
|
|
||||||
horizontalRule: false,
|
|
||||||
hardBreak: false
|
|
||||||
})
|
|
||||||
],
|
|
||||||
editorProps: {
|
|
||||||
attributes: {
|
|
||||||
role: 'textbox',
|
|
||||||
'aria-multiline': 'true',
|
|
||||||
'aria-label': m.geschichte_editor_body_placeholder(),
|
|
||||||
class:
|
|
||||||
'prose max-w-none focus:outline-none min-h-[260px] font-serif text-base leading-relaxed'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onUpdate({ editor: ed }) {
|
|
||||||
body = ed.getHTML();
|
|
||||||
dirty = true;
|
|
||||||
},
|
|
||||||
onSelectionUpdate() {
|
|
||||||
toolbarVersion++;
|
|
||||||
},
|
|
||||||
onTransaction() {
|
|
||||||
toolbarVersion++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
editor?.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeNavigate(({ cancel }) => {
|
|
||||||
if (dirty && !submitting) {
|
|
||||||
const ok = window.confirm(m.geschichte_editor_unsaved_changes());
|
|
||||||
if (!ok) cancel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleTitleBlur() {
|
|
||||||
titleTouched = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTitleInput() {
|
|
||||||
dirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
|
||||||
titleTouched = true;
|
|
||||||
if (titleEmpty) return;
|
|
||||||
await onSubmit({
|
|
||||||
title: title.trim(),
|
|
||||||
body,
|
|
||||||
status: nextStatus,
|
|
||||||
personIds: selectedPersons.map((p) => p.id!).filter(Boolean),
|
|
||||||
documentIds: selectedDocuments.map((d) => d.id!).filter(Boolean)
|
|
||||||
});
|
|
||||||
dirty = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isActive(name: string, attrs?: Record<string, unknown>): boolean {
|
|
||||||
void toolbarVersion;
|
|
||||||
return editor?.isActive(name, attrs) ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function exec(action: () => void) {
|
|
||||||
if (!editor) return;
|
|
||||||
action();
|
|
||||||
editor.commands.focus();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
|
|
||||||
<!-- Editor column -->
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<!-- Title -->
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={title}
|
|
||||||
oninput={handleTitleInput}
|
|
||||||
onblur={handleTitleBlur}
|
|
||||||
placeholder={m.geschichte_editor_title_placeholder()}
|
|
||||||
aria-invalid={showTitleError}
|
|
||||||
aria-describedby={showTitleError ? 'title-error' : undefined}
|
|
||||||
class="block w-full rounded border border-line bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
/>
|
|
||||||
{#if showTitleError}
|
|
||||||
<p id="title-error" class="mt-1 text-sm text-danger" role="alert">
|
|
||||||
{m.geschichte_editor_title_required()}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Toolbar -->
|
|
||||||
<div
|
|
||||||
role="toolbar"
|
|
||||||
aria-label="Formatierung"
|
|
||||||
class="flex flex-wrap items-center gap-1 rounded border border-line bg-surface p-1"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => exec(() => editor!.chain().focus().toggleBold().run())}
|
|
||||||
aria-label={m.geschichte_editor_toolbar_bold()}
|
|
||||||
aria-pressed={isActive('bold')}
|
|
||||||
title={m.geschichte_editor_toolbar_bold()}
|
|
||||||
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
|
||||||
>
|
|
||||||
B
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => exec(() => editor!.chain().focus().toggleItalic().run())}
|
|
||||||
aria-label={m.geschichte_editor_toolbar_italic()}
|
|
||||||
aria-pressed={isActive('italic')}
|
|
||||||
title={m.geschichte_editor_toolbar_italic()}
|
|
||||||
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink italic hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
|
||||||
>
|
|
||||||
I
|
|
||||||
</button>
|
|
||||||
<span class="mx-1 h-6 w-px bg-line" aria-hidden="true"></span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => exec(() => editor!.chain().focus().toggleHeading({ level: 2 }).run())}
|
|
||||||
aria-label={m.geschichte_editor_toolbar_h2()}
|
|
||||||
aria-pressed={isActive('heading', { level: 2 })}
|
|
||||||
title={m.geschichte_editor_toolbar_h2()}
|
|
||||||
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-sm font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
|
||||||
>
|
|
||||||
H2
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => exec(() => editor!.chain().focus().toggleHeading({ level: 3 }).run())}
|
|
||||||
aria-label={m.geschichte_editor_toolbar_h3()}
|
|
||||||
aria-pressed={isActive('heading', { level: 3 })}
|
|
||||||
title={m.geschichte_editor_toolbar_h3()}
|
|
||||||
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-sm font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
|
||||||
>
|
|
||||||
H3
|
|
||||||
</button>
|
|
||||||
<span class="mx-1 h-6 w-px bg-line" aria-hidden="true"></span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => exec(() => editor!.chain().focus().toggleBulletList().run())}
|
|
||||||
aria-label={m.geschichte_editor_toolbar_ul()}
|
|
||||||
aria-pressed={isActive('bulletList')}
|
|
||||||
title={m.geschichte_editor_toolbar_ul()}
|
|
||||||
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
|
||||||
>
|
|
||||||
•
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => exec(() => editor!.chain().focus().toggleOrderedList().run())}
|
|
||||||
aria-label={m.geschichte_editor_toolbar_ol()}
|
|
||||||
aria-pressed={isActive('orderedList')}
|
|
||||||
title={m.geschichte_editor_toolbar_ol()}
|
|
||||||
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
|
||||||
>
|
|
||||||
1.
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Editor surface -->
|
|
||||||
<div
|
|
||||||
class="rounded border border-line bg-surface p-4 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring"
|
|
||||||
>
|
|
||||||
<div bind:this={editorEl} class="min-h-[260px]"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<aside class="flex flex-col gap-6">
|
|
||||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
|
||||||
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">Status</h2>
|
|
||||||
<p class="mb-3">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
|
|
||||||
? 'bg-muted text-ink-2'
|
|
||||||
: 'bg-accent-bg text-ink'}"
|
|
||||||
>
|
|
||||||
{isDraft
|
|
||||||
? m.geschichte_editor_status_draft()
|
|
||||||
: m.geschichte_editor_status_published()}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p class="font-sans text-xs text-ink-3">
|
|
||||||
{isDraft
|
|
||||||
? m.geschichte_editor_status_draft_hint()
|
|
||||||
: m.geschichte_editor_status_published_hint()}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
|
||||||
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
|
||||||
{m.geschichte_editor_personen_heading()}
|
|
||||||
</h2>
|
|
||||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
|
|
||||||
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
|
||||||
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
|
||||||
{m.geschichte_editor_dokumente_heading()}
|
|
||||||
</h2>
|
|
||||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_dokumente_hint()}</p>
|
|
||||||
<DocumentMultiSelect bind:selectedDocuments={selectedDocuments} />
|
|
||||||
</section>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Save bar -->
|
|
||||||
<div
|
|
||||||
class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
|
|
||||||
>
|
|
||||||
<p class="font-sans text-xs text-ink-3">
|
|
||||||
{isDraft
|
|
||||||
? m.geschichte_editor_save_hint_draft()
|
|
||||||
: m.geschichte_editor_save_hint_published()}
|
|
||||||
</p>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
{#if isDraft}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => save('DRAFT')}
|
|
||||||
disabled={submitting || titleEmpty}
|
|
||||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{m.geschichte_editor_save_draft()}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => save('PUBLISHED')}
|
|
||||||
disabled={submitting || titleEmpty}
|
|
||||||
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{m.geschichte_editor_publish()}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => save('DRAFT')}
|
|
||||||
disabled={submitting}
|
|
||||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-amber-700 hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{m.geschichte_editor_unpublish()}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => save('PUBLISHED')}
|
|
||||||
disabled={submitting || titleEmpty}
|
|
||||||
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{m.geschichte_editor_save()}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
|
||||||
import { page, userEvent } from 'vitest/browser';
|
|
||||||
import GeschichteEditor from './GeschichteEditor.svelte';
|
|
||||||
|
|
||||||
const personFactory = (id: string, displayName: string) => ({
|
|
||||||
id,
|
|
||||||
firstName: displayName.split(' ')[0],
|
|
||||||
lastName: displayName.split(' ').slice(1).join(' ') || displayName,
|
|
||||||
displayName,
|
|
||||||
personType: 'PERSON' as const
|
|
||||||
});
|
|
||||||
|
|
||||||
const docFactory = (id: string, title: string, date = '1882-01-01') => ({
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
documentDate: date,
|
|
||||||
originalFilename: `${title}.pdf`,
|
|
||||||
status: 'UPLOADED' as const,
|
|
||||||
metadataComplete: false,
|
|
||||||
scriptType: 'UNKNOWN' as const,
|
|
||||||
createdAt: '2024-01-01T00:00:00',
|
|
||||||
updatedAt: '2024-01-01T00:00:00'
|
|
||||||
});
|
|
||||||
|
|
||||||
const draftFactory = (overrides: Record<string, unknown> = {}) => ({
|
|
||||||
id: 'g1',
|
|
||||||
title: 'Existing draft',
|
|
||||||
body: '<p>Hello world</p>',
|
|
||||||
status: 'DRAFT' as const,
|
|
||||||
persons: [],
|
|
||||||
documents: [],
|
|
||||||
createdAt: '2024-01-01T00:00:00',
|
|
||||||
updatedAt: '2024-01-01T00:00:00',
|
|
||||||
...overrides
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => cleanup());
|
|
||||||
|
|
||||||
describe('GeschichteEditor — title-required guard', () => {
|
|
||||||
it('disables both DRAFT save buttons when the title is empty', async () => {
|
|
||||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
|
||||||
render(GeschichteEditor, { onSubmit });
|
|
||||||
|
|
||||||
const draft = await page.getByRole('button', { name: 'Entwurf speichern' }).element();
|
|
||||||
const publish = await page.getByRole('button', { name: 'Veröffentlichen' }).element();
|
|
||||||
expect(draft).toHaveProperty('disabled', true);
|
|
||||||
expect(publish).toHaveProperty('disabled', true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the inline error after the title field is blurred while empty', async () => {
|
|
||||||
const onSubmit = vi.fn();
|
|
||||||
render(GeschichteEditor, { onSubmit });
|
|
||||||
|
|
||||||
await userEvent.click(page.getByPlaceholder('Titel der Geschichte'));
|
|
||||||
await userEvent.tab(); // blur
|
|
||||||
await expect.element(page.getByText('Bitte gib einen Titel ein.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GeschichteEditor — save bar adapts to status', () => {
|
|
||||||
it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => {
|
|
||||||
render(GeschichteEditor, { onSubmit: vi.fn() });
|
|
||||||
await expect
|
|
||||||
.element(page.getByRole('button', { name: 'Entwurf speichern' }))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
await expect.element(page.getByRole('button', { name: 'Veröffentlichen' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders PUBLISHED mode buttons when geschichte.status is PUBLISHED', async () => {
|
|
||||||
render(GeschichteEditor, {
|
|
||||||
geschichte: draftFactory({ status: 'PUBLISHED', publishedAt: '2024-04-01T12:00:00' }),
|
|
||||||
onSubmit: vi.fn()
|
|
||||||
});
|
|
||||||
await expect.element(page.getByRole('button', { name: 'Speichern' })).toBeInTheDocument();
|
|
||||||
await expect
|
|
||||||
.element(page.getByRole('button', { name: 'Zurück zu Entwurf' }))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GeschichteEditor — pre-fill', () => {
|
|
||||||
it('renders initial persons as chips', async () => {
|
|
||||||
render(GeschichteEditor, {
|
|
||||||
initialPersons: [personFactory('p1', 'Franz Raddatz')],
|
|
||||||
onSubmit: vi.fn()
|
|
||||||
});
|
|
||||||
await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders initial documents as chips', async () => {
|
|
||||||
render(GeschichteEditor, {
|
|
||||||
initialDocuments: [docFactory('d1', 'Brief von Eugenie')],
|
|
||||||
onSubmit: vi.fn()
|
|
||||||
});
|
|
||||||
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('populates the title input from a geschichte prop', async () => {
|
|
||||||
render(GeschichteEditor, {
|
|
||||||
geschichte: draftFactory({ title: 'My existing story' }),
|
|
||||||
onSubmit: vi.fn()
|
|
||||||
});
|
|
||||||
const input = await page.getByPlaceholder('Titel der Geschichte').element();
|
|
||||||
expect((input as HTMLInputElement).value).toBe('My existing story');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GeschichteEditor — onSubmit payload', () => {
|
|
||||||
it('passes the trimmed title and DRAFT status when "Entwurf speichern" is clicked', async () => {
|
|
||||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
|
||||||
render(GeschichteEditor, { onSubmit });
|
|
||||||
|
|
||||||
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), ' My title ');
|
|
||||||
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
|
|
||||||
|
|
||||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
||||||
const payload = onSubmit.mock.calls[0][0];
|
|
||||||
expect(payload.title).toBe('My title');
|
|
||||||
expect(payload.status).toBe('DRAFT');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes status=PUBLISHED when "Veröffentlichen" is clicked', async () => {
|
|
||||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
|
||||||
render(GeschichteEditor, { onSubmit });
|
|
||||||
|
|
||||||
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
|
|
||||||
await userEvent.click(page.getByRole('button', { name: 'Veröffentlichen' }));
|
|
||||||
|
|
||||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes the personIds and documentIds from initial props through onSubmit', async () => {
|
|
||||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
|
||||||
render(GeschichteEditor, {
|
|
||||||
initialPersons: [personFactory('p1', 'Franz Raddatz')],
|
|
||||||
initialDocuments: [docFactory('d1', 'Brief A')],
|
|
||||||
onSubmit
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
|
|
||||||
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
|
|
||||||
|
|
||||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
||||||
const payload = onSubmit.mock.calls[0][0];
|
|
||||||
expect(payload.personIds).toEqual(['p1']);
|
|
||||||
expect(payload.documentIds).toEqual(['d1']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import type { components } from '$lib/generated/api';
|
|
||||||
import { plainExcerpt } from '$lib/utils/extractText';
|
|
||||||
import { formatDate } from '$lib/utils/date';
|
|
||||||
|
|
||||||
type Geschichte = components['schemas']['Geschichte'];
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
geschichten: Geschichte[];
|
|
||||||
personId: string;
|
|
||||||
personName: string;
|
|
||||||
canWrite: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { geschichten, personId, personName, canWrite }: Props = $props();
|
|
||||||
|
|
||||||
const visible = $derived(geschichten.slice(0, 3));
|
|
||||||
const hasOverflow = $derived(geschichten.length >= 3);
|
|
||||||
|
|
||||||
function formatPublishedDate(g: Geschichte): string | null {
|
|
||||||
if (!g.publishedAt) return null;
|
|
||||||
return formatDate(g.publishedAt.slice(0, 10), 'short');
|
|
||||||
}
|
|
||||||
|
|
||||||
function authorName(g: Geschichte): string {
|
|
||||||
const a = g.author;
|
|
||||||
if (!a) return '';
|
|
||||||
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
|
||||||
return full || a.email || '';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if geschichten.length > 0}
|
|
||||||
<section
|
|
||||||
aria-labelledby="geschichten-card-heading"
|
|
||||||
class="rounded-sm border border-line bg-surface p-6 shadow-sm"
|
|
||||||
>
|
|
||||||
<header class="mb-5 flex items-center justify-between">
|
|
||||||
<h2
|
|
||||||
id="geschichten-card-heading"
|
|
||||||
class="font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
|
||||||
>
|
|
||||||
{m.geschichten_card_heading()}
|
|
||||||
</h2>
|
|
||||||
{#if canWrite}
|
|
||||||
<a
|
|
||||||
href="/geschichten/new?personId={personId}"
|
|
||||||
class="inline-flex items-center font-sans text-sm font-medium text-ink-2 hover:text-ink"
|
|
||||||
>
|
|
||||||
{m.geschichten_card_write_action()}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<ul class="-mx-2">
|
|
||||||
{#each visible as g (g.id)}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/geschichten/{g.id}"
|
|
||||||
class="group flex flex-col gap-1 border-b border-line px-2 py-3 transition-colors last:border-b-0 hover:bg-muted"
|
|
||||||
>
|
|
||||||
<span class="font-serif text-base font-bold text-ink group-hover:underline">
|
|
||||||
{g.title}
|
|
||||||
</span>
|
|
||||||
<span class="font-sans text-xs text-ink-3">
|
|
||||||
{authorName(g)}
|
|
||||||
{#if formatPublishedDate(g)}· {formatPublishedDate(g)}{/if}
|
|
||||||
</span>
|
|
||||||
{#if g.body}
|
|
||||||
<span class="font-serif text-sm text-ink-2">{plainExcerpt(g.body, 80)}</span>
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{#if hasOverflow}
|
|
||||||
<footer class="mt-4 border-t border-line pt-3">
|
|
||||||
<a
|
|
||||||
href="/geschichten?personId={personId}"
|
|
||||||
class="inline-flex items-center font-sans text-sm font-medium text-ink hover:underline"
|
|
||||||
>
|
|
||||||
{m.geschichten_card_show_all_for_person({ name: personName })} →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it } from 'vitest';
|
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
|
||||||
import { page } from 'vitest/browser';
|
|
||||||
import GeschichtenCard from './GeschichtenCard.svelte';
|
|
||||||
|
|
||||||
const makeStory = (id: string, title: string, body: string | null = '<p>Body</p>') => ({
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
status: 'PUBLISHED' as const,
|
|
||||||
publishedAt: '2024-04-01T12:00:00',
|
|
||||||
createdAt: '2024-03-01T12:00:00',
|
|
||||||
updatedAt: '2024-04-01T12:00:00',
|
|
||||||
persons: [],
|
|
||||||
documents: [],
|
|
||||||
author: {
|
|
||||||
id: 'u1',
|
|
||||||
email: 'marcel@example.com',
|
|
||||||
firstName: 'Marcel',
|
|
||||||
lastName: 'Raddatz',
|
|
||||||
enabled: true,
|
|
||||||
notifyOnReply: false,
|
|
||||||
notifyOnMention: false,
|
|
||||||
groups: [],
|
|
||||||
createdAt: '2024-01-01T00:00:00',
|
|
||||||
color: '#000'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => cleanup());
|
|
||||||
|
|
||||||
describe('GeschichtenCard', () => {
|
|
||||||
it('renders nothing when geschichten is empty', async () => {
|
|
||||||
render(GeschichtenCard, {
|
|
||||||
geschichten: [],
|
|
||||||
personId: 'p1',
|
|
||||||
personName: 'Franz',
|
|
||||||
canWrite: true
|
|
||||||
});
|
|
||||||
// No heading, no list — the entire <section> should not exist
|
|
||||||
expect(
|
|
||||||
document.querySelector('section[aria-labelledby="geschichten-card-heading"]')
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the section heading and stories when geschichten is non-empty', async () => {
|
|
||||||
render(GeschichtenCard, {
|
|
||||||
geschichten: [makeStory('g1', 'Erinnerung an Franz')],
|
|
||||||
personId: 'p1',
|
|
||||||
personName: 'Franz',
|
|
||||||
canWrite: false
|
|
||||||
});
|
|
||||||
await expect.element(page.getByText('Geschichten')).toBeInTheDocument();
|
|
||||||
// The whole row is one link to the story; matching on the title text via
|
|
||||||
// a partial regex tolerates trailing author/date metadata in the
|
|
||||||
// accessible name.
|
|
||||||
const link = await page
|
|
||||||
.getByRole('link', { name: /Erinnerung an Franz/ })
|
|
||||||
.first()
|
|
||||||
.element();
|
|
||||||
expect(link.getAttribute('href')).toBe('/geschichten/g1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('makes the entire story row a single clickable link', async () => {
|
|
||||||
render(GeschichtenCard, {
|
|
||||||
geschichten: [makeStory('g1', 'A title', '<p>Some body excerpt text</p>')],
|
|
||||||
personId: 'p1',
|
|
||||||
personName: 'Franz',
|
|
||||||
canWrite: false
|
|
||||||
});
|
|
||||||
// The body-excerpt text is inside the same <a> as the title.
|
|
||||||
const links = await page.getByRole('link', { name: /A title/ }).all();
|
|
||||||
expect(links.length).toBeGreaterThan(0);
|
|
||||||
const linkEl = await links[0].element();
|
|
||||||
expect(linkEl.tagName).toBe('A');
|
|
||||||
expect(linkEl.textContent).toContain('Some body excerpt text');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides the "+ Geschichte schreiben" link when canWrite is false', async () => {
|
|
||||||
render(GeschichtenCard, {
|
|
||||||
geschichten: [makeStory('g1', 'A story')],
|
|
||||||
personId: 'p1',
|
|
||||||
personName: 'Franz',
|
|
||||||
canWrite: false
|
|
||||||
});
|
|
||||||
const writeLinks = await page.getByText(/Geschichte schreiben/).all();
|
|
||||||
expect(writeLinks).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the write-action link only when canWrite is true', async () => {
|
|
||||||
render(GeschichtenCard, {
|
|
||||||
geschichten: [makeStory('g1', 'A story')],
|
|
||||||
personId: 'p1',
|
|
||||||
personName: 'Franz',
|
|
||||||
canWrite: true
|
|
||||||
});
|
|
||||||
const link = await page.getByRole('link', { name: /Geschichte schreiben/ }).element();
|
|
||||||
expect(link.getAttribute('href')).toBe('/geschichten/new?personId=p1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides the "Alle Geschichten zu …" footer link below the 3-story threshold', async () => {
|
|
||||||
render(GeschichtenCard, {
|
|
||||||
geschichten: [makeStory('g1', 'A'), makeStory('g2', 'B')],
|
|
||||||
personId: 'p1',
|
|
||||||
personName: 'Franz',
|
|
||||||
canWrite: false
|
|
||||||
});
|
|
||||||
const overflow = await page.getByText(/Alle Geschichten zu/).all();
|
|
||||||
expect(overflow).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the footer link at the 3-story threshold (>= 3)', async () => {
|
|
||||||
render(GeschichtenCard, {
|
|
||||||
geschichten: [makeStory('g1', 'A'), makeStory('g2', 'B'), makeStory('g3', 'C')],
|
|
||||||
personId: 'p1',
|
|
||||||
personName: 'Franz',
|
|
||||||
canWrite: false
|
|
||||||
});
|
|
||||||
const link = await page.getByRole('link', { name: /Alle Geschichten zu Franz/ }).element();
|
|
||||||
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a plain-text excerpt without HTML markup', async () => {
|
|
||||||
render(GeschichtenCard, {
|
|
||||||
geschichten: [
|
|
||||||
makeStory(
|
|
||||||
'g1',
|
|
||||||
'Mit HTML',
|
|
||||||
'<p>Plain <strong>bold</strong> story</p><script>alert(1)</script>'
|
|
||||||
)
|
|
||||||
],
|
|
||||||
personId: 'p1',
|
|
||||||
personName: 'Franz',
|
|
||||||
canWrite: false
|
|
||||||
});
|
|
||||||
// Body excerpt appears once as plain text — no <strong> rendered, no script
|
|
||||||
await expect.element(page.getByText(/Plain bold story/)).toBeInTheDocument();
|
|
||||||
expect(document.body.innerHTML).not.toContain('<script>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { components } from '$lib/generated/api';
|
|
||||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
|
||||||
|
|
||||||
// The dropdown receives a single reactive state object. PersonMentionEditor
|
|
||||||
// mutates fields on this object (model.items = ..., etc.) and Svelte's $state
|
|
||||||
// proxy reactivity propagates the change here. This is the supported way to
|
|
||||||
// update an imperatively-mounted Svelte 5 component — `mount` does not return
|
|
||||||
// settable prop accessors.
|
|
||||||
type DropdownState = {
|
|
||||||
items: Person[];
|
|
||||||
command: (item: Person) => void;
|
|
||||||
clientRect: (() => DOMRect | null) | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { model }: { model: DropdownState } = $props();
|
|
||||||
|
|
||||||
// highlightedIndex must be both writable (keyboard handler mutates it) and
|
|
||||||
// reset when `items` changes (so it never points past the end of a new list).
|
|
||||||
// A pure $derived is read-only and cannot serve both needs, so $state + $effect
|
|
||||||
// is the correct pattern here. The autofixer suggestion to use $derived does not
|
|
||||||
// apply: the mutation in onKeyDown is not a derivation.
|
|
||||||
let highlightedIndex = $state(0);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
// Read model.items to subscribe; reset index whenever the list is replaced.
|
|
||||||
void model.items;
|
|
||||||
highlightedIndex = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Positioning — flip strategy: open upward when there is not enough room
|
|
||||||
// below the cursor to show the dropdown without clipping the viewport.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type Position = {
|
|
||||||
top: string | null;
|
|
||||||
bottom: string | null;
|
|
||||||
left: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DROPDOWN_CLEARANCE_PX = 200;
|
|
||||||
|
|
||||||
const position = $derived.by<Position>(() => {
|
|
||||||
const cr = model.clientRect;
|
|
||||||
if (!cr) return { top: '0px', bottom: null, left: '0px' };
|
|
||||||
const rect = cr();
|
|
||||||
if (!rect) return { top: '0px', bottom: null, left: '0px' };
|
|
||||||
|
|
||||||
// Some editors report a caret DOMRect with zero width; fall back to rect.x.
|
|
||||||
const left = `${rect.width === 0 ? rect.x : rect.left}px`;
|
|
||||||
|
|
||||||
if (window.innerHeight - rect.bottom < DROPDOWN_CLEARANCE_PX) {
|
|
||||||
// Not enough space below — anchor bottom of dropdown to top of caret.
|
|
||||||
return {
|
|
||||||
top: null,
|
|
||||||
bottom: `${window.innerHeight - rect.top}px`,
|
|
||||||
left
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { top: `${rect.bottom}px`, bottom: null, left };
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Keyboard handler — exported so Tiptap's render() can forward events.
|
|
||||||
// Returns true when the event is consumed (prevents the editor's default).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function onKeyDown(event: KeyboardEvent): boolean {
|
|
||||||
const len = model.items.length;
|
|
||||||
if (event.key === 'ArrowDown') {
|
|
||||||
highlightedIndex = (highlightedIndex + 1) % Math.max(len, 1);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'ArrowUp') {
|
|
||||||
highlightedIndex = (highlightedIndex - 1 + Math.max(len, 1)) % Math.max(len, 1);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
const selected = model.items[highlightedIndex];
|
|
||||||
if (selected) {
|
|
||||||
model.command(selected);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape: let the suggestion plugin handle it (return false = not consumed).
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectItem(item: Person) {
|
|
||||||
model.command(item);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Mounted imperatively to document.body by the Tiptap suggestion plugin.
|
|
||||||
Positioned absolutely relative to the viewport using inline styles derived
|
|
||||||
from the Tiptap clientRect() callback.
|
|
||||||
|
|
||||||
SECURITY: This component receives pre-filtered Person[] items from the
|
|
||||||
parent — it does NOT fetch. The parent's fetch relies on the SvelteKit Vite
|
|
||||||
proxy injecting the auth_token cookie as the Authorization header.
|
|
||||||
Mounted in transcribe mode behind WRITE_ALL — never reachable to
|
|
||||||
unauthenticated users.
|
|
||||||
-->
|
|
||||||
<div
|
|
||||||
class="fixed z-50 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
|
||||||
role="listbox"
|
|
||||||
aria-label={m.person_mention_btn_label()}
|
|
||||||
style:top={position.top}
|
|
||||||
style:bottom={position.bottom}
|
|
||||||
style:left={position.left}
|
|
||||||
>
|
|
||||||
{#if model.items.length === 0}
|
|
||||||
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
|
|
||||||
{m.person_mention_popup_empty()}
|
|
||||||
</p>
|
|
||||||
<!--
|
|
||||||
Empty-state escape hatch — without it the transcriber has to close
|
|
||||||
the dropdown, navigate to /persons/new, come back, and re-type the
|
|
||||||
query. target=_blank keeps the document and editor state intact;
|
|
||||||
rel=noopener prevents reverse-tabnabbing on the new tab. Leonie #5621.
|
|
||||||
-->
|
|
||||||
<a
|
|
||||||
href="/persons/new"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none"
|
|
||||||
onmousedown={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
{m.person_mention_create_new()}
|
|
||||||
<span aria-hidden="true">→</span>
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
{#each model.items as person, i (person.id)}
|
|
||||||
<div
|
|
||||||
class={[
|
|
||||||
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
|
|
||||||
// brand-mint ring (≈2.5:1 on white) fails WCAG 1.4.11 Non-Text
|
|
||||||
// Contrast for a meaningful keyboard-highlight indicator. brand-navy
|
|
||||||
// gives ~14.5:1 against the bg-brand-mint/20 row. Leonie #5621.
|
|
||||||
i === highlightedIndex && 'bg-brand-mint/20 ring-2 ring-brand-navy ring-inset'
|
|
||||||
]}
|
|
||||||
role="option"
|
|
||||||
aria-selected={i === highlightedIndex}
|
|
||||||
data-test-person-id={person.id}
|
|
||||||
tabindex="-1"
|
|
||||||
onmousedown={(e) => {
|
|
||||||
// Prevent blur on the editor before the selection fires.
|
|
||||||
e.preventDefault();
|
|
||||||
selectItem(person);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span class="truncate font-serif text-base text-ink">{person.displayName}</span>
|
|
||||||
{#if formatLifeDateRange(person.birthYear, person.deathYear)}
|
|
||||||
<span class="truncate font-sans text-xs text-ink-3">
|
|
||||||
{formatLifeDateRange(person.birthYear, person.deathYear)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
|
||||||
import { chipLabel, otherName } from '$lib/relationshipLabels';
|
|
||||||
import type { components } from '$lib/generated/api';
|
|
||||||
import type { LoadState } from '$lib/types/personHoverCard';
|
|
||||||
|
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
personId: string;
|
|
||||||
cardId: string;
|
|
||||||
position: { top: number; left: number };
|
|
||||||
state: LoadState;
|
|
||||||
onmouseenter?: () => void;
|
|
||||||
onmouseleave?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { personId, cardId, position, state, onmouseenter, onmouseleave }: Props = $props();
|
|
||||||
|
|
||||||
const FAMILY_REL_TYPES: ReadonlySet<RelationshipDTO['relationType']> = new Set([
|
|
||||||
'PARENT_OF',
|
|
||||||
'SPOUSE_OF',
|
|
||||||
'SIBLING_OF'
|
|
||||||
]);
|
|
||||||
const NOTES_MAX = 120;
|
|
||||||
|
|
||||||
const familyChips = $derived(
|
|
||||||
state.status === 'loaded'
|
|
||||||
? state.relationships.filter((r) => FAMILY_REL_TYPES.has(r.relationType))
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
|
|
||||||
const dateRange = $derived(
|
|
||||||
state.status === 'loaded'
|
|
||||||
? formatLifeDateRange(state.person.birthYear, state.person.deathYear)
|
|
||||||
: ''
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cut the notes excerpt at the last word boundary inside the NOTES_MAX
|
|
||||||
* window. Mid-word truncation is especially ugly in German compound nouns
|
|
||||||
* ("…Familienzu…"), so prefer the previous space if there is one within
|
|
||||||
* a reasonable distance. Fall back to a hard cut for strings with no
|
|
||||||
* spaces at all (e.g. a single 150-char word). Leonie FINDING-04 / Elicit E5.
|
|
||||||
*/
|
|
||||||
function truncateAtWordBoundary(text: string, max: number): string {
|
|
||||||
if (text.length <= max) return text;
|
|
||||||
const window = text.slice(0, max);
|
|
||||||
const lastSpace = window.lastIndexOf(' ');
|
|
||||||
// If the last space is too close to the start (< 70% of the window) we'd
|
|
||||||
// produce a near-empty excerpt — fall back to the hard cut instead.
|
|
||||||
const minBoundary = Math.floor(max * 0.7);
|
|
||||||
const cut = lastSpace >= minBoundary ? window.slice(0, lastSpace) : window;
|
|
||||||
return cut + '…';
|
|
||||||
}
|
|
||||||
|
|
||||||
const notesExcerpt = $derived.by(() => {
|
|
||||||
if (state.status !== 'loaded') return null;
|
|
||||||
const notes = state.person.notes;
|
|
||||||
if (!notes) return null;
|
|
||||||
return truncateAtWordBoundary(notes, NOTES_MAX);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Accessible name for the region landmark — required by WCAG 1.3.1.
|
|
||||||
// Falls back to a localised loading label so axe-core never sees an unnamed
|
|
||||||
// region (Leonie FINDING-02 / Elicit NFR concern).
|
|
||||||
const ariaLabel = $derived(
|
|
||||||
state.status === 'loaded' ? state.person.displayName : m.person_mention_loading()
|
|
||||||
);
|
|
||||||
|
|
||||||
// aria-busy="true" while loading so SR clients know the region's contents
|
|
||||||
// will change. Cleared on loaded/error so the new content is announced.
|
|
||||||
const ariaBusy = $derived(state.status === 'loading');
|
|
||||||
|
|
||||||
const showMaidenName = $derived(
|
|
||||||
state.status === 'loaded' &&
|
|
||||||
!!state.person.alias &&
|
|
||||||
state.person.alias !== state.person.lastName &&
|
|
||||||
state.person.alias !== state.person.displayName
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="person-hover-card"
|
|
||||||
data-testid="person-hover-card"
|
|
||||||
id={cardId}
|
|
||||||
role="region"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
aria-busy={ariaBusy ? 'true' : undefined}
|
|
||||||
style:position="fixed"
|
|
||||||
style:top={`${position.top}px`}
|
|
||||||
style:left={`${position.left}px`}
|
|
||||||
onmouseenter={onmouseenter}
|
|
||||||
onmouseleave={onmouseleave}
|
|
||||||
onfocusin={onmouseenter}
|
|
||||||
onfocusout={(e) => {
|
|
||||||
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) {
|
|
||||||
onmouseleave?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#if state.status === 'loading'}
|
|
||||||
<div
|
|
||||||
data-testid="person-hover-card-skeleton"
|
|
||||||
class="skeleton"
|
|
||||||
role="status"
|
|
||||||
aria-label={m.person_mention_loading()}
|
|
||||||
>
|
|
||||||
<div class="bar"></div>
|
|
||||||
<div class="bar"></div>
|
|
||||||
<div class="bar"></div>
|
|
||||||
</div>
|
|
||||||
{:else if state.status === 'error'}
|
|
||||||
<div data-testid="person-hover-card-error" class="error-message">
|
|
||||||
{m.person_mention_load_error()}
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()} →</a>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div data-testid="person-hover-card-content" class="content">
|
|
||||||
<div class="header">
|
|
||||||
<div class="name" data-testid="person-hover-card-name">{state.person.displayName}</div>
|
|
||||||
{#if dateRange}
|
|
||||||
<div class="dates" data-testid="person-hover-card-dates">{dateRange}</div>
|
|
||||||
{/if}
|
|
||||||
{#if showMaidenName}
|
|
||||||
<div class="maiden" data-testid="person-hover-card-maiden">
|
|
||||||
{m.person_born_name_prefix()}
|
|
||||||
{state.person.alias}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if familyChips.length > 0}
|
|
||||||
<div class="chips" data-testid="person-hover-card-chips">
|
|
||||||
{#each familyChips as chip (chip.id)}
|
|
||||||
<span class="chip">
|
|
||||||
<span class="chip-type">{chipLabel(chip, personId)}:</span>
|
|
||||||
{otherName(chip, personId)}
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if notesExcerpt}
|
|
||||||
<p class="notes" data-testid="person-hover-card-notes">{notesExcerpt}</p>
|
|
||||||
{/if}
|
|
||||||
<div class="footer">
|
|
||||||
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()} →</a>
|
|
||||||
<span class="hint">{m.person_mention_hover_hint()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.person-hover-card {
|
|
||||||
width: 320px;
|
|
||||||
min-height: 180px;
|
|
||||||
background-color: var(--c-surface);
|
|
||||||
border: 1px solid var(--c-line);
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
||||||
padding: 14px 16px;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--c-ink);
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* On touch devices the card is suppressed entirely — tap navigates directly. */
|
|
||||||
@media (hover: none) {
|
|
||||||
.person-hover-card {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton .bar {
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: var(--c-line);
|
|
||||||
animation: pulse 1.4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton .bar:nth-child(1) {
|
|
||||||
width: 60%;
|
|
||||||
}
|
|
||||||
.skeleton .bar:nth-child(2) {
|
|
||||||
width: 40%;
|
|
||||||
}
|
|
||||||
.skeleton .bar:nth-child(3) {
|
|
||||||
width: 90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.skeleton .bar {
|
|
||||||
animation: none;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
background-color: var(--c-ink);
|
|
||||||
color: var(--c-surface);
|
|
||||||
margin: -14px -16px 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-top-left-radius: 6px;
|
|
||||||
border-top-right-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font-family: var(--font-serif);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dates,
|
|
||||||
.maiden {
|
|
||||||
font-size: 12px;
|
|
||||||
color: color-mix(in srgb, var(--c-surface) 75%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
background-color: var(--c-accent-bg);
|
|
||||||
color: var(--c-ink);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 2px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-type {
|
|
||||||
font-weight: 600;
|
|
||||||
/* opacity 0.7 on --c-ink: ~5.6:1 light, ~7.1:1 dark — WCAG AA ✓ */
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notes {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--c-ink-2);
|
|
||||||
line-height: 1.4;
|
|
||||||
margin: 0 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--c-ink-2);
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
border-top: 1px solid var(--c-line);
|
|
||||||
padding-top: 8px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.open-link {
|
|
||||||
color: var(--c-ink);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 3px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--c-ink-3);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,373 +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('shows the other person name when hovered person is the object (relatedPersonId) in a PARENT_OF row', async () => {
|
|
||||||
// Storage: Heinrich PARENT_OF Auguste. When viewing Auguste's card,
|
|
||||||
// the chip must show "Heinrich" (the parent), not "Auguste" (herself).
|
|
||||||
const relationships: RelationshipDTO[] = [
|
|
||||||
{
|
|
||||||
id: 'r-parent',
|
|
||||||
personId: 'p-heinrich',
|
|
||||||
relatedPersonId: 'p-aug',
|
|
||||||
personDisplayName: 'Heinrich Raddatz',
|
|
||||||
relatedPersonDisplayName: 'Auguste Raddatz',
|
|
||||||
relationType: 'PARENT_OF'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
render(PersonHoverCard, {
|
|
||||||
personId: 'p-aug',
|
|
||||||
cardId: 'card-1',
|
|
||||||
position: POSITION,
|
|
||||||
state: { status: 'loaded', person: AUGUSTE, relationships }
|
|
||||||
});
|
|
||||||
await expect.element(page.getByText('Heinrich Raddatz')).toBeInTheDocument();
|
|
||||||
// Auguste must NOT appear as her own parent chip name
|
|
||||||
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
|
|
||||||
expect(chips?.textContent).not.toContain('Auguste Raddatz');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits the chips section entirely when no family relationships', async () => {
|
|
||||||
const onlyFriend: RelationshipDTO[] = [
|
|
||||||
{
|
|
||||||
id: 'r1',
|
|
||||||
personId: 'p-aug',
|
|
||||||
relatedPersonId: 'p-friend',
|
|
||||||
personDisplayName: 'Auguste',
|
|
||||||
relatedPersonDisplayName: 'Karl Friend',
|
|
||||||
relationType: 'FRIEND'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
render(PersonHoverCard, {
|
|
||||||
personId: 'p-aug',
|
|
||||||
cardId: 'card-1',
|
|
||||||
position: POSITION,
|
|
||||||
state: { status: 'loaded', person: AUGUSTE, relationships: onlyFriend }
|
|
||||||
});
|
|
||||||
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
|
|
||||||
expect(chips).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders notes excerpt unchanged when notes ≤ 120 characters', async () => {
|
|
||||||
const withNotes = { ...AUGUSTE, notes: 'Born in Berlin.' } as Person;
|
|
||||||
render(PersonHoverCard, {
|
|
||||||
personId: 'p-aug',
|
|
||||||
cardId: 'card-1',
|
|
||||||
position: POSITION,
|
|
||||||
state: { status: 'loaded', person: withNotes, relationships: [] }
|
|
||||||
});
|
|
||||||
await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('truncates notes longer than 120 characters with an ellipsis (single long word)', async () => {
|
|
||||||
// Single 150-char word with no spaces: word-boundary cut would yield nothing,
|
|
||||||
// so fall back to a hard cut at 120 + ellipsis (Sara #7: pin the exact length).
|
|
||||||
const long = 'x'.repeat(150);
|
|
||||||
const withLongNotes = { ...AUGUSTE, notes: long } as Person;
|
|
||||||
render(PersonHoverCard, {
|
|
||||||
personId: 'p-aug',
|
|
||||||
cardId: 'card-1',
|
|
||||||
position: POSITION,
|
|
||||||
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
|
||||||
});
|
|
||||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
|
||||||
expect(notes.textContent).toBe('x'.repeat(120) + '…');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('truncates at the last word boundary inside the 120-char window (Leonie FINDING-04)', async () => {
|
|
||||||
// 150-char string with spaces — must cut at the last space, not mid-word.
|
|
||||||
const sentence = 'Sie war eine bekannte Schriftstellerin und engagierte sich '.repeat(3);
|
|
||||||
// length is 180, last space at idx ≤120
|
|
||||||
const withLongNotes = { ...AUGUSTE, notes: sentence } as Person;
|
|
||||||
render(PersonHoverCard, {
|
|
||||||
personId: 'p-aug',
|
|
||||||
cardId: 'card-1',
|
|
||||||
position: POSITION,
|
|
||||||
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
|
||||||
});
|
|
||||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
|
||||||
const text = notes.textContent ?? '';
|
|
||||||
// Ends with ellipsis
|
|
||||||
expect(text.endsWith('…')).toBe(true);
|
|
||||||
// Last char before the ellipsis is NOT a half-word — verify by checking that
|
|
||||||
// the position right before … is the end of a word (i.e., there's no letter
|
|
||||||
// further along in the original text immediately after our cut point).
|
|
||||||
const cut = text.slice(0, -1); // strip the …
|
|
||||||
// Find this cut substring in the original sentence
|
|
||||||
const idx = sentence.indexOf(cut);
|
|
||||||
expect(idx).toBe(0);
|
|
||||||
const charAfterCut = sentence[cut.length];
|
|
||||||
// The next char should be a space — confirming we cut on a boundary
|
|
||||||
expect(charAfterCut).toBe(' ');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits notes section when notes is null', async () => {
|
|
||||||
render(PersonHoverCard, {
|
|
||||||
personId: 'p-aug',
|
|
||||||
cardId: 'card-1',
|
|
||||||
position: POSITION,
|
|
||||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
|
||||||
});
|
|
||||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]');
|
|
||||||
expect(notes).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('footer renders an anchor link to /persons/{personId}', async () => {
|
|
||||||
render(PersonHoverCard, {
|
|
||||||
personId: 'p-aug',
|
|
||||||
cardId: 'card-1',
|
|
||||||
position: POSITION,
|
|
||||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
|
||||||
});
|
|
||||||
const link = document.querySelector('a[href="/persons/p-aug"]')!;
|
|
||||||
expect(link).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PersonHoverCard — accessibility', () => {
|
|
||||||
it('uses aria-live="polite" so screen readers announce loaded content', async () => {
|
|
||||||
render(PersonHoverCard, {
|
|
||||||
personId: 'p-aug',
|
|
||||||
cardId: 'card-1',
|
|
||||||
position: POSITION,
|
|
||||||
state: { status: 'loading' }
|
|
||||||
});
|
|
||||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
|
||||||
expect(root.getAttribute('aria-live')).toBe('polite');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets aria-busy="true" while loading so SR announces the state change on load', async () => {
|
|
||||||
render(PersonHoverCard, {
|
|
||||||
personId: 'p-aug',
|
|
||||||
cardId: 'card-1',
|
|
||||||
position: POSITION,
|
|
||||||
state: { status: 'loading' }
|
|
||||||
});
|
|
||||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
|
||||||
expect(root.getAttribute('aria-busy')).toBe('true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not set aria-busy when loaded (so the loaded content is announced)', async () => {
|
|
||||||
render(PersonHoverCard, {
|
|
||||||
personId: 'p-aug',
|
|
||||||
cardId: 'card-1',
|
|
||||||
position: POSITION,
|
|
||||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
|
||||||
});
|
|
||||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
|
||||||
// aria-busy is either absent or "false"
|
|
||||||
const busy = root.getAttribute('aria-busy');
|
|
||||||
expect(busy === null || busy === 'false').toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('names the region with the person displayName when loaded (WCAG 1.3.1)', async () => {
|
|
||||||
render(PersonHoverCard, {
|
|
||||||
personId: 'p-aug',
|
|
||||||
cardId: 'card-1',
|
|
||||||
position: POSITION,
|
|
||||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
|
||||||
});
|
|
||||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
|
||||||
expect(root.getAttribute('aria-label')).toBe('Auguste Raddatz');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('names the region with a generic loading label while loading', async () => {
|
|
||||||
render(PersonHoverCard, {
|
|
||||||
personId: 'p-aug',
|
|
||||||
cardId: 'card-1',
|
|
||||||
position: POSITION,
|
|
||||||
state: { status: 'loading' }
|
|
||||||
});
|
|
||||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
|
||||||
// Region must have an accessible name in every state — axe-core flags
|
|
||||||
// role="region" without aria-label / aria-labelledby.
|
|
||||||
expect(root.getAttribute('aria-label')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => {
|
|
||||||
render(PersonHoverCard, {
|
|
||||||
personId: 'p-aug',
|
|
||||||
cardId: 'card-xyz',
|
|
||||||
position: POSITION,
|
|
||||||
state: { status: 'loading' }
|
|
||||||
});
|
|
||||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
|
||||||
expect(root.id).toBe('card-xyz');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('positions itself fixed at the given top/left', async () => {
|
|
||||||
render(PersonHoverCard, {
|
|
||||||
personId: 'p-aug',
|
|
||||||
cardId: 'card-1',
|
|
||||||
position: { top: 333, left: 444 },
|
|
||||||
state: { status: 'loading' }
|
|
||||||
});
|
|
||||||
const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement;
|
|
||||||
expect(root.style.top).toBe('333px');
|
|
||||||
expect(root.style.left).toBe('444px');
|
|
||||||
expect(root.style.position).toBe('fixed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, onDestroy, mount, unmount } from 'svelte';
|
|
||||||
import { Editor } from '@tiptap/core';
|
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
|
||||||
import { Mention } from '@tiptap/extension-mention';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import type { components } from '$lib/generated/api';
|
|
||||||
import type { PersonMention } from '$lib/types';
|
|
||||||
import { deserialize, serialize } from '$lib/utils/mentionSerializer';
|
|
||||||
import MentionDropdown from './MentionDropdown.svelte';
|
|
||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
value: string;
|
|
||||||
mentionedPersons: PersonMention[];
|
|
||||||
placeholder?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
onfocus?: () => void;
|
|
||||||
onblur?: () => void;
|
|
||||||
onSelectionChange?: (text: string | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
let {
|
|
||||||
value = $bindable(''),
|
|
||||||
mentionedPersons = $bindable([]),
|
|
||||||
placeholder = '',
|
|
||||||
disabled = false,
|
|
||||||
onfocus,
|
|
||||||
onblur,
|
|
||||||
onSelectionChange
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let editorEl: HTMLDivElement;
|
|
||||||
let editor: Editor | null = null;
|
|
||||||
|
|
||||||
// Single reactive state object shared with MentionDropdown. Mutating these
|
|
||||||
// fields propagates to the mounted dropdown via Svelte's $state proxy —
|
|
||||||
// this is required because Svelte 5's `mount()` does NOT return prop
|
|
||||||
// accessors; setting `instance.items = ...` does not update the component.
|
|
||||||
let dropdownState = $state<{
|
|
||||||
items: Person[];
|
|
||||||
command: (item: Person) => void;
|
|
||||||
clientRect: (() => DOMRect | null) | null;
|
|
||||||
}>({
|
|
||||||
items: [],
|
|
||||||
command: () => {},
|
|
||||||
clientRect: null
|
|
||||||
});
|
|
||||||
|
|
||||||
type DropdownExports = {
|
|
||||||
onKeyDown: (event: KeyboardEvent) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
// Custom Mention node: uses personId / displayName instead of the
|
|
||||||
// default id / label attribute names so the mentionSerializer can
|
|
||||||
// round-trip correctly without attribute remapping.
|
|
||||||
const CustomMention = Mention.extend({
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
personId: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (el) => el.getAttribute('data-person-id'),
|
|
||||||
renderHTML: (attrs) => ({ 'data-person-id': attrs.personId })
|
|
||||||
},
|
|
||||||
displayName: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (el) => el.getAttribute('data-display-name'),
|
|
||||||
renderHTML: (attrs) => ({ 'data-display-name': attrs.displayName })
|
|
||||||
},
|
|
||||||
mentionSuggestionChar: {
|
|
||||||
default: '@',
|
|
||||||
parseHTML: (el) => el.getAttribute('data-mention-suggestion-char'),
|
|
||||||
renderHTML: (attrs) => ({
|
|
||||||
'data-mention-suggestion-char': attrs.mentionSuggestionChar
|
|
||||||
})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
editor = new Editor({
|
|
||||||
element: editorEl,
|
|
||||||
// Initial editable state honors the `disabled` prop. The reactive
|
|
||||||
// $effect below keeps it in sync if the prop flips after mount —
|
|
||||||
// without this, a keyboard user can tab into the contenteditable
|
|
||||||
// even when the wrapper has pointer-events-none (WCAG 2.1.1).
|
|
||||||
editable: !disabled,
|
|
||||||
extensions: [
|
|
||||||
StarterKit.configure({
|
|
||||||
heading: false,
|
|
||||||
bold: false,
|
|
||||||
italic: false,
|
|
||||||
strike: false,
|
|
||||||
code: false,
|
|
||||||
blockquote: false,
|
|
||||||
codeBlock: false,
|
|
||||||
bulletList: false,
|
|
||||||
orderedList: false,
|
|
||||||
hardBreak: false,
|
|
||||||
horizontalRule: false
|
|
||||||
}),
|
|
||||||
CustomMention.configure({
|
|
||||||
renderHTML({ node }) {
|
|
||||||
// Underline color matches the read-mode .person-mention rule
|
|
||||||
// (ink at ~50% alpha) — brand-mint on white fails WCAG 1.4.11
|
|
||||||
// Non-Text Contrast (≈1.7:1, needs 3:1). Leonie #5621.
|
|
||||||
return [
|
|
||||||
'span',
|
|
||||||
{
|
|
||||||
'data-type': 'mention',
|
|
||||||
'data-person-id': node.attrs.personId,
|
|
||||||
'data-display-name': node.attrs.displayName,
|
|
||||||
class:
|
|
||||||
'mention-token underline decoration-ink/50 underline-offset-2 text-brand-navy font-medium'
|
|
||||||
},
|
|
||||||
`@${node.attrs.displayName}`
|
|
||||||
];
|
|
||||||
},
|
|
||||||
renderText({ node }) {
|
|
||||||
return `@${node.attrs.displayName}`;
|
|
||||||
},
|
|
||||||
suggestion: {
|
|
||||||
char: '@',
|
|
||||||
allowSpaces: true,
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// EXCEPTION to frontend/CLAUDE.md "no client-side API fetch":
|
|
||||||
// Tiptap's suggestion plugin lives entirely on the client and
|
|
||||||
// fires on every keystroke after `@`. Routing each query through
|
|
||||||
// a SvelteKit form action would round-trip through SSR for a
|
|
||||||
// dropdown that needs to feel instantaneous, and a +server.ts
|
|
||||||
// endpoint would only proxy the same call. Auth flows through
|
|
||||||
// the Vite proxy in dev and Caddy in prod (cookie-based), so the
|
|
||||||
// network surface is identical to a server-driven call.
|
|
||||||
// Markus #5616: an ADR will formalise this. Open follow-up:
|
|
||||||
// "ADR: client-side fetch exception for editor suggestion plugins."
|
|
||||||
// Nora #5618 #3 — separate issue tracks the GET /api/persons
|
|
||||||
// response-shape audit (PersonSummaryDTO leaks `notes`).
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
items: async ({ query }: { query: string }) => {
|
|
||||||
if (!query) return [];
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
|
|
||||||
if (!res.ok) return [];
|
|
||||||
return ((await res.json()) as Person[]).slice(0, 5);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// AC-1 fix: insert the typed query as displayName, not person.displayName.
|
|
||||||
command({ editor: ed, range, props }) {
|
|
||||||
const p = props as unknown as { personId: string; displayName: string };
|
|
||||||
const nodeAfter = ed.view.state.selection.$to.nodeAfter;
|
|
||||||
if (nodeAfter?.text?.startsWith(' ')) range.to += 1;
|
|
||||||
ed.chain()
|
|
||||||
.focus()
|
|
||||||
.insertContentAt(range, [
|
|
||||||
{
|
|
||||||
type: 'mention',
|
|
||||||
attrs: { personId: p.personId, displayName: p.displayName }
|
|
||||||
},
|
|
||||||
{ type: 'text', text: ' ' }
|
|
||||||
])
|
|
||||||
.run();
|
|
||||||
},
|
|
||||||
render() {
|
|
||||||
let component: object | null = null;
|
|
||||||
let exports: DropdownExports | null = null;
|
|
||||||
|
|
||||||
// Tiptap's SuggestionProps types `command` against the default
|
|
||||||
// MentionNodeAttrs (id/label). Our custom Mention extension uses
|
|
||||||
// personId/displayName, so we cast the renderProps locally.
|
|
||||||
type LooseRenderProps = {
|
|
||||||
items: unknown;
|
|
||||||
command: (props: { personId: string; displayName: string }) => void;
|
|
||||||
query: string;
|
|
||||||
clientRect?: (() => DOMRect | null) | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateState = (renderProps: LooseRenderProps) => {
|
|
||||||
dropdownState.items = renderProps.items as Person[];
|
|
||||||
// AC-1: pass typed query as displayName, not person.displayName
|
|
||||||
dropdownState.command = (item: Person) =>
|
|
||||||
renderProps.command({
|
|
||||||
personId: item.id,
|
|
||||||
displayName: renderProps.query
|
|
||||||
});
|
|
||||||
dropdownState.clientRect = renderProps.clientRect ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
onStart(renderProps) {
|
|
||||||
updateState(renderProps as unknown as LooseRenderProps);
|
|
||||||
const mounted = mount(MentionDropdown, {
|
|
||||||
target: document.body,
|
|
||||||
props: { model: dropdownState }
|
|
||||||
});
|
|
||||||
component = mounted as object;
|
|
||||||
exports = mounted as unknown as DropdownExports;
|
|
||||||
},
|
|
||||||
onUpdate(renderProps) {
|
|
||||||
updateState(renderProps as unknown as LooseRenderProps);
|
|
||||||
},
|
|
||||||
onKeyDown({ event }) {
|
|
||||||
// Escape is handled by the suggestion plugin itself.
|
|
||||||
if (event.key === 'Escape') return false;
|
|
||||||
return exports?.onKeyDown(event) ?? false;
|
|
||||||
},
|
|
||||||
onExit() {
|
|
||||||
if (component) {
|
|
||||||
unmount(component);
|
|
||||||
component = null;
|
|
||||||
exports = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
],
|
|
||||||
content: deserialize(value, mentionedPersons),
|
|
||||||
editorProps: {
|
|
||||||
attributes: {
|
|
||||||
role: 'textbox',
|
|
||||||
'aria-multiline': 'true',
|
|
||||||
'aria-label': m.transcription_editor_aria_label(),
|
|
||||||
'data-editor-inner': '',
|
|
||||||
class: [
|
|
||||||
'min-h-[120px] px-1 py-2.5',
|
|
||||||
'font-serif text-base leading-relaxed text-ink',
|
|
||||||
'focus:outline-none',
|
|
||||||
'tiptap-editor-inner'
|
|
||||||
].join(' ')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onUpdate({ editor: ed }) {
|
|
||||||
const { text, mentionedPersons: mp } = serialize(ed.getJSON());
|
|
||||||
value = text;
|
|
||||||
mentionedPersons = mp;
|
|
||||||
},
|
|
||||||
onFocus() {
|
|
||||||
onfocus?.();
|
|
||||||
},
|
|
||||||
onBlur() {
|
|
||||||
onblur?.();
|
|
||||||
},
|
|
||||||
onSelectionUpdate({ editor: ed }) {
|
|
||||||
const { from, to } = ed.state.selection;
|
|
||||||
onSelectionChange?.(from !== to ? ed.state.doc.textBetween(from, to) : null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
editor?.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep the data-placeholder attribute in sync with actual emptiness so the
|
|
||||||
// placeholder CSS only fires when there is no content (not just on blur).
|
|
||||||
$effect(() => {
|
|
||||||
if (!editor || !placeholder) return;
|
|
||||||
void value; // Tiptap's onUpdate always fires on content change, but $effect needs a
|
|
||||||
// reactive read to re-run — void value registers value as a dependency without using it.
|
|
||||||
const inner = editorEl?.querySelector('[data-editor-inner]') as HTMLElement | null;
|
|
||||||
if (!inner) return;
|
|
||||||
if (editor.isEmpty) {
|
|
||||||
inner.setAttribute('data-placeholder', placeholder);
|
|
||||||
} else {
|
|
||||||
inner.removeAttribute('data-placeholder');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep editor in sync with the reactive `disabled` prop. Tiptap's setEditable
|
|
||||||
// flips contenteditable on the inner DOM and stops accepting input — matches
|
|
||||||
// the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1).
|
|
||||||
//
|
|
||||||
// Guard: setEditable triggers a ProseMirror transaction which fires onUpdate;
|
|
||||||
// onUpdate writes through bind:value / bind:mentionedPersons. Without this
|
|
||||||
// idempotence check, the effect would loop on every prop pass-through.
|
|
||||||
$effect(() => {
|
|
||||||
const shouldBeEditable = !disabled;
|
|
||||||
if (editor && editor.isEditable !== shouldBeEditable) {
|
|
||||||
editor.setEditable(shouldBeEditable);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
|
|
||||||
class:opacity-50={disabled}
|
|
||||||
class:pointer-events-none={disabled}
|
|
||||||
aria-disabled={disabled ? 'true' : undefined}
|
|
||||||
bind:this={editorEl}
|
|
||||||
></div>
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
/**
|
|
||||||
* PersonMentionEditor — Tiptap-based component tests.
|
|
||||||
*
|
|
||||||
* All old tests used document.querySelector('textarea') which is dead after
|
|
||||||
* the Tiptap migration. These tests drive the contenteditable via
|
|
||||||
* userEvent.type() and inspect the serialized output from the test host.
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
|
||||||
import { page, userEvent } from 'vitest/browser';
|
|
||||||
import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte';
|
|
||||||
import type { components } from '$lib/generated/api';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
|
||||||
type PersonMention = components['schemas']['PersonMention'];
|
|
||||||
|
|
||||||
const AUGUSTE: Person = {
|
|
||||||
id: 'p-aug',
|
|
||||||
firstName: 'Auguste',
|
|
||||||
lastName: 'Raddatz',
|
|
||||||
displayName: 'Auguste Raddatz',
|
|
||||||
birthYear: 1882,
|
|
||||||
deathYear: 1944
|
|
||||||
} as unknown as Person;
|
|
||||||
|
|
||||||
const ANNA: Person = {
|
|
||||||
id: 'p-anna',
|
|
||||||
firstName: 'Anna',
|
|
||||||
lastName: 'Schmidt',
|
|
||||||
displayName: 'Anna Schmidt',
|
|
||||||
birthYear: 1860
|
|
||||||
} as unknown as Person;
|
|
||||||
|
|
||||||
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockFetchEmpty() {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
|
|
||||||
|
|
||||||
function renderHost(
|
|
||||||
initial: { value?: string; mentionedPersons?: PersonMention[]; disabled?: boolean } = {}
|
|
||||||
) {
|
|
||||||
let snapshot: Snapshot = {
|
|
||||||
value: initial.value ?? '',
|
|
||||||
mentionedPersons: initial.mentionedPersons ?? []
|
|
||||||
};
|
|
||||||
render(PersonMentionEditorHost, {
|
|
||||||
initialValue: initial.value ?? '',
|
|
||||||
initialMentions: initial.mentionedPersons ?? [],
|
|
||||||
disabled: initial.disabled ?? false,
|
|
||||||
onChange: (snap: Snapshot) => {
|
|
||||||
snapshot = snap;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
get snapshot() {
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PersonMentionEditor — rendering', () => {
|
|
||||||
it('renders the editor as a textbox (ARIA role from editorProps)', async () => {
|
|
||||||
render(PersonMentionEditorHost, {
|
|
||||||
initialValue: '',
|
|
||||||
initialMentions: [],
|
|
||||||
onChange: () => {}
|
|
||||||
});
|
|
||||||
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reflects bound initial value as visible text', async () => {
|
|
||||||
render(PersonMentionEditorHost, {
|
|
||||||
initialValue: 'Hallo Welt',
|
|
||||||
initialMentions: [],
|
|
||||||
onChange: () => {}
|
|
||||||
});
|
|
||||||
await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Typeahead opens on @ ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PersonMentionEditor — typeahead', () => {
|
|
||||||
it('opens the dropdown when typing @ + query and shows results', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
|
||||||
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hits /api/persons?q= with the typed query', async () => {
|
|
||||||
const fetchMock = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows life dates next to the name in the dropdown', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
|
||||||
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows empty state when no persons match', async () => {
|
|
||||||
mockFetchEmpty();
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@xyz');
|
|
||||||
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('offers a "create new person" link in the empty state', async () => {
|
|
||||||
mockFetchEmpty();
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@xyz');
|
|
||||||
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
const link = page.getByRole('link', { name: /Neue Person anlegen/ });
|
|
||||||
await expect.element(link).toBeVisible();
|
|
||||||
await expect.element(link).toHaveAttribute('href', '/persons/new');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
|
|
||||||
|
|
||||||
describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
|
|
||||||
it('stores the typed query as displayName, not the person DB name', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
const host = renderHost();
|
|
||||||
|
|
||||||
// User types "@Aug" (not the full "Auguste Raddatz") and selects Auguste Raddatz
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
|
||||||
expect(host.snapshot.mentionedPersons[0]).toEqual({
|
|
||||||
personId: 'p-aug',
|
|
||||||
displayName: 'Aug' // typed text, not "Auguste Raddatz"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('regression: text value contains the typed query, not the full DB name', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
const host = renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
// Text should contain "@Aug " (typed text + space), not "@Auguste Raddatz "
|
|
||||||
expect(host.snapshot.value).toContain('@Aug');
|
|
||||||
expect(host.snapshot.value).not.toContain('@Auguste Raddatz');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('pushes {personId, displayName} into mentionedPersons sidecar', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
const host = renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
const host = renderHost({
|
|
||||||
value: '@Aug ',
|
|
||||||
mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }]
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Keyboard navigation ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PersonMentionEditor — keyboard navigation', () => {
|
|
||||||
it('Enter selects the highlighted result', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
const host = renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@A');
|
|
||||||
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.keyboard('{Enter}');
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ArrowDown moves the highlight to the next result', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@A');
|
|
||||||
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.keyboard('{ArrowDown}');
|
|
||||||
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
const annaOption = page.getByRole('option', { name: /Anna Schmidt/ });
|
|
||||||
await expect.element(annaOption).toHaveAttribute('aria-selected', 'true');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Escape closes the dropdown without inserting', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
const host = renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
|
||||||
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.keyboard('{Escape}');
|
|
||||||
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(host.snapshot.mentionedPersons).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Disabled state (WCAG 2.1.1 — keyboard users) ────────────────────────────
|
|
||||||
|
|
||||||
describe('PersonMentionEditor — disabled state', () => {
|
|
||||||
it('sets contenteditable=false on the editor when disabled', async () => {
|
|
||||||
renderHost({ value: 'Bestehender Text', disabled: true });
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
|
||||||
expect(textbox).not.toBeNull();
|
|
||||||
expect(textbox!.getAttribute('contenteditable')).toBe('false');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes aria-disabled=true on the editor wrapper when disabled', async () => {
|
|
||||||
renderHost({ disabled: true });
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
const wrapper = document.querySelector('[aria-disabled="true"]');
|
|
||||||
expect(wrapper).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the editor editable (contenteditable=true) when not disabled', async () => {
|
|
||||||
renderHost({ disabled: false });
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
|
||||||
expect(textbox).not.toBeNull();
|
|
||||||
expect(textbox!.getAttribute('contenteditable')).toBe('true');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Security — XSS in displayName (CWE-79) ──────────────────────────────────
|
|
||||||
|
|
||||||
describe('PersonMentionEditor — XSS resistance', () => {
|
|
||||||
it('renders a malicious displayName as text, not as HTML elements', async () => {
|
|
||||||
// A historical sidecar entry whose displayName contains an HTML payload
|
|
||||||
// that would execute if interpolated as raw HTML. Tiptap's renderHTML
|
|
||||||
// returns the @-prefixed string as the third tuple entry, which
|
|
||||||
// ProseMirror's DOMSerializer treats as a Text node — escaping it.
|
|
||||||
const maliciousMention: PersonMention = {
|
|
||||||
personId: '00000000-0000-0000-0000-000000000001',
|
|
||||||
displayName: '<img src=x onerror=alert(1)>'
|
|
||||||
};
|
|
||||||
|
|
||||||
renderHost({
|
|
||||||
value: '@<img src=x onerror=alert(1)>',
|
|
||||||
mentionedPersons: [maliciousMention]
|
|
||||||
});
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
|
||||||
expect(textbox).not.toBeNull();
|
|
||||||
// No element from the malicious payload should have appeared as a real
|
|
||||||
// DOM node. (Tiptap inserts its own ProseMirror-separator <img> in empty
|
|
||||||
// paragraphs — that is internal markup and never carries user attrs;
|
|
||||||
// guard against the injection by checking the user-controlled attrs.)
|
|
||||||
expect(textbox!.querySelector('img[onerror]')).toBeNull();
|
|
||||||
expect(textbox!.querySelector('img[src="x"]')).toBeNull();
|
|
||||||
expect(textbox!.querySelector('script')).toBeNull();
|
|
||||||
// The payload should appear as visible text content instead.
|
|
||||||
expect(textbox!.textContent ?? '').toContain('<img src=x onerror=alert(1)>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Placeholder behavior ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PersonMentionEditor — placeholder behavior', () => {
|
|
||||||
it('sets data-placeholder on the inner element when editor is empty', async () => {
|
|
||||||
render(PersonMentionEditorHost, {
|
|
||||||
initialValue: '',
|
|
||||||
initialMentions: [],
|
|
||||||
placeholder: 'Gib Text ein...',
|
|
||||||
onChange: () => {}
|
|
||||||
});
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
|
|
||||||
expect(inner).not.toBeNull();
|
|
||||||
expect(inner!.getAttribute('data-placeholder')).toBe('Gib Text ein...');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits data-placeholder on the inner element when editor has content', async () => {
|
|
||||||
render(PersonMentionEditorHost, {
|
|
||||||
initialValue: 'Bestehender Text',
|
|
||||||
initialMentions: [],
|
|
||||||
placeholder: 'Gib Text ein...',
|
|
||||||
onChange: () => {}
|
|
||||||
});
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
|
|
||||||
expect(inner).not.toBeNull();
|
|
||||||
expect(inner!.hasAttribute('data-placeholder')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── i18n message content ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PersonMentionEditor — i18n message content', () => {
|
|
||||||
it('transcription_block_placeholder contains @ mention trigger for discoverability', () => {
|
|
||||||
expect(m.transcription_block_placeholder()).toContain('@');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PersonMentionEditor — touch target', () => {
|
|
||||||
it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
renderHost();
|
|
||||||
|
|
||||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
|
||||||
|
|
||||||
await vi.waitFor(async () => {
|
|
||||||
await expect.element(page.getByRole('option').first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
const option = document.querySelector('[role="option"]') as HTMLElement;
|
|
||||||
expect(option).not.toBeNull();
|
|
||||||
expect(option.className).toContain('min-h-[44px]');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +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;
|
|
||||||
disabled?: boolean;
|
|
||||||
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
let {
|
|
||||||
initialValue = '',
|
|
||||||
initialMentions = [],
|
|
||||||
placeholder,
|
|
||||||
disabled = false,
|
|
||||||
onChange
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// initial* props seed mount-time state; reading them inside untrack signals
|
|
||||||
// the intentional one-shot capture and silences state_referenced_locally.
|
|
||||||
let value = $state(untrack(() => initialValue));
|
|
||||||
let mentionedPersons = $state<PersonMention[]>(untrack(() => [...initialMentions]));
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
onChange({ value, mentionedPersons: [...mentionedPersons] });
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<PersonMentionEditor
|
|
||||||
bind:value={value}
|
|
||||||
bind:mentionedPersons={mentionedPersons}
|
|
||||||
placeholder={placeholder}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
@@ -12,25 +12,16 @@ const PERSONS = [
|
|||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
displayName: 'Max Mustermann',
|
displayName: 'Max Mustermann',
|
||||||
personType: 'PERSON',
|
personType: 'PERSON'
|
||||||
familyMember: false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
firstName: 'Anna',
|
firstName: 'Anna',
|
||||||
lastName: 'Musterfrau',
|
lastName: 'Musterfrau',
|
||||||
displayName: 'Anna Musterfrau',
|
displayName: 'Anna Musterfrau',
|
||||||
personType: 'PERSON',
|
personType: 'PERSON'
|
||||||
familyMember: false
|
|
||||||
},
|
},
|
||||||
{
|
{ id: '3', firstName: 'Karl', lastName: 'König', displayName: 'Karl König', personType: 'PERSON' }
|
||||||
id: '3',
|
|
||||||
firstName: 'Karl',
|
|
||||||
lastName: 'König',
|
|
||||||
displayName: 'Karl König',
|
|
||||||
personType: 'PERSON',
|
|
||||||
familyMember: false
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function mockFetch(persons = PERSONS) {
|
function mockFetch(persons = PERSONS) {
|
||||||
@@ -71,16 +62,14 @@ describe('PersonMultiSelect – rendering', () => {
|
|||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
displayName: 'Max Mustermann',
|
displayName: 'Max Mustermann',
|
||||||
personType: 'PERSON',
|
personType: 'PERSON'
|
||||||
familyMember: false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
firstName: 'Anna',
|
firstName: 'Anna',
|
||||||
lastName: 'Musterfrau',
|
lastName: 'Musterfrau',
|
||||||
displayName: 'Anna Musterfrau',
|
displayName: 'Anna Musterfrau',
|
||||||
personType: 'PERSON',
|
personType: 'PERSON'
|
||||||
familyMember: false
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -97,16 +86,14 @@ describe('PersonMultiSelect – rendering', () => {
|
|||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
displayName: 'Max Mustermann',
|
displayName: 'Max Mustermann',
|
||||||
personType: 'PERSON',
|
personType: 'PERSON'
|
||||||
familyMember: false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
firstName: 'Anna',
|
firstName: 'Anna',
|
||||||
lastName: 'Musterfrau',
|
lastName: 'Musterfrau',
|
||||||
displayName: 'Anna Musterfrau',
|
displayName: 'Anna Musterfrau',
|
||||||
personType: 'PERSON',
|
personType: 'PERSON'
|
||||||
familyMember: false
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -125,8 +112,7 @@ describe('PersonMultiSelect – rendering', () => {
|
|||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
displayName: 'Max Mustermann',
|
displayName: 'Max Mustermann',
|
||||||
personType: 'PERSON',
|
personType: 'PERSON'
|
||||||
familyMember: false
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -180,8 +166,7 @@ describe('PersonMultiSelect – selecting persons', () => {
|
|||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
displayName: 'Max Mustermann',
|
displayName: 'Max Mustermann',
|
||||||
personType: 'PERSON',
|
personType: 'PERSON'
|
||||||
familyMember: false
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -202,8 +187,7 @@ describe('PersonMultiSelect – selecting persons', () => {
|
|||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
displayName: 'Max Mustermann',
|
displayName: 'Max Mustermann',
|
||||||
personType: 'PERSON',
|
personType: 'PERSON'
|
||||||
familyMember: false
|
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
render(PersonMultiSelect, { selectedPersons: [] });
|
render(PersonMultiSelect, { selectedPersons: [] });
|
||||||
@@ -226,16 +210,14 @@ describe('PersonMultiSelect – removing persons', () => {
|
|||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
displayName: 'Max Mustermann',
|
displayName: 'Max Mustermann',
|
||||||
personType: 'PERSON',
|
personType: 'PERSON'
|
||||||
familyMember: false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
firstName: 'Anna',
|
firstName: 'Anna',
|
||||||
lastName: 'Musterfrau',
|
lastName: 'Musterfrau',
|
||||||
displayName: 'Anna Musterfrau',
|
displayName: 'Anna Musterfrau',
|
||||||
personType: 'PERSON',
|
personType: 'PERSON'
|
||||||
familyMember: false
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -254,16 +236,14 @@ describe('PersonMultiSelect – removing persons', () => {
|
|||||||
firstName: 'Max',
|
firstName: 'Max',
|
||||||
lastName: 'Mustermann',
|
lastName: 'Mustermann',
|
||||||
displayName: 'Max Mustermann',
|
displayName: 'Max Mustermann',
|
||||||
personType: 'PERSON',
|
personType: 'PERSON'
|
||||||
familyMember: false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
firstName: 'Anna',
|
firstName: 'Anna',
|
||||||
lastName: 'Musterfrau',
|
lastName: 'Musterfrau',
|
||||||
displayName: 'Anna Musterfrau',
|
displayName: 'Anna Musterfrau',
|
||||||
personType: 'PERSON',
|
personType: 'PERSON'
|
||||||
familyMember: false
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ interface Props {
|
|||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
restrictToCorrespondentsOf?: string;
|
restrictToCorrespondentsOf?: string;
|
||||||
excludePersonId?: string;
|
|
||||||
badge?: 'additive' | 'replace';
|
badge?: 'additive' | 'replace';
|
||||||
onchange?: (value: string) => void;
|
onchange?: (value: string) => void;
|
||||||
onfocused?: () => void;
|
onfocused?: () => void;
|
||||||
@@ -37,7 +36,6 @@ let {
|
|||||||
autofocus = false,
|
autofocus = false,
|
||||||
required = false,
|
required = false,
|
||||||
restrictToCorrespondentsOf,
|
restrictToCorrespondentsOf,
|
||||||
excludePersonId,
|
|
||||||
badge,
|
badge,
|
||||||
onchange,
|
onchange,
|
||||||
onfocused
|
onfocused
|
||||||
@@ -63,40 +61,21 @@ $effect(() => {
|
|||||||
const typeahead = createTypeahead<Person>({
|
const typeahead = createTypeahead<Person>({
|
||||||
fetchUrl: async (term) => {
|
fetchUrl: async (term) => {
|
||||||
const personId = restrictToCorrespondentsOf;
|
const personId = restrictToCorrespondentsOf;
|
||||||
const excludeId = excludePersonId;
|
|
||||||
const filter = (results: Person[]) =>
|
|
||||||
excludeId ? results.filter((p) => p.id !== excludeId) : results;
|
|
||||||
if (personId) {
|
if (personId) {
|
||||||
const url =
|
const url =
|
||||||
term.length >= 1
|
term.length >= 1
|
||||||
? `/api/persons/${personId}/correspondents?q=${encodeURIComponent(term)}`
|
? `/api/persons/${personId}/correspondents?q=${encodeURIComponent(term)}`
|
||||||
: `/api/persons/${personId}/correspondents`;
|
: `/api/persons/${personId}/correspondents`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
return res.ok ? filter(await res.json()) : [];
|
return res.ok ? await res.json() : [];
|
||||||
}
|
}
|
||||||
if (term.length < 1) return [];
|
if (term.length < 1) return [];
|
||||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`);
|
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`);
|
||||||
return res.ok ? filter(await res.json()) : [];
|
return res.ok ? await res.json() : [];
|
||||||
},
|
},
|
||||||
debounceMs: 300
|
debounceMs: 300
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fixed-position dropdown state — escapes any CSS stacking context that would clip it.
|
|
||||||
let inputEl: HTMLInputElement;
|
|
||||||
let dropdownStyle = $state('');
|
|
||||||
let activeIndex = $state(-1);
|
|
||||||
|
|
||||||
// Stable id linking the input's aria-controls to the listbox element.
|
|
||||||
const listboxId = `${name}-listbox`;
|
|
||||||
|
|
||||||
const isOpen = $derived(typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading));
|
|
||||||
|
|
||||||
function updateDropdownPosition() {
|
|
||||||
if (!inputEl) return;
|
|
||||||
const rect = inputEl.getBoundingClientRect();
|
|
||||||
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInput() {
|
function handleInput() {
|
||||||
if (value && searchTerm !== initialName) {
|
if (value && searchTerm !== initialName) {
|
||||||
value = '';
|
value = '';
|
||||||
@@ -109,7 +88,6 @@ function handleInput() {
|
|||||||
|
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
onfocused?.();
|
onfocused?.();
|
||||||
updateDropdownPosition();
|
|
||||||
if (restrictToCorrespondentsOf) {
|
if (restrictToCorrespondentsOf) {
|
||||||
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -131,47 +109,13 @@ function selectPerson(person: Person) {
|
|||||||
value = person.id!;
|
value = person.id!;
|
||||||
searchTerm = person.displayName;
|
searchTerm = person.displayName;
|
||||||
typeahead.close();
|
typeahead.close();
|
||||||
activeIndex = -1;
|
|
||||||
onchange?.(person.id!);
|
onchange?.(person.id!);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDropdown() {
|
|
||||||
typeahead.close();
|
|
||||||
activeIndex = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if (!isOpen) return;
|
|
||||||
|
|
||||||
const results = typeahead.results;
|
|
||||||
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
activeIndex = (activeIndex + 1) % results.length;
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
activeIndex = (activeIndex - 1 + results.length) % results.length;
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
if (activeIndex >= 0 && results[activeIndex]) {
|
|
||||||
selectPerson(results[activeIndex]);
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep dropdown position current when user scrolls or resizes.
|
|
||||||
// fixed positioning is intentional — it escapes any CSS stacking context (overflow, transform,
|
|
||||||
// shadow-sm + z-index combinations) that would clip an absolute-positioned dropdown.
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
|
||||||
|
|
||||||
<div class="relative" use:clickOutside onclickoutside={closeDropdown}>
|
|
||||||
<label
|
<label
|
||||||
for="{name}-search"
|
for={name}
|
||||||
class={compact
|
class={compact
|
||||||
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||||
: 'block text-sm font-medium text-ink-2'}
|
: 'block text-sm font-medium text-ink-2'}
|
||||||
@@ -181,22 +125,13 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<input type="hidden" name={name} bind:value={value} />
|
<input type="hidden" name={name} bind:value={value} />
|
||||||
|
|
||||||
<input
|
<input
|
||||||
bind:this={inputEl}
|
|
||||||
type="text"
|
type="text"
|
||||||
id="{name}-search"
|
id="{name}-search"
|
||||||
role="combobox"
|
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autofocus={autofocus}
|
autofocus={autofocus}
|
||||||
bind:value={searchTerm}
|
bind:value={searchTerm}
|
||||||
aria-expanded={isOpen}
|
|
||||||
aria-haspopup="listbox"
|
|
||||||
aria-controls={listboxId}
|
|
||||||
aria-activedescendant={activeIndex >= 0 && typeahead.results[activeIndex]
|
|
||||||
? `${listboxId}-option-${typeahead.results[activeIndex].id}`
|
|
||||||
: undefined}
|
|
||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
onfocus={handleFocus}
|
onfocus={handleFocus}
|
||||||
onkeydown={handleKeydown}
|
|
||||||
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
||||||
class={large
|
class={large
|
||||||
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||||
@@ -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'}
|
: '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}
|
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
||||||
<ul
|
<div
|
||||||
id={listboxId}
|
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"
|
||||||
role="listbox"
|
|
||||||
style={dropdownStyle}
|
|
||||||
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
|
||||||
>
|
>
|
||||||
{#if typeahead.loading}
|
{#if typeahead.loading}
|
||||||
<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}
|
{:else}
|
||||||
{#each typeahead.results as person, i (person.id)}
|
{#each typeahead.results as person (person.id)}
|
||||||
<li
|
<div
|
||||||
id="{listboxId}-option-{person.id}"
|
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
|
||||||
role="option"
|
|
||||||
aria-selected={i === activeIndex}
|
|
||||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg {i === activeIndex ? 'bg-accent-bg' : ''}"
|
|
||||||
onclick={() => selectPerson(person)}
|
onclick={() => selectPerson(person)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||||
tabindex="-1"
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="block truncate font-medium">
|
<span class="block truncate font-medium">
|
||||||
{person.displayName}
|
{person.displayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</ul>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page, userEvent } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import PersonTypeahead from './PersonTypeahead.svelte';
|
import PersonTypeahead from './PersonTypeahead.svelte';
|
||||||
|
|
||||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||||
@@ -130,11 +130,11 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
await expect.element(input).toHaveValue('Max Mustermann');
|
await expect.element(input).toHaveValue('Max Mustermann');
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('option', { name: 'Max Mustermann' }))
|
.element(page.getByRole('button', { name: 'Max Mustermann' }))
|
||||||
.not.toBeInTheDocument();
|
.not.toBeInTheDocument();
|
||||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
||||||
});
|
});
|
||||||
@@ -145,7 +145,7 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
await tick();
|
await tick();
|
||||||
expect(hiddenInput('senderId')?.value).toBe('1');
|
expect(hiddenInput('senderId')?.value).toBe('1');
|
||||||
@@ -158,7 +158,7 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
expect(onchange).toHaveBeenCalledWith('1');
|
expect(onchange).toHaveBeenCalledWith('1');
|
||||||
});
|
});
|
||||||
@@ -177,7 +177,7 @@ describe('PersonTypeahead – selection', () => {
|
|||||||
const input = page.getByPlaceholder('Namen tippen...');
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
await input.fill('Ma');
|
await input.fill('Ma');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
await expect.element(input).toHaveValue('Max Mustermann');
|
await expect.element(input).toHaveValue('Max Mustermann');
|
||||||
});
|
});
|
||||||
@@ -194,7 +194,7 @@ describe('PersonTypeahead – clearing a selection', () => {
|
|||||||
|
|
||||||
await input.fill('Mu');
|
await input.fill('Mu');
|
||||||
await waitForDebounce();
|
await waitForDebounce();
|
||||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
expect(onchange).toHaveBeenCalledWith('1');
|
expect(onchange).toHaveBeenCalledWith('1');
|
||||||
onchange.mockClear();
|
onchange.mockClear();
|
||||||
@@ -285,194 +285,3 @@ describe('PersonTypeahead – click outside', () => {
|
|||||||
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── ARIA roles ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PersonTypeahead – ARIA roles', () => {
|
|
||||||
it('dropdown uses role="listbox" container and role="option" items', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
|
||||||
const input = page.getByPlaceholder('Namen tippen...');
|
|
||||||
await input.fill('Mu');
|
|
||||||
await waitForDebounce();
|
|
||||||
|
|
||||||
// Container must be a listbox
|
|
||||||
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Items must be options, not buttons
|
|
||||||
const options = page.getByRole('option');
|
|
||||||
await expect.element(options.first()).toBeInTheDocument();
|
|
||||||
await expect.element(page.getByRole('option', { name: 'Max Mustermann' })).toBeInTheDocument();
|
|
||||||
await expect.element(page.getByRole('option', { name: 'Anna Musterfrau' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('input has aria-expanded="false" when dropdown is closed', async () => {
|
|
||||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
|
||||||
const input = page.getByPlaceholder('Namen tippen...');
|
|
||||||
await expect.element(input).toHaveAttribute('aria-expanded', 'false');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('input has aria-expanded="true" when dropdown is open', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
|
||||||
const input = page.getByPlaceholder('Namen tippen...');
|
|
||||||
await input.fill('Mu');
|
|
||||||
await waitForDebounce();
|
|
||||||
await expect.element(input).toHaveAttribute('aria-expanded', 'true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('input has aria-controls pointing to the listbox id', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
|
||||||
const input = page.getByPlaceholder('Namen tippen...');
|
|
||||||
await input.fill('Mu');
|
|
||||||
await waitForDebounce();
|
|
||||||
|
|
||||||
const ariaControls = await input.element().getAttribute('aria-controls');
|
|
||||||
expect(ariaControls).toBeTruthy();
|
|
||||||
|
|
||||||
// The listbox with that id must exist
|
|
||||||
const listbox = document.getElementById(ariaControls!);
|
|
||||||
expect(listbox).not.toBeNull();
|
|
||||||
expect(listbox!.getAttribute('role')).toBe('listbox');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('input has aria-haspopup="listbox"', async () => {
|
|
||||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
|
||||||
const input = page.getByPlaceholder('Namen tippen...');
|
|
||||||
await expect.element(input).toHaveAttribute('aria-haspopup', 'listbox');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Keyboard navigation ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('PersonTypeahead – keyboard navigation', () => {
|
|
||||||
it('ArrowDown moves highlight to the first option', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
|
||||||
const input = page.getByPlaceholder('Namen tippen...');
|
|
||||||
await input.fill('Mu');
|
|
||||||
await waitForDebounce();
|
|
||||||
|
|
||||||
await input.click();
|
|
||||||
await userEvent.keyboard('{ArrowDown}');
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
// First option should be highlighted (aria-selected="true")
|
|
||||||
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
|
|
||||||
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ArrowDown then ArrowDown moves highlight to the second option', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
|
||||||
const input = page.getByPlaceholder('Namen tippen...');
|
|
||||||
await input.fill('Mu');
|
|
||||||
await waitForDebounce();
|
|
||||||
|
|
||||||
await input.click();
|
|
||||||
await userEvent.keyboard('{ArrowDown}');
|
|
||||||
await tick();
|
|
||||||
await userEvent.keyboard('{ArrowDown}');
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
const secondOption = page.getByRole('option', { name: 'Anna Musterfrau' });
|
|
||||||
await expect.element(secondOption).toHaveAttribute('aria-selected', 'true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ArrowUp from first wraps to last option', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
|
||||||
const input = page.getByPlaceholder('Namen tippen...');
|
|
||||||
await input.fill('Mu');
|
|
||||||
await waitForDebounce();
|
|
||||||
|
|
||||||
await input.click();
|
|
||||||
await userEvent.keyboard('{ArrowDown}'); // highlight first
|
|
||||||
await tick();
|
|
||||||
await userEvent.keyboard('{ArrowUp}'); // wrap to last
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
const lastOption = page.getByRole('option', { name: 'Anna Musterfrau' });
|
|
||||||
await expect.element(lastOption).toHaveAttribute('aria-selected', 'true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ArrowDown from last wraps to first option', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
|
||||||
const input = page.getByPlaceholder('Namen tippen...');
|
|
||||||
await input.fill('Mu');
|
|
||||||
await waitForDebounce();
|
|
||||||
|
|
||||||
await input.click();
|
|
||||||
await userEvent.keyboard('{ArrowDown}'); // highlight first (index 0)
|
|
||||||
await tick();
|
|
||||||
await userEvent.keyboard('{ArrowDown}'); // highlight second (index 1 = last)
|
|
||||||
await tick();
|
|
||||||
await userEvent.keyboard('{ArrowDown}'); // wrap to first (index 0)
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
|
|
||||||
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Enter selects the highlighted option', async () => {
|
|
||||||
mockFetchWithPersons([
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
firstName: 'Max',
|
|
||||||
lastName: 'Mustermann',
|
|
||||||
displayName: 'Max Mustermann',
|
|
||||||
personType: 'PERSON'
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
|
||||||
const input = page.getByPlaceholder('Namen tippen...');
|
|
||||||
await input.fill('Ma');
|
|
||||||
await waitForDebounce();
|
|
||||||
|
|
||||||
await input.click();
|
|
||||||
await userEvent.keyboard('{ArrowDown}');
|
|
||||||
await tick();
|
|
||||||
await userEvent.keyboard('{Enter}');
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
await expect.element(input).toHaveValue('Max Mustermann');
|
|
||||||
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Escape closes the dropdown without selecting', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
|
||||||
const input = page.getByPlaceholder('Namen tippen...');
|
|
||||||
await input.fill('Mu');
|
|
||||||
await waitForDebounce();
|
|
||||||
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
|
|
||||||
|
|
||||||
await input.click();
|
|
||||||
await userEvent.keyboard('{Escape}');
|
|
||||||
await tick();
|
|
||||||
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
|
||||||
// Value unchanged — nothing was selected
|
|
||||||
await expect.element(input).toHaveValue('Mu');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('active option id is set as aria-activedescendant on the input', async () => {
|
|
||||||
mockFetchWithPersons();
|
|
||||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
|
||||||
const input = page.getByPlaceholder('Namen tippen...');
|
|
||||||
await input.fill('Mu');
|
|
||||||
await waitForDebounce();
|
|
||||||
|
|
||||||
// No active option before pressing ArrowDown
|
|
||||||
const beforeNav = await input.element().getAttribute('aria-activedescendant');
|
|
||||||
expect(beforeNav).toBeFalsy();
|
|
||||||
|
|
||||||
await input.click();
|
|
||||||
await userEvent.keyboard('{ArrowDown}');
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
const afterNav = await input.element().getAttribute('aria-activedescendant');
|
|
||||||
expect(afterNav).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -36,7 +36,7 @@ let { docs, weeklyCount }: Props = $props();
|
|||||||
{#each docs as doc (doc.id)}
|
{#each docs as doc (doc.id)}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/documents/{doc.id}?task=transcribe"
|
href="/documents/{doc.id}"
|
||||||
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
<span class="font-serif text-sm text-ink">{doc.title}</span>
|
<span class="font-serif text-sm text-ink">{doc.title}</span>
|
||||||
|
|||||||
@@ -56,12 +56,12 @@ describe('SegmentationColumn', () => {
|
|||||||
await expect.element(page.getByText(/diese Woche/)).not.toBeInTheDocument();
|
await expect.element(page.getByText(/diese Woche/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('links to /documents/{id}?task=transcribe', async () => {
|
it('links to /documents/{id}', async () => {
|
||||||
const doc = makeDoc({ id: 'abc-123', title: 'Verlinktes Dokument' });
|
const doc = makeDoc({ id: 'abc-123', title: 'Verlinktes Dokument' });
|
||||||
|
|
||||||
render(SegmentationColumn, { props: { docs: [doc], weeklyCount: 0 } });
|
render(SegmentationColumn, { props: { docs: [doc], weeklyCount: 0 } });
|
||||||
|
|
||||||
const link = page.getByRole('link', { name: /Verlinktes Dokument/ });
|
const link = page.getByRole('link', { name: /Verlinktes Dokument/ });
|
||||||
await expect.element(link).toHaveAttribute('href', '/documents/abc-123?task=transcribe');
|
await expect.element(link).toHaveAttribute('href', '/documents/abc-123');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user