Compare commits
218 Commits
feat/issue
...
d4f666e981
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4f666e981 | ||
|
|
678d9dab38 | ||
|
|
42cf078bb6 | ||
|
|
32e4e30e40 | ||
|
|
dd9c4d57ee | ||
|
|
aae005d5e6 | ||
|
|
9b6d8fbef1 | ||
|
|
4f3020ffab | ||
|
|
34ab8a0a2c | ||
|
|
96d023a7cb | ||
|
|
0802889ea9 | ||
|
|
2ae830a3c8 | ||
|
|
c23fad7dc8 | ||
|
|
11c0d49907 | ||
|
|
da249369ee | ||
|
|
74b13abf53 | ||
|
|
ad535e314b | ||
|
|
18e5d18cc7 | ||
|
|
35ec7e799f | ||
|
|
77ac9a01b5 | ||
|
|
b698f9f223 | ||
|
|
ed270f68e1 | ||
|
|
fe1014a08a | ||
|
|
9e6efacbcb | ||
|
|
ab3e633a0c | ||
|
|
b381b2078a | ||
|
|
9e7861fa03 | ||
|
|
afd6d0b20d | ||
|
|
e5024fc804 | ||
|
|
9fc96a15cf | ||
|
|
08d96e5b0f | ||
|
|
b7a2f6c2fe | ||
|
|
b944ae9510 | ||
|
|
71b249bf31 | ||
|
|
f662bd870e | ||
|
|
db66d0cc61 | ||
|
|
7dc5dc6f71 | ||
|
|
d974d39d17 | ||
|
|
5e4e487d5f | ||
|
|
b3fe9b1171 | ||
|
|
3c7c7a9aa4 | ||
|
|
9908f7afdc | ||
|
|
96d9ff5db1 | ||
|
|
0113367d05 | ||
|
|
fb6bffd7ee | ||
|
|
b087de84c4 | ||
|
|
3e07f6798c | ||
|
|
bc0824b934 | ||
|
|
7ccd541d40 | ||
|
|
835dc77382 | ||
|
|
37edac4da6 | ||
|
|
49443ad16a | ||
|
|
e6844c403c | ||
|
|
f1932fd5f6 | ||
|
|
ba88febc77 | ||
|
|
fa7b97acdc | ||
|
|
6ef888a128 | ||
|
|
94d0733412 | ||
|
|
4ac94b2feb | ||
|
|
392af640c4 | ||
|
|
7a25feb04e | ||
|
|
d87ad36278 | ||
|
|
39ddf90725 | ||
|
|
e5634c301e | ||
|
|
68cb6e9b76 | ||
|
|
5591f95871 | ||
|
|
41a57c0dc8 | ||
|
|
2d19ca7244 | ||
|
|
bc58d77f2c | ||
|
|
515fa03088 | ||
|
|
060a1149e0 | ||
|
|
558e1e6b22 | ||
|
|
6dd60571e3 | ||
|
|
3365f5845e | ||
|
|
3faac13533 | ||
|
|
5890bb3abd | ||
|
|
060db69108 | ||
|
|
1842e23c81 | ||
|
|
26519d029a | ||
|
|
488d4384a1 | ||
|
|
6a6967d841 | ||
|
|
ae868f4110 | ||
|
|
1fd38830fe | ||
|
|
c9c395eb59 | ||
|
|
c247e1e971 | ||
|
|
eb6e21f032 | ||
|
|
b4b46a0a79 | ||
|
|
ba73387d50 | ||
|
|
d9c7abf2ab | ||
|
|
7fc56022ae | ||
|
|
e8ba840560 | ||
|
|
09f71a2dce | ||
|
|
86ad5ca9b3 | ||
|
|
780c682136 | ||
|
|
a8a3b7f574 | ||
|
|
f0bb1c3163 | ||
|
|
cacbd57752 | ||
|
|
43aacd9f60 | ||
|
|
362a84dde9 | ||
|
|
49db82e1bd | ||
|
|
fd3a44d10c | ||
|
|
cb51e8e432 | ||
|
|
bbde9e8497 | ||
|
|
793496440c | ||
|
|
e3175f493c | ||
|
|
64a61f705c | ||
|
|
e50aab2578 | ||
|
|
02d3e2ab61 | ||
|
|
c4ee2c666b | ||
|
|
bf8fb00dd2 | ||
|
|
b3ce15f0dd | ||
|
|
c7013f4902 | ||
|
|
091f6c7592 | ||
|
|
3a6f90441e | ||
|
|
13e0801b30 | ||
|
|
4c3aa159c5 | ||
|
|
eb51155b4e | ||
|
|
43f474fc5b | ||
|
|
8ca3f37817 | ||
|
|
1dc812bd47 | ||
|
|
7a647b5633 | ||
|
|
5f76d4a1ac | ||
|
|
c7958681f5 | ||
|
|
1f3f879f9c | ||
|
|
7906373053 | ||
|
|
2d48821f95 | ||
|
|
0def9e9b9d | ||
|
|
acffcc8516 | ||
|
|
48492330a7 | ||
|
|
d924d9059c | ||
|
|
99aee777de | ||
|
|
8b498665df | ||
|
|
5ebe1f1a5a | ||
|
|
221a6af838 | ||
|
|
404d874b4e | ||
|
|
4bc4267e5a | ||
|
|
bd17532118 | ||
|
|
e021261300 | ||
|
|
e94ffde075 | ||
|
|
29a1df5d9c | ||
|
|
4d288589fa | ||
|
|
a2c633c5de | ||
|
|
28112e1d7b | ||
|
|
08e7987033 | ||
|
|
1db0f38f62 | ||
|
|
4e8df66a79 | ||
|
|
80ddfb47ac | ||
|
|
7805da52e6 | ||
|
|
0f3e000379 | ||
|
|
b435fd69f7 | ||
|
|
a6c8db226d | ||
|
|
e833d1f71a | ||
|
|
5d82a3e471 | ||
|
|
cb93f55396 | ||
|
|
3cfaae06da | ||
|
|
a81323a7a1 | ||
|
|
10b1bab57b | ||
|
|
000333d540 | ||
|
|
5817a79151 | ||
|
|
3b430828b7 | ||
|
|
f8aa8c6574 | ||
|
|
ce005622f2 | ||
|
|
0e9fa157e5 | ||
|
|
fa1dfbc99d | ||
|
|
eb91639a5e | ||
|
|
43fb51305e | ||
|
|
6babcc7f17 | ||
|
|
1754b96b18 | ||
|
|
d230156651 | ||
|
|
93f4a00032 | ||
|
|
ea97bdd869 | ||
|
|
cbaff016d0 | ||
|
|
0b3455dbb2 | ||
|
|
499d0a3ca8 | ||
|
|
bd3feda182 | ||
|
|
f2127e2814 | ||
|
|
13bb3b451e | ||
|
|
6074ac396f | ||
|
|
b6253cb023 | ||
|
|
e94e9a3573 | ||
|
|
06ecad5e74 | ||
|
|
fcfae8fb78 | ||
|
|
83de7ff673 | ||
|
|
48649e67f9 | ||
|
|
1d14c32c23 | ||
|
|
d27fed3c35 | ||
|
|
22752ac1ae | ||
|
|
7a3d919c2d | ||
|
|
b969bcd877 | ||
|
|
cd26057ea5 | ||
|
|
ccbcbca0e8 | ||
|
|
c40cc05f68 | ||
|
|
a021355072 | ||
|
|
8971fee75e | ||
|
|
48a704f002 | ||
|
|
a7b1dcb5e1 | ||
|
|
f382bd9974 | ||
|
|
d7f4f6f163 | ||
|
|
242e10179d | ||
|
|
aaf885cafd | ||
|
|
b658a13247 | ||
|
|
6bed617959 | ||
|
|
51db976348 | ||
|
|
fc46704144 | ||
|
|
050f2bc929 | ||
|
|
f29f4d3f5b | ||
|
|
790c6f5b02 | ||
|
|
acea4a60f2 | ||
|
|
25f62ce93b | ||
|
|
df6175ed2c | ||
| f6cf2e0e42 | |||
|
|
33ca2df45b | ||
|
|
0979302205 | ||
|
|
9fb2c025cf | ||
|
|
ee2de8135b | ||
|
|
fe13df574a | ||
|
|
a9080e9dab | ||
|
|
e8a1cc82ff |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -13,3 +13,7 @@ scripts/large-data.sql
|
||||
.vitest-attachments
|
||||
**/test-results/
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
|
||||
# Repo uses npm; yarn.lock is ignored to avoid double-lockfile drift.
|
||||
frontend/yarn.lock
|
||||
|
||||
3
backend/api_tests/Transcription.http
Normal file
3
backend/api_tests/Transcription.http
Normal file
@@ -0,0 +1,3 @@
|
||||
### Mark all blocks as reviewed
|
||||
PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all
|
||||
Authorization: Basic admin admin123
|
||||
@@ -177,6 +177,13 @@
|
||||
<artifactId>imageio-tiff</artifactId>
|
||||
<version>3.12.0</version>
|
||||
</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>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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,6 +6,7 @@ import jakarta.validation.ConstraintViolationException;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
@@ -47,6 +48,12 @@ public class GlobalExceptionHandler {
|
||||
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ResponseEntity<ErrorResponse> handleMessageNotReadable(HttpMessageNotReadableException ex) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "Invalid request body"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
||||
return ResponseEntity.status(ex.getStatusCode())
|
||||
|
||||
@@ -34,11 +34,13 @@ public class PersonController {
|
||||
private final DocumentService documentService;
|
||||
|
||||
@GetMapping
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
|
||||
return ResponseEntity.ok(personService.findAll(q));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public Person getPerson(@PathVariable UUID id) {
|
||||
return personService.getById(id);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
|
||||
@@ -45,7 +46,7 @@ public class TranscriptionBlockController {
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public TranscriptionBlock createBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@RequestBody CreateTranscriptionBlockDTO dto,
|
||||
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
|
||||
Authentication authentication) {
|
||||
UUID userId = requireUserId(authentication);
|
||||
return transcriptionService.createBlock(documentId, dto, userId);
|
||||
@@ -56,7 +57,7 @@ public class TranscriptionBlockController {
|
||||
public TranscriptionBlock updateBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID blockId,
|
||||
@RequestBody UpdateTranscriptionBlockDTO dto,
|
||||
@Valid @RequestBody UpdateTranscriptionBlockDTO dto,
|
||||
Authentication authentication) {
|
||||
UUID userId = requireUserId(authentication);
|
||||
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
|
||||
@@ -90,6 +91,15 @@ public class TranscriptionBlockController {
|
||||
return transcriptionService.reviewBlock(documentId, blockId, userId);
|
||||
}
|
||||
|
||||
@PutMapping("/review-all")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public List<TranscriptionBlock> markAllBlocksReviewed(
|
||||
@PathVariable UUID documentId,
|
||||
Authentication authentication) {
|
||||
UUID userId = requireUserId(authentication);
|
||||
return transcriptionService.markAllBlocksReviewed(documentId, userId);
|
||||
}
|
||||
|
||||
@GetMapping("/{blockId}/history")
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public List<TranscriptionBlockVersion> getBlockHistory(
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.raddatz.familienarchiv.model.PersonMention;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class CreateTranscriptionBlockDTO {
|
||||
@Min(0)
|
||||
private int pageNumber;
|
||||
@@ -22,4 +29,8 @@ public class CreateTranscriptionBlockDTO {
|
||||
private double height;
|
||||
private String text;
|
||||
private String label;
|
||||
|
||||
@Valid
|
||||
@Builder.Default
|
||||
private List<PersonMention> mentionedPersons = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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,6 +17,7 @@ public interface PersonSummaryDTO {
|
||||
Integer getBirthYear();
|
||||
Integer getDeathYear();
|
||||
String getNotes();
|
||||
boolean isFamilyMember();
|
||||
long getDocumentCount();
|
||||
|
||||
default String getDisplayName() {
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.raddatz.familienarchiv.model.PersonMention;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class UpdateTranscriptionBlockDTO {
|
||||
private String text;
|
||||
private String label;
|
||||
|
||||
@Valid
|
||||
@Builder.Default
|
||||
private List<PersonMention> mentionedPersons = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ public enum ErrorCode {
|
||||
ALIAS_NOT_FOUND,
|
||||
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
||||
INVALID_PERSON_TYPE,
|
||||
|
||||
// --- Documents ---
|
||||
/** A document with the given ID does not exist. 404 */
|
||||
DOCUMENT_NOT_FOUND,
|
||||
@@ -96,6 +95,18 @@ public enum ErrorCode {
|
||||
/** Internal inconsistency: expected training run row was not found after creation. 500 */
|
||||
OCR_TRAINING_CONFLICT,
|
||||
|
||||
// --- Relationships (Stammbaum) ---
|
||||
/** A relationship row with the given ID does not exist. 404 */
|
||||
RELATIONSHIP_NOT_FOUND,
|
||||
/** Adding this relationship would create a cycle (e.g. reverse PARENT_OF already exists). 409 */
|
||||
CIRCULAR_RELATIONSHIP,
|
||||
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
|
||||
DUPLICATE_RELATIONSHIP,
|
||||
|
||||
// --- 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 ---
|
||||
/** A tag with the given ID does not exist. 404 */
|
||||
TAG_NOT_FOUND,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
public enum GeschichteStatus {
|
||||
DRAFT,
|
||||
PUBLISHED
|
||||
}
|
||||
@@ -47,6 +47,11 @@ public class Person {
|
||||
private Integer birthYear;
|
||||
private Integer deathYear;
|
||||
|
||||
@Column(name = "family_member", nullable = false)
|
||||
@Builder.Default
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private boolean familyMember = false;
|
||||
|
||||
// Entity-graph navigation for JPA JOIN queries (e.g. DocumentSpecifications.hasText).
|
||||
// Uses entity relationship rather than cross-domain repository access, avoiding a
|
||||
// separate DB roundtrip while respecting domain boundaries.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Embeddable;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Embeddable
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PersonMention {
|
||||
|
||||
@NotNull
|
||||
@Column(name = "person_id", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID personId;
|
||||
|
||||
@NotNull
|
||||
@Size(max = 200)
|
||||
@Column(name = "display_name", nullable = false, length = 200)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
// Archival: the text the transcriber typed after @. Never updated on person rename.
|
||||
private String displayName;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@@ -33,6 +35,16 @@ public class TranscriptionBlock {
|
||||
@Column(columnDefinition = "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)
|
||||
private String label;
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.raddatz.familienarchiv.relationship;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "person_relationships")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@ToString(exclude = "notes")
|
||||
public class PersonRelationship {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "person_id", nullable = false)
|
||||
@JsonIgnore
|
||||
private Person person;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "related_person_id", nullable = false)
|
||||
@JsonIgnore
|
||||
private Person relatedPerson;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "relation_type", nullable = false, length = 30)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private RelationType relationType;
|
||||
|
||||
@Column(name = "from_year")
|
||||
private Integer fromYear;
|
||||
|
||||
@Column(name = "to_year")
|
||||
private Integer toYear;
|
||||
|
||||
@Column(length = 2000)
|
||||
private String notes;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false, nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Instant createdAt;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.raddatz.familienarchiv.relationship;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface PersonRelationshipRepository extends JpaRepository<PersonRelationship, UUID> {
|
||||
|
||||
/**
|
||||
* Bulk fetch for the network endpoint — pulls only edges of the given types.
|
||||
* The service filters by family_member afterwards.
|
||||
*/
|
||||
@Query("SELECT r FROM PersonRelationship r " +
|
||||
"JOIN FETCH r.person " +
|
||||
"JOIN FETCH r.relatedPerson " +
|
||||
"WHERE r.relationType IN :types")
|
||||
List<PersonRelationship> findAllByRelationTypeIn(@Param("types") Collection<RelationType> types);
|
||||
|
||||
/** Used for the circular-PARENT_OF check in {@code addRelationship}. */
|
||||
boolean existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||
UUID personId, UUID relatedPersonId, RelationType relationType);
|
||||
|
||||
/**
|
||||
* All edges incident on {@code personId} (either side) restricted to the given types.
|
||||
* Used by the inference service to load a person's local subgraph for BFS.
|
||||
*/
|
||||
@Query("SELECT r FROM PersonRelationship r " +
|
||||
"WHERE (r.person.id = :personId OR r.relatedPerson.id = :personId) " +
|
||||
"AND r.relationType IN :types")
|
||||
List<PersonRelationship> findAllByPersonIdOrRelatedPersonIdAndRelationTypeIn(
|
||||
@Param("personId") UUID personId,
|
||||
@Param("types") Collection<RelationType> types);
|
||||
|
||||
/**
|
||||
* All edges incident on {@code personId} (either side), all types.
|
||||
* Used by the "direct relationships" listings (person edit, side panel).
|
||||
*/
|
||||
@Query("SELECT r FROM PersonRelationship r " +
|
||||
"JOIN FETCH r.person " +
|
||||
"JOIN FETCH r.relatedPerson " +
|
||||
"WHERE r.person.id = :personId OR r.relatedPerson.id = :personId")
|
||||
List<PersonRelationship> findAllByPersonIdOrRelatedPersonId(@Param("personId") UUID personId);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.raddatz.familienarchiv.relationship;
|
||||
|
||||
/**
|
||||
* Abstract direction tokens emitted by the BFS in {@link RelationshipInferenceService}.
|
||||
* A path is a list of these tokens — e.g. niece-of-me is {@code [SIBLING, DOWN]}.
|
||||
*
|
||||
* <p>Reversing a path swaps {@link #UP} ↔ {@link #DOWN} and leaves the symmetric
|
||||
* tokens ({@link #SPOUSE}, {@link #SIBLING}) untouched.
|
||||
*/
|
||||
public enum RelationToken {
|
||||
UP,
|
||||
DOWN,
|
||||
SPOUSE,
|
||||
SIBLING;
|
||||
|
||||
public RelationToken reverse() {
|
||||
return switch (this) {
|
||||
case UP -> DOWN;
|
||||
case DOWN -> UP;
|
||||
case SPOUSE -> SPOUSE;
|
||||
case SIBLING -> SIBLING;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.raddatz.familienarchiv.relationship;
|
||||
|
||||
/**
|
||||
* Family-network relationship taxonomy.
|
||||
*
|
||||
* <p>Symmetric types ({@link #SPOUSE_OF}, {@link #SIBLING_OF}) are stored once;
|
||||
* the inference service walks them in both directions. {@link #PARENT_OF} is
|
||||
* directional: A PARENT_OF B means A is the parent.
|
||||
*/
|
||||
public enum RelationType {
|
||||
PARENT_OF,
|
||||
SPOUSE_OF,
|
||||
SIBLING_OF,
|
||||
FRIEND,
|
||||
COLLEAGUE,
|
||||
EMPLOYER,
|
||||
DOCTOR,
|
||||
NEIGHBOR,
|
||||
OTHER
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.raddatz.familienarchiv.relationship;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
|
||||
import org.raddatz.familienarchiv.relationship.dto.FamilyMemberPatchDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Stammbaum API. Endpoints split across two roots:
|
||||
* <ul>
|
||||
* <li>{@code /api/network} — the family graph</li>
|
||||
* <li>{@code /api/persons/{id}/...} — per-person relationship operations
|
||||
* (PersonController is intentionally left untouched)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class RelationshipController {
|
||||
|
||||
private final RelationshipService relationshipService;
|
||||
|
||||
// READ endpoints carry no @RequirePermission: all authenticated users may read the family graph.
|
||||
// Unauthenticated requests are rejected by Spring Security's anyRequest().authenticated() rule.
|
||||
|
||||
@GetMapping("/api/network")
|
||||
public NetworkDTO getNetwork() {
|
||||
return relationshipService.getFamilyNetwork();
|
||||
}
|
||||
|
||||
@GetMapping("/api/persons/{id}/relationships")
|
||||
public List<RelationshipDTO> getRelationships(@PathVariable UUID id) {
|
||||
return relationshipService.getRelationships(id);
|
||||
}
|
||||
|
||||
@GetMapping("/api/persons/{id}/inferred-relationships")
|
||||
public List<InferredRelationshipWithPersonDTO> getInferredRelationships(@PathVariable UUID id) {
|
||||
return relationshipService.getInferredRelationships(id);
|
||||
}
|
||||
|
||||
@GetMapping("/api/persons/{aId}/relationship-to/{bId}")
|
||||
public InferredRelationshipDTO getRelationshipBetween(@PathVariable UUID aId, @PathVariable UUID bId) {
|
||||
return relationshipService.getRelationshipBetween(aId, bId)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.RELATIONSHIP_NOT_FOUND, "No relationship path between " + aId + " and " + bId));
|
||||
}
|
||||
|
||||
@PostMapping("/api/persons/{id}/relationships")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public ResponseEntity<RelationshipDTO> addRelationship(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody CreateRelationshipRequest dto) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(relationshipService.addRelationship(id, dto));
|
||||
}
|
||||
|
||||
@DeleteMapping("/api/persons/{id}/relationships/{relId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public void deleteRelationship(@PathVariable UUID id, @PathVariable UUID relId) {
|
||||
relationshipService.deleteRelationship(id, relId);
|
||||
}
|
||||
|
||||
@PatchMapping("/api/persons/{id}/family-member")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public Person patchFamilyMember(@PathVariable UUID id, @RequestBody FamilyMemberPatchDTO dto) {
|
||||
return relationshipService.setFamilyMember(id, dto.familyMember());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package org.raddatz.familienarchiv.relationship;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
|
||||
import org.raddatz.familienarchiv.service.PersonService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Derives indirect family relationships by BFS over the family-graph subset
|
||||
* (PARENT_OF, SPOUSE_OF, SIBLING_OF). Time-ignorant: from_year / to_year are
|
||||
* not consulted. Siblings are also derived from shared parents — no SIBLING_OF
|
||||
* row is required.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RelationshipInferenceService {
|
||||
|
||||
// 8 hops covers great-grandparents ↔ great-great-grandchildren and second cousins —
|
||||
// the practical horizon for a 1899–1950 family archive. Paths longer than this are
|
||||
// classified as LABEL_DISTANT and rarely carry meaningful relationship labels.
|
||||
static final int MAX_DEPTH = 8;
|
||||
|
||||
/** "distant" is the catch-all label for paths that do not match the LABEL_MAP. */
|
||||
static final String LABEL_DISTANT = "distant";
|
||||
|
||||
private static final Map<List<RelationToken>, String> LABEL_MAP = buildLabelMap();
|
||||
|
||||
private final PersonRelationshipRepository relationshipRepository;
|
||||
private final PersonService personService;
|
||||
|
||||
private static Map<List<RelationToken>, String> buildLabelMap() {
|
||||
Map<List<RelationToken>, String> m = new HashMap<>();
|
||||
m.put(List.of(RelationToken.UP), "parent");
|
||||
m.put(List.of(RelationToken.DOWN), "child");
|
||||
m.put(List.of(RelationToken.SPOUSE), "spouse");
|
||||
m.put(List.of(RelationToken.SIBLING), "sibling");
|
||||
m.put(List.of(RelationToken.UP, RelationToken.UP), "grandparent");
|
||||
m.put(List.of(RelationToken.DOWN, RelationToken.DOWN), "grandchild");
|
||||
m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.UP), "great_grandparent");
|
||||
m.put(List.of(RelationToken.DOWN, RelationToken.DOWN, RelationToken.DOWN), "great_grandchild");
|
||||
m.put(List.of(RelationToken.UP, RelationToken.SIBLING), "uncle_aunt");
|
||||
m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN), "niece_nephew");
|
||||
m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.SIBLING), "great_uncle_aunt");
|
||||
m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN, RelationToken.DOWN), "great_niece_nephew");
|
||||
m.put(List.of(RelationToken.SPOUSE, RelationToken.UP), "inlaw_parent");
|
||||
m.put(List.of(RelationToken.DOWN, RelationToken.SPOUSE), "inlaw_child");
|
||||
m.put(List.of(RelationToken.SPOUSE, RelationToken.SIBLING), "sibling_inlaw");
|
||||
m.put(List.of(RelationToken.SIBLING, RelationToken.SPOUSE), "sibling_inlaw");
|
||||
m.put(List.of(RelationToken.UP, RelationToken.SIBLING, RelationToken.DOWN), "cousin_1");
|
||||
return Collections.unmodifiableMap(m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortest token path from {@code from} to {@code to}, or empty if unreachable
|
||||
* within {@link #MAX_DEPTH} hops. Package-private to permit direct path
|
||||
* assertions in unit tests.
|
||||
*/
|
||||
Optional<List<RelationToken>> findShortestPath(UUID from, UUID to) {
|
||||
if (from.equals(to)) return Optional.empty();
|
||||
Map<UUID, List<Edge>> adj = buildAdjacency();
|
||||
return bfs(adj, from, to);
|
||||
}
|
||||
|
||||
/** Two-sided label between A and B. {@code labelFromA} reads "B is my <labelFromA>". */
|
||||
public Optional<InferredRelationshipDTO> infer(UUID a, UUID b) {
|
||||
Optional<List<RelationToken>> aToB = findShortestPath(a, b);
|
||||
if (aToB.isEmpty()) return Optional.empty();
|
||||
List<RelationToken> path = aToB.get();
|
||||
return Optional.of(new InferredRelationshipDTO(
|
||||
labelFor(path),
|
||||
labelFor(reversePath(path)),
|
||||
path.size()));
|
||||
}
|
||||
|
||||
/** All persons reachable from {@code personId} within MAX_DEPTH, with their labels. */
|
||||
public List<InferredRelationshipWithPersonDTO> findAllFor(UUID personId) {
|
||||
Map<UUID, List<Edge>> adj = buildAdjacency();
|
||||
Map<UUID, List<RelationToken>> shortestPaths = bfsAll(adj, personId);
|
||||
shortestPaths.remove(personId);
|
||||
if (shortestPaths.isEmpty()) return List.of();
|
||||
|
||||
List<UUID> ids = new ArrayList<>(shortestPaths.keySet());
|
||||
Map<UUID, Person> byId = new HashMap<>();
|
||||
for (Person p : personService.getAllById(ids)) {
|
||||
byId.put(p.getId(), p);
|
||||
}
|
||||
|
||||
List<InferredRelationshipWithPersonDTO> out = new ArrayList<>();
|
||||
for (UUID id : ids) {
|
||||
Person p = byId.get(id);
|
||||
if (p == null) continue;
|
||||
List<RelationToken> path = shortestPaths.get(id);
|
||||
PersonNodeDTO node = new PersonNodeDTO(
|
||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), p.isFamilyMember());
|
||||
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
|
||||
}
|
||||
out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops)
|
||||
.thenComparing(d -> d.person().displayName()));
|
||||
return out;
|
||||
}
|
||||
|
||||
static String labelFor(List<RelationToken> path) {
|
||||
String specific = LABEL_MAP.get(path);
|
||||
return specific != null ? specific : LABEL_DISTANT;
|
||||
}
|
||||
|
||||
private static List<RelationToken> reversePath(List<RelationToken> path) {
|
||||
List<RelationToken> reversed = new ArrayList<>(path.size());
|
||||
for (int i = path.size() - 1; i >= 0; i--) {
|
||||
reversed.add(path.get(i).reverse());
|
||||
}
|
||||
return List.copyOf(reversed);
|
||||
}
|
||||
|
||||
private Map<UUID, List<Edge>> buildAdjacency() {
|
||||
List<PersonRelationship> edges = relationshipRepository.findAllByRelationTypeIn(
|
||||
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
|
||||
Map<UUID, List<Edge>> adj = new HashMap<>();
|
||||
Map<UUID, List<UUID>> parentToChildren = new HashMap<>();
|
||||
|
||||
for (PersonRelationship e : edges) {
|
||||
UUID a = e.getPerson().getId();
|
||||
UUID b = e.getRelatedPerson().getId();
|
||||
switch (e.getRelationType()) {
|
||||
case PARENT_OF -> {
|
||||
addEdge(adj, a, b, RelationToken.DOWN);
|
||||
addEdge(adj, b, a, RelationToken.UP);
|
||||
parentToChildren.computeIfAbsent(a, k -> new ArrayList<>()).add(b);
|
||||
}
|
||||
case SPOUSE_OF -> {
|
||||
addEdge(adj, a, b, RelationToken.SPOUSE);
|
||||
addEdge(adj, b, a, RelationToken.SPOUSE);
|
||||
}
|
||||
case SIBLING_OF -> {
|
||||
addEdge(adj, a, b, RelationToken.SIBLING);
|
||||
addEdge(adj, b, a, RelationToken.SIBLING);
|
||||
}
|
||||
default -> { /* family graph excludes other types */ }
|
||||
}
|
||||
}
|
||||
|
||||
for (List<UUID> children : parentToChildren.values()) {
|
||||
for (int i = 0; i < children.size(); i++) {
|
||||
for (int j = i + 1; j < children.size(); j++) {
|
||||
UUID c1 = children.get(i);
|
||||
UUID c2 = children.get(j);
|
||||
addEdge(adj, c1, c2, RelationToken.SIBLING);
|
||||
addEdge(adj, c2, c1, RelationToken.SIBLING);
|
||||
}
|
||||
}
|
||||
}
|
||||
return adj;
|
||||
}
|
||||
|
||||
private static void addEdge(Map<UUID, List<Edge>> adj, UUID from, UUID to, RelationToken token) {
|
||||
adj.computeIfAbsent(from, k -> new ArrayList<>()).add(new Edge(to, token));
|
||||
}
|
||||
|
||||
private static Optional<List<RelationToken>> bfs(Map<UUID, List<Edge>> adj, UUID from, UUID to) {
|
||||
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
|
||||
shortest.put(from, List.of());
|
||||
Deque<UUID> queue = new ArrayDeque<>();
|
||||
queue.add(from);
|
||||
while (!queue.isEmpty()) {
|
||||
UUID curr = queue.poll();
|
||||
List<RelationToken> currPath = shortest.get(curr);
|
||||
if (currPath.size() >= MAX_DEPTH) continue;
|
||||
for (Edge e : adj.getOrDefault(curr, List.of())) {
|
||||
if (shortest.containsKey(e.target())) continue;
|
||||
List<RelationToken> nextPath = append(currPath, e.token());
|
||||
shortest.put(e.target(), nextPath);
|
||||
if (e.target().equals(to)) return Optional.of(nextPath);
|
||||
queue.add(e.target());
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static Map<UUID, List<RelationToken>> bfsAll(Map<UUID, List<Edge>> adj, UUID from) {
|
||||
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
|
||||
shortest.put(from, List.of());
|
||||
Deque<UUID> queue = new ArrayDeque<>();
|
||||
queue.add(from);
|
||||
while (!queue.isEmpty()) {
|
||||
UUID curr = queue.poll();
|
||||
List<RelationToken> currPath = shortest.get(curr);
|
||||
if (currPath.size() >= MAX_DEPTH) continue;
|
||||
for (Edge e : adj.getOrDefault(curr, List.of())) {
|
||||
if (shortest.containsKey(e.target())) continue;
|
||||
List<RelationToken> nextPath = append(currPath, e.token());
|
||||
shortest.put(e.target(), nextPath);
|
||||
queue.add(e.target());
|
||||
}
|
||||
}
|
||||
return shortest;
|
||||
}
|
||||
|
||||
private static List<RelationToken> append(List<RelationToken> prefix, RelationToken next) {
|
||||
List<RelationToken> out = new ArrayList<>(prefix.size() + 1);
|
||||
out.addAll(prefix);
|
||||
out.add(next);
|
||||
return List.copyOf(out);
|
||||
}
|
||||
|
||||
private record Edge(UUID target, RelationToken token) {}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package org.raddatz.familienarchiv.relationship;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
|
||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
|
||||
import org.raddatz.familienarchiv.service.PersonService;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Owns the {@code person_relationships} table and the family_member flag.
|
||||
* Always orchestrates {@link PersonService} for cross-domain access — never
|
||||
* touches {@link org.raddatz.familienarchiv.repository.PersonRepository}.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RelationshipService {
|
||||
|
||||
private final PersonRelationshipRepository relationshipRepository;
|
||||
private final PersonService personService;
|
||||
private final RelationshipInferenceService inferenceService;
|
||||
|
||||
public List<RelationshipDTO> getRelationships(UUID personId) {
|
||||
personService.getById(personId);
|
||||
List<PersonRelationship> rels = relationshipRepository.findAllByPersonIdOrRelatedPersonId(personId);
|
||||
return rels.stream().map(RelationshipService::toDTO).toList();
|
||||
}
|
||||
|
||||
public List<InferredRelationshipWithPersonDTO> getInferredRelationships(UUID personId) {
|
||||
personService.getById(personId);
|
||||
return inferenceService.findAllFor(personId);
|
||||
}
|
||||
|
||||
public Optional<InferredRelationshipDTO> getRelationshipBetween(UUID a, UUID b) {
|
||||
personService.getById(a);
|
||||
personService.getById(b);
|
||||
return inferenceService.infer(a, b);
|
||||
}
|
||||
|
||||
public NetworkDTO getFamilyNetwork() {
|
||||
// Two queries: 1 for nodes (family members), 1 for edges (family-graph types).
|
||||
List<Person> familyMembers = personService.findAllFamilyMembers();
|
||||
Set<UUID> familyIds = new HashSet<>(familyMembers.size());
|
||||
List<PersonNodeDTO> nodes = new ArrayList<>(familyMembers.size());
|
||||
for (Person p : familyMembers) {
|
||||
familyIds.add(p.getId());
|
||||
nodes.add(new PersonNodeDTO(
|
||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), true));
|
||||
}
|
||||
|
||||
List<PersonRelationship> familyEdges = relationshipRepository.findAllByRelationTypeIn(
|
||||
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
|
||||
|
||||
List<RelationshipDTO> edges = new ArrayList<>();
|
||||
for (PersonRelationship r : familyEdges) {
|
||||
UUID p = r.getPerson().getId();
|
||||
UUID rp = r.getRelatedPerson().getId();
|
||||
if (familyIds.contains(p) && familyIds.contains(rp)) {
|
||||
edges.add(toDTO(r));
|
||||
}
|
||||
}
|
||||
return new NetworkDTO(nodes, edges);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
|
||||
if (personId.equals(dto.relatedPersonId())) {
|
||||
throw DomainException.badRequest(
|
||||
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
|
||||
}
|
||||
Person person = personService.getById(personId);
|
||||
Person relatedPerson = personService.getById(dto.relatedPersonId());
|
||||
|
||||
validateYears(dto.fromYear(), dto.toYear());
|
||||
|
||||
if (dto.relationType() == RelationType.PARENT_OF
|
||||
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||
relatedPerson.getId(), personId, RelationType.PARENT_OF)) {
|
||||
throw DomainException.conflict(
|
||||
ErrorCode.CIRCULAR_RELATIONSHIP,
|
||||
"Reverse PARENT_OF already exists between " + personId + " and " + relatedPerson.getId());
|
||||
}
|
||||
|
||||
PersonRelationship rel = PersonRelationship.builder()
|
||||
.person(person)
|
||||
.relatedPerson(relatedPerson)
|
||||
.relationType(dto.relationType())
|
||||
.fromYear(dto.fromYear())
|
||||
.toYear(dto.toYear())
|
||||
.notes(blankToNull(dto.notes()))
|
||||
.build();
|
||||
|
||||
try {
|
||||
// saveAndFlush so the unique_rel constraint violates synchronously and is
|
||||
// caught here, not at commit time outside the @Transactional boundary.
|
||||
return toDTO(relationshipRepository.saveAndFlush(rel));
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
throw DomainException.conflict(
|
||||
ErrorCode.DUPLICATE_RELATIONSHIP,
|
||||
"Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + dto.relationType() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteRelationship(UUID personId, UUID relId) {
|
||||
PersonRelationship rel = relationshipRepository.findById(relId)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
|
||||
|
||||
UUID storageSubject = rel.getPerson().getId();
|
||||
UUID storageObject = rel.getRelatedPerson().getId();
|
||||
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
|
||||
throw DomainException.forbidden(
|
||||
"Relationship " + relId + " does not belong to person " + personId);
|
||||
}
|
||||
relationshipRepository.delete(rel);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person setFamilyMember(UUID personId, boolean familyMember) {
|
||||
return personService.setFamilyMember(personId, familyMember);
|
||||
}
|
||||
|
||||
private static String blankToNull(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
|
||||
private static void validateYears(Integer fromYear, Integer toYear) {
|
||||
if (fromYear != null && toYear != null && toYear < fromYear) {
|
||||
throw DomainException.badRequest(
|
||||
ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear");
|
||||
}
|
||||
}
|
||||
|
||||
private static RelationshipDTO toDTO(PersonRelationship r) {
|
||||
Person p = r.getPerson();
|
||||
Person rp = r.getRelatedPerson();
|
||||
return new RelationshipDTO(
|
||||
r.getId(),
|
||||
p.getId(),
|
||||
rp.getId(),
|
||||
p.getDisplayName(),
|
||||
p.getBirthYear(),
|
||||
p.getDeathYear(),
|
||||
rp.getDisplayName(),
|
||||
rp.getBirthYear(),
|
||||
rp.getDeathYear(),
|
||||
r.getRelationType(),
|
||||
r.getFromYear(),
|
||||
r.getToYear(),
|
||||
r.getNotes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.raddatz.familienarchiv.relationship.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.raddatz.familienarchiv.relationship.RelationType;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record CreateRelationshipRequest(
|
||||
@NotNull UUID relatedPersonId,
|
||||
@NotNull RelationType relationType,
|
||||
Integer fromYear,
|
||||
Integer toYear,
|
||||
@Size(max = 2000) String notes
|
||||
) {}
|
||||
@@ -0,0 +1,4 @@
|
||||
package org.raddatz.familienarchiv.relationship.dto;
|
||||
|
||||
/** Body for {@code PATCH /api/persons/{id}/family-member}. */
|
||||
public record FamilyMemberPatchDTO(boolean familyMember) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.raddatz.familienarchiv.relationship.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
/**
|
||||
* Pairwise inferred relationship for the document badge.
|
||||
* {@code labelFromA} reads "Person B, from A's point of view" and vice-versa
|
||||
* (e.g. A=parent, B=child → labelFromA = "Sohn", labelFromB = "Vater").
|
||||
*/
|
||||
public record InferredRelationshipDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String labelFromA,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String labelFromB,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int hops
|
||||
) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.raddatz.familienarchiv.relationship.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
/**
|
||||
* Used by {@code GET /api/persons/{id}/inferred-relationships}: each entry
|
||||
* is a derived relationship to another family member, labelled from the
|
||||
* requesting person's perspective.
|
||||
*/
|
||||
public record InferredRelationshipWithPersonDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) PersonNodeDTO person,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String label,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int hops
|
||||
) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.raddatz.familienarchiv.relationship.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Payload for {@code GET /api/network}. Nodes are family members; edges are family-graph relationships. */
|
||||
public record NetworkDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<PersonNodeDTO> nodes,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<RelationshipDTO> edges
|
||||
) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.raddatz.familienarchiv.relationship.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Lightweight node for the Stammbaum tree and inferred-relationship payloads. */
|
||||
public record PersonNodeDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
|
||||
Integer birthYear,
|
||||
Integer deathYear,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean familyMember
|
||||
) {}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.raddatz.familienarchiv.relationship.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.relationship.RelationType;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Wire shape for one stored relationship row. Both sides include name + years
|
||||
* so the frontend can render the row from either perspective (e.g. on the
|
||||
* subject's page the row reads "Elternteil von [related]"; on the object's
|
||||
* page it reads "Kind von [person]").
|
||||
*
|
||||
* <p>Storage truth: {@code personId} is the {@code person_id} column,
|
||||
* {@code relatedPersonId} is the {@code related_person_id} column. The
|
||||
* frontend determines orientation by comparing against the viewpoint.
|
||||
*/
|
||||
public record RelationshipDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID personId,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID relatedPersonId,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String personDisplayName,
|
||||
Integer personBirthYear,
|
||||
Integer personDeathYear,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String relatedPersonDisplayName,
|
||||
Integer relatedPersonBirthYear,
|
||||
Integer relatedPersonDeathYear,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
|
||||
Integer fromYear,
|
||||
Integer toYear,
|
||||
String notes
|
||||
) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
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> {
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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,6 +26,9 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
// Hilfsmethode: Alle sortiert laden (für den leeren Status)
|
||||
List<Person> findAllByOrderByLastNameAscFirstNameAsc();
|
||||
|
||||
// Stammbaum-Knoten: alle Personen mit family_member = true.
|
||||
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||
|
||||
// Lookup by full alias string, used during ODS mass import
|
||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
||||
|
||||
@@ -38,6 +41,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
p.family_member AS familyMember,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||
FROM persons p
|
||||
@@ -50,6 +54,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
p.family_member AS familyMember,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||
FROM persons p
|
||||
@@ -58,7 +63,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes
|
||||
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member
|
||||
ORDER BY p.last_name ASC, p.first_name ASC
|
||||
""",
|
||||
nativeQuery = true)
|
||||
|
||||
@@ -29,6 +29,15 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
|
||||
|
||||
Optional<TranscriptionBlock> findByAnnotationId(UUID annotationId);
|
||||
|
||||
@Query("""
|
||||
SELECT DISTINCT b FROM TranscriptionBlock b
|
||||
JOIN FETCH b.mentionedPersons
|
||||
WHERE b.id IN (
|
||||
SELECT bb.id FROM TranscriptionBlock bb JOIN bb.mentionedPersons m WHERE m.personId = :personId
|
||||
)
|
||||
""")
|
||||
List<TranscriptionBlock> findByPersonIdWithMentionsFetched(@Param("personId") UUID personId);
|
||||
|
||||
void deleteByAnnotationId(UUID annotationId);
|
||||
|
||||
int countByDocumentId(UUID documentId);
|
||||
@@ -51,21 +60,25 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
|
||||
""")
|
||||
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("""
|
||||
SELECT COUNT(b) FROM TranscriptionBlock b
|
||||
JOIN Document d ON d.id = b.documentId
|
||||
WHERE b.source = 'MANUAL'
|
||||
AND d.sender.id = :personId
|
||||
AND d.scriptType = 'HANDWRITING_KURRENT'
|
||||
AND 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels
|
||||
""")
|
||||
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("""
|
||||
SELECT b FROM TranscriptionBlock b
|
||||
JOIN Document d ON d.id = b.documentId
|
||||
WHERE b.source = 'MANUAL'
|
||||
AND d.sender.id = :personId
|
||||
AND d.scriptType = 'HANDWRITING_KURRENT'
|
||||
AND 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels
|
||||
""")
|
||||
List<TranscriptionBlock> findManualKurrentBlocksByPerson(@Param("personId") UUID personId);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ public enum Permission {
|
||||
READ_ALL,
|
||||
WRITE_ALL,
|
||||
ANNOTATE_ALL,
|
||||
BLOG_WRITE,
|
||||
ADMIN,
|
||||
ADMIN_USER,
|
||||
ADMIN_TAG,
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
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,7 +1,6 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -58,6 +57,17 @@ public class PersonService {
|
||||
return personRepository.findAllById(ids);
|
||||
}
|
||||
|
||||
public List<Person> findAllFamilyMembers() {
|
||||
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person setFamilyMember(UUID personId, boolean familyMember) {
|
||||
Person person = getById(personId);
|
||||
person.setFamilyMember(familyMember);
|
||||
return personRepository.save(person);
|
||||
}
|
||||
|
||||
public Optional<Person> findByName(String firstName, String lastName) {
|
||||
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,8 @@ public class TranscriptionService {
|
||||
if (dto.getLabel() != null) {
|
||||
block.setLabel(dto.getLabel());
|
||||
}
|
||||
block.getMentionedPersons().clear();
|
||||
block.getMentionedPersons().addAll(dto.getMentionedPersons());
|
||||
block.setUpdatedBy(userId);
|
||||
|
||||
TranscriptionBlock saved = blockRepository.save(block);
|
||||
@@ -205,6 +207,18 @@ public class TranscriptionService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<TranscriptionBlock> markAllBlocksReviewed(UUID documentId, UUID userId) {
|
||||
List<TranscriptionBlock> blocks = blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
|
||||
for (TranscriptionBlock block : blocks) {
|
||||
if (!block.isReviewed()) {
|
||||
block.setReviewed(true);
|
||||
auditService.logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, documentId, null);
|
||||
}
|
||||
}
|
||||
return blockRepository.saveAll(blocks);
|
||||
}
|
||||
|
||||
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
|
||||
getBlock(documentId, blockId);
|
||||
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Family network: marks a Person as a tree node and stores typed relationships
|
||||
-- between two persons. The tree page (/stammbaum) only shows persons with
|
||||
-- family_member = TRUE. Symmetric types (SPOUSE_OF, SIBLING_OF) are stored once;
|
||||
-- the partial unique index keeps SIBLING_OF pairs from being duplicated in the
|
||||
-- reverse direction.
|
||||
|
||||
ALTER TABLE persons
|
||||
ADD COLUMN family_member BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE person_relationships (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
|
||||
related_person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
|
||||
relation_type VARCHAR(30) NOT NULL,
|
||||
from_year INTEGER,
|
||||
to_year INTEGER,
|
||||
notes VARCHAR(2000),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT no_self_rel CHECK (person_id <> related_person_id),
|
||||
CONSTRAINT unique_rel UNIQUE (person_id, related_person_id, relation_type)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_person_rel_person_id ON person_relationships(person_id);
|
||||
CREATE INDEX idx_person_rel_related_person_id ON person_relationships(related_person_id);
|
||||
|
||||
-- Symmetric SIBLING_OF: enforce only one row per unordered pair.
|
||||
CREATE UNIQUE INDEX unique_sibling_pair ON person_relationships (
|
||||
LEAST(person_id, related_person_id),
|
||||
GREATEST(person_id, related_person_id)
|
||||
) WHERE relation_type = 'SIBLING_OF';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Symmetric SPOUSE_OF: enforce only one row per unordered pair, mirroring the
|
||||
-- SIBLING_OF index added in V54.
|
||||
CREATE UNIQUE INDEX unique_spouse_pair ON person_relationships (
|
||||
LEAST(person_id, related_person_id),
|
||||
GREATEST(person_id, related_person_id)
|
||||
) WHERE relation_type = 'SPOUSE_OF';
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Sidecar table for @-mentions inside transcription_blocks.text.
|
||||
-- Each row is one (block_id, person_id, display_name) tuple emitted by the
|
||||
-- typeahead in the transcription editor. block.text contains the literal
|
||||
-- "@DisplayName" — the UUID lives only here so historical text stays clean.
|
||||
--
|
||||
-- Schema choice: child table via @ElementCollection (mirrors the established
|
||||
-- UserGroup.permissions / group_permissions pattern), NOT JSONB. The "show
|
||||
-- all blocks mentioning person X" query on the person detail page joins on
|
||||
-- the indexed person_id column — equally fast as JSONB GIN containment, no
|
||||
-- new dependency. document_comments.comment_mentions stays as a many-to-many
|
||||
-- to AppUser; the divergence is intentional: Person mentions need lazy
|
||||
-- degradation when a person is deleted (no FK), while user mentions don't.
|
||||
--
|
||||
-- No FK on person_id: when a Person is deleted we want @Auguste Raddatz to
|
||||
-- remain visible as plain unlinked text inside the transcription rather than
|
||||
-- vanishing or cascade-deleting the block.
|
||||
|
||||
CREATE TABLE transcription_block_mentioned_persons (
|
||||
block_id UUID NOT NULL REFERENCES transcription_blocks(id) ON DELETE CASCADE,
|
||||
person_id UUID NOT NULL,
|
||||
display_name VARCHAR(200) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tbmp_person_id ON transcription_block_mentioned_persons(person_id);
|
||||
CREATE INDEX idx_tbmp_block_id ON transcription_block_mentioned_persons(block_id);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Prevent duplicate sidecar rows for the same (block, person) pair.
|
||||
-- @ElementCollection uses DELETE+INSERT per update so normal JPA writes can't
|
||||
-- create duplicates, but a raw-SQL import or concurrent bypass of JPA could.
|
||||
ALTER TABLE transcription_block_mentioned_persons
|
||||
ADD CONSTRAINT uq_tbmp_block_person UNIQUE (block_id, person_id);
|
||||
@@ -0,0 +1,34 @@
|
||||
-- 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);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 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'
|
||||
);
|
||||
@@ -0,0 +1,237 @@
|
||||
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,6 +57,13 @@ class PersonControllerTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getPersons_returns403_whenMissingReadAllPermission() throws Exception {
|
||||
mockMvc.perform(get("/api/persons"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getPersons_returns200_withEmptyList() throws Exception {
|
||||
when(personService.findAll(null)).thenReturn(Collections.emptyList());
|
||||
mockMvc.perform(get("/api/persons"))
|
||||
@@ -64,7 +71,7 @@ class PersonControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getPersons_delegatesQueryParam_toService() throws Exception {
|
||||
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
|
||||
when(personService.findAll("Hans")).thenReturn(List.of(dto));
|
||||
@@ -85,6 +92,7 @@ class PersonControllerTest {
|
||||
public Integer getBirthYear() { return null; }
|
||||
public Integer getDeathYear() { return null; }
|
||||
public String getNotes() { return null; }
|
||||
public boolean isFamilyMember() { return false; }
|
||||
public long getDocumentCount() { return 0; }
|
||||
};
|
||||
}
|
||||
@@ -99,6 +107,13 @@ class PersonControllerTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getPerson_returns403_whenMissingReadAllPermission() throws Exception {
|
||||
mockMvc.perform(get("/api/persons/{id}", UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getPerson_returns200_whenFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build();
|
||||
|
||||
@@ -183,6 +183,36 @@ class TranscriptionBlockControllerTest {
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createBlock_returns400_whenMentionedPersonDisplayNameExceeds200Chars() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||
String longName = "A".repeat(201);
|
||||
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
||||
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
|
||||
+ "\",\"displayName\":\"" + longName + "\"}]}";
|
||||
|
||||
mockMvc.perform(post(URL_BASE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createBlock_returns400_whenMentionedPersonPersonIdIsNull() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
||||
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||
|
||||
mockMvc.perform(post(URL_BASE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
// ─── PUT /api/documents/{id}/transcription-blocks/{blockId} ─────────────
|
||||
|
||||
@Test
|
||||
@@ -221,6 +251,34 @@ class TranscriptionBlockControllerTest {
|
||||
.andExpect(jsonPath("$.label").value("Anrede"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updateBlock_returns400_whenMentionedPersonDisplayNameExceeds200Chars() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||
String longName = "A".repeat(201);
|
||||
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
|
||||
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
|
||||
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updateBlock_returns400_whenMentionedPersonPersonIdIsNull() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||
|
||||
mockMvc.perform(put(URL_BLOCK)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updateBlock_returns404_whenBlockDoesNotExist() throws Exception {
|
||||
@@ -380,4 +438,63 @@ class TranscriptionBlockControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.reviewed").value(true));
|
||||
}
|
||||
|
||||
// ─── PUT .../review-all ───────────────────────────────────────────────────
|
||||
|
||||
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void markAllBlocksReviewed_returns200_withAllReviewedBlocks_whenAuthorised() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||
TranscriptionBlock b1 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
|
||||
.text("Block 1").sortOrder(0).reviewed(true).build();
|
||||
TranscriptionBlock b2 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
|
||||
.text("Block 2").sortOrder(1).reviewed(true).build();
|
||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||
.thenReturn(List.of(b1, b2));
|
||||
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$[0].reviewed").value(true))
|
||||
.andExpect(jsonPath("$[1].reviewed").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void markAllBlocksReviewed_returns200_withEmptyList_whenNoBlocksExist() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$").isArray())
|
||||
.andExpect(jsonPath("$").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(null);
|
||||
|
||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package org.raddatz.familienarchiv.relationship;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(RelationshipController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class RelationshipControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean RelationshipService relationshipService;
|
||||
@MockitoBean UserService userService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
private static final UUID PERSON_ID = UUID.randomUUID();
|
||||
private static final UUID OTHER_ID = UUID.randomUUID();
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void getRelationshipBetween_returns404_with_RELATIONSHIP_NOT_FOUND_code_when_no_path() throws Exception {
|
||||
when(relationshipService.getRelationshipBetween(any(), any())).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(get("/api/persons/{aId}/relationship-to/{bId}", PERSON_ID, OTHER_ID))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.RELATIONSHIP_NOT_FOUND.name()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRelationships_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/persons/{id}/relationships", PERSON_ID))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getNetwork_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/network"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception {
|
||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception {
|
||||
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"familyMember\":true}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void getNetwork_returns200_with_NetworkDTO_for_authenticated_user() throws Exception {
|
||||
PersonNodeDTO node = new PersonNodeDTO(PERSON_ID, "Alice Müller", 1900, 1980, true);
|
||||
RelationshipDTO edge = new RelationshipDTO(
|
||||
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||
"Alice Müller", 1900, 1980,
|
||||
"Bob Müller", 1930, null,
|
||||
RelationType.PARENT_OF, null, null, null);
|
||||
when(relationshipService.getFamilyNetwork())
|
||||
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
|
||||
|
||||
mockMvc.perform(get("/api/network"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.nodes[0].displayName").value("Alice Müller"))
|
||||
.andExpect(jsonPath("$.edges[0].relationType").value("PARENT_OF"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void getInferredRelationships_returns200_with_list_for_authenticated_user() throws Exception {
|
||||
PersonNodeDTO relative = new PersonNodeDTO(OTHER_ID, "Bob Müller", 1930, null, true);
|
||||
InferredRelationshipWithPersonDTO inferred =
|
||||
new InferredRelationshipWithPersonDTO(relative, "Großvater", 2);
|
||||
when(relationshipService.getInferredRelationships(PERSON_ID))
|
||||
.thenReturn(List.of(inferred));
|
||||
|
||||
mockMvc.perform(get("/api/persons/{id}/inferred-relationships", PERSON_ID))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].label").value("Großvater"))
|
||||
.andExpect(jsonPath("$[0].hops").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||
void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
|
||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||
void addRelationship_returns201_with_RelationshipDTO_for_WRITE_ALL_user() throws Exception {
|
||||
RelationshipDTO created = new RelationshipDTO(
|
||||
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||
"Alice Müller", null, null,
|
||||
"Bob Müller", null, null,
|
||||
RelationType.PARENT_OF, null, null, null);
|
||||
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
||||
|
||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.relationType").value("PARENT_OF"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||
void deleteRelationship_returns204_for_WRITE_ALL_user() throws Exception {
|
||||
UUID relId = UUID.randomUUID();
|
||||
doNothing().when(relationshipService).deleteRelationship(any(), any());
|
||||
|
||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package org.raddatz.familienarchiv.relationship;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||
import org.raddatz.familienarchiv.service.PersonService;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static java.util.Collections.emptyList;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.raddatz.familienarchiv.relationship.RelationToken.*;
|
||||
import static org.raddatz.familienarchiv.relationship.RelationType.*;
|
||||
|
||||
/**
|
||||
* Felix Brandt — TDD red phase for RelationshipInferenceService.
|
||||
* <p>
|
||||
* 18 unit tests, one per LABEL_MAP entry plus one no-path case. Each setup wires
|
||||
* a small graph through the mocked repository and asserts the exact abstract
|
||||
* token sequence emitted by BFS — except {@code distant_label_for_long_chain}
|
||||
* which asserts the fallback label, and {@code returns_empty_when_no_path}
|
||||
* which asserts no result.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class RelationshipInferenceServiceTest {
|
||||
|
||||
@Mock PersonRelationshipRepository relationshipRepository;
|
||||
@Mock PersonService personService;
|
||||
@InjectMocks RelationshipInferenceService service;
|
||||
|
||||
// --- 1: parent ---
|
||||
@Test
|
||||
void parent_path_emits_UP() {
|
||||
Person parent = person();
|
||||
Person child = person();
|
||||
givenEdges(parentOf(parent, child));
|
||||
|
||||
assertThat(service.findShortestPath(child.getId(), parent.getId()))
|
||||
.hasValue(List.of(UP));
|
||||
}
|
||||
|
||||
// --- 2: child ---
|
||||
@Test
|
||||
void child_path_emits_DOWN() {
|
||||
Person parent = person();
|
||||
Person child = person();
|
||||
givenEdges(parentOf(parent, child));
|
||||
|
||||
assertThat(service.findShortestPath(parent.getId(), child.getId()))
|
||||
.hasValue(List.of(DOWN));
|
||||
}
|
||||
|
||||
// --- 3: spouse ---
|
||||
@Test
|
||||
void spouse_path_emits_SPOUSE() {
|
||||
Person a = person();
|
||||
Person b = person();
|
||||
givenEdges(spouseOf(a, b));
|
||||
|
||||
assertThat(service.findShortestPath(a.getId(), b.getId()))
|
||||
.hasValue(List.of(SPOUSE));
|
||||
}
|
||||
|
||||
// --- 4: sibling ---
|
||||
@Test
|
||||
void sibling_path_emits_SIBLING() {
|
||||
Person a = person();
|
||||
Person b = person();
|
||||
givenEdges(siblingOf(a, b));
|
||||
|
||||
assertThat(service.findShortestPath(a.getId(), b.getId()))
|
||||
.hasValue(List.of(SIBLING));
|
||||
}
|
||||
|
||||
// --- 5: grandparent (UP, UP) ---
|
||||
@Test
|
||||
void grandparent_path_emits_UP_UP() {
|
||||
Person grandparent = person();
|
||||
Person parent = person();
|
||||
Person grandchild = person();
|
||||
givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
|
||||
|
||||
assertThat(service.findShortestPath(grandchild.getId(), grandparent.getId()))
|
||||
.hasValue(List.of(UP, UP));
|
||||
}
|
||||
|
||||
// --- 6: grandchild (DOWN, DOWN) ---
|
||||
@Test
|
||||
void grandchild_path_emits_DOWN_DOWN() {
|
||||
Person grandparent = person();
|
||||
Person parent = person();
|
||||
Person grandchild = person();
|
||||
givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
|
||||
|
||||
assertThat(service.findShortestPath(grandparent.getId(), grandchild.getId()))
|
||||
.hasValue(List.of(DOWN, DOWN));
|
||||
}
|
||||
|
||||
// --- 7: great-grandparent (UP, UP, UP) ---
|
||||
@Test
|
||||
void great_grandparent_path_emits_UP_UP_UP() {
|
||||
Person g = person();
|
||||
Person p = person();
|
||||
Person c = person();
|
||||
Person gc = person();
|
||||
givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
|
||||
|
||||
assertThat(service.findShortestPath(gc.getId(), g.getId()))
|
||||
.hasValue(List.of(UP, UP, UP));
|
||||
}
|
||||
|
||||
// --- 8: great-grandchild (DOWN, DOWN, DOWN) ---
|
||||
@Test
|
||||
void great_grandchild_path_emits_DOWN_DOWN_DOWN() {
|
||||
Person g = person();
|
||||
Person p = person();
|
||||
Person c = person();
|
||||
Person gc = person();
|
||||
givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
|
||||
|
||||
assertThat(service.findShortestPath(g.getId(), gc.getId()))
|
||||
.hasValue(List.of(DOWN, DOWN, DOWN));
|
||||
}
|
||||
|
||||
// --- 9: uncle/aunt (UP, SIBLING) ---
|
||||
@Test
|
||||
void uncle_aunt_path_emits_UP_SIBLING() {
|
||||
Person grandparent = person();
|
||||
Person parent = person();
|
||||
Person uncle = person();
|
||||
Person me = person();
|
||||
// grandparent has two children: parent and uncle. me is parent's child.
|
||||
givenEdges(
|
||||
parentOf(grandparent, parent),
|
||||
parentOf(grandparent, uncle),
|
||||
parentOf(parent, me));
|
||||
|
||||
assertThat(service.findShortestPath(me.getId(), uncle.getId()))
|
||||
.hasValue(List.of(UP, SIBLING));
|
||||
}
|
||||
|
||||
// --- 10: niece/nephew (SIBLING, DOWN) ---
|
||||
@Test
|
||||
void niece_nephew_path_emits_SIBLING_DOWN() {
|
||||
Person grandparent = person();
|
||||
Person uncle = person();
|
||||
Person sibling = person();
|
||||
Person niece = person();
|
||||
// grandparent has uncle + sibling; sibling has niece.
|
||||
givenEdges(
|
||||
parentOf(grandparent, uncle),
|
||||
parentOf(grandparent, sibling),
|
||||
parentOf(sibling, niece));
|
||||
|
||||
assertThat(service.findShortestPath(uncle.getId(), niece.getId()))
|
||||
.hasValue(List.of(SIBLING, DOWN));
|
||||
}
|
||||
|
||||
// --- 11: great uncle/aunt (UP, UP, SIBLING) ---
|
||||
@Test
|
||||
void great_uncle_aunt_path_emits_UP_UP_SIBLING() {
|
||||
Person ggp = person();
|
||||
Person grandparent = person();
|
||||
Person greatUncle = person();
|
||||
Person parent = person();
|
||||
Person me = person();
|
||||
givenEdges(
|
||||
parentOf(ggp, grandparent),
|
||||
parentOf(ggp, greatUncle),
|
||||
parentOf(grandparent, parent),
|
||||
parentOf(parent, me));
|
||||
|
||||
assertThat(service.findShortestPath(me.getId(), greatUncle.getId()))
|
||||
.hasValue(List.of(UP, UP, SIBLING));
|
||||
}
|
||||
|
||||
// --- 12: great niece/nephew (SIBLING, DOWN, DOWN) ---
|
||||
@Test
|
||||
void great_niece_nephew_path_emits_SIBLING_DOWN_DOWN() {
|
||||
Person grandparent = person();
|
||||
Person sibling = person();
|
||||
Person greatUncle = person();
|
||||
Person niece = person();
|
||||
Person greatNiece = person();
|
||||
givenEdges(
|
||||
parentOf(grandparent, sibling),
|
||||
parentOf(grandparent, greatUncle),
|
||||
parentOf(sibling, niece),
|
||||
parentOf(niece, greatNiece));
|
||||
|
||||
assertThat(service.findShortestPath(greatUncle.getId(), greatNiece.getId()))
|
||||
.hasValue(List.of(SIBLING, DOWN, DOWN));
|
||||
}
|
||||
|
||||
// --- 13: parent-in-law (SPOUSE, UP) ---
|
||||
@Test
|
||||
void inlaw_parent_path_emits_SPOUSE_UP() {
|
||||
Person inlaw = person();
|
||||
Person spouse = person();
|
||||
Person me = person();
|
||||
givenEdges(
|
||||
parentOf(inlaw, spouse),
|
||||
spouseOf(me, spouse));
|
||||
|
||||
assertThat(service.findShortestPath(me.getId(), inlaw.getId()))
|
||||
.hasValue(List.of(SPOUSE, UP));
|
||||
}
|
||||
|
||||
// --- 14: child-in-law (DOWN, SPOUSE) ---
|
||||
@Test
|
||||
void inlaw_child_path_emits_DOWN_SPOUSE() {
|
||||
Person me = person();
|
||||
Person child = person();
|
||||
Person inlawChild = person();
|
||||
givenEdges(
|
||||
parentOf(me, child),
|
||||
spouseOf(child, inlawChild));
|
||||
|
||||
assertThat(service.findShortestPath(me.getId(), inlawChild.getId()))
|
||||
.hasValue(List.of(DOWN, SPOUSE));
|
||||
}
|
||||
|
||||
// --- 15: sibling-in-law via my spouse's sibling (SPOUSE, SIBLING) ---
|
||||
@Test
|
||||
void sibling_inlaw_via_spouse_emits_SPOUSE_SIBLING() {
|
||||
Person me = person();
|
||||
Person spouse = person();
|
||||
Person spouseSibling = person();
|
||||
givenEdges(
|
||||
spouseOf(me, spouse),
|
||||
siblingOf(spouse, spouseSibling));
|
||||
|
||||
assertThat(service.findShortestPath(me.getId(), spouseSibling.getId()))
|
||||
.hasValue(List.of(SPOUSE, SIBLING));
|
||||
}
|
||||
|
||||
// --- 16: cousin (UP, SIBLING, DOWN) ---
|
||||
@Test
|
||||
void cousin_1_path_emits_UP_SIBLING_DOWN() {
|
||||
Person ggp = person();
|
||||
Person parentMine = person();
|
||||
Person uncle = person();
|
||||
Person me = person();
|
||||
Person cousin = person();
|
||||
givenEdges(
|
||||
parentOf(ggp, parentMine),
|
||||
parentOf(ggp, uncle),
|
||||
parentOf(parentMine, me),
|
||||
parentOf(uncle, cousin));
|
||||
|
||||
assertThat(service.findShortestPath(me.getId(), cousin.getId()))
|
||||
.hasValue(List.of(UP, SIBLING, DOWN));
|
||||
}
|
||||
|
||||
// --- 17: distant (label fallback for long chains) ---
|
||||
@Test
|
||||
void distant_label_for_long_chain() {
|
||||
// Seven-generation ancestor: chain of seven PARENT_OF edges.
|
||||
Person a0 = person();
|
||||
Person a1 = person();
|
||||
Person a2 = person();
|
||||
Person a3 = person();
|
||||
Person a4 = person();
|
||||
Person a5 = person();
|
||||
Person a6 = person();
|
||||
Person a7 = person();
|
||||
givenEdges(
|
||||
parentOf(a0, a1),
|
||||
parentOf(a1, a2),
|
||||
parentOf(a2, a3),
|
||||
parentOf(a3, a4),
|
||||
parentOf(a4, a5),
|
||||
parentOf(a5, a6),
|
||||
parentOf(a6, a7));
|
||||
|
||||
Optional<InferredRelationshipDTO> inferred = service.infer(a7.getId(), a0.getId());
|
||||
assertThat(inferred).hasValueSatisfying(r -> {
|
||||
assertThat(r.hops()).isEqualTo(7);
|
||||
assertThat(r.labelFromA()).isEqualTo("distant");
|
||||
assertThat(r.labelFromB()).isEqualTo("distant");
|
||||
});
|
||||
}
|
||||
|
||||
// --- 18: no path ---
|
||||
@Test
|
||||
void returns_empty_when_no_path() {
|
||||
Person a = person();
|
||||
Person b = person();
|
||||
// No edges between them.
|
||||
givenEdges(/* none */);
|
||||
|
||||
assertThat(service.findShortestPath(a.getId(), b.getId())).isEmpty();
|
||||
assertThat(service.infer(a.getId(), b.getId())).isEmpty();
|
||||
}
|
||||
|
||||
// --- 19: findAllFor delegates person resolution to PersonService ---
|
||||
@Test
|
||||
void findAllFor_resolves_persons_via_PersonService() {
|
||||
Person parent = person();
|
||||
Person child = person();
|
||||
givenEdges(parentOf(parent, child));
|
||||
when(personService.getAllById(anyList())).thenReturn(List.of(child));
|
||||
|
||||
List<InferredRelationshipWithPersonDTO> results = service.findAllFor(parent.getId());
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).person().displayName()).isEqualTo(child.getDisplayName());
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private void givenEdges(PersonRelationship... edges) {
|
||||
when(relationshipRepository.findAllByRelationTypeIn(anyCollection()))
|
||||
.thenReturn(edges.length == 0 ? emptyList() : List.of(edges));
|
||||
}
|
||||
|
||||
private static Person person() {
|
||||
return Person.builder().id(UUID.randomUUID()).lastName("X").familyMember(true).build();
|
||||
}
|
||||
|
||||
private static PersonRelationship parentOf(Person parent, Person child) {
|
||||
return edge(parent, child, PARENT_OF);
|
||||
}
|
||||
|
||||
private static PersonRelationship spouseOf(Person a, Person b) {
|
||||
return edge(a, b, SPOUSE_OF);
|
||||
}
|
||||
|
||||
private static PersonRelationship siblingOf(Person a, Person b) {
|
||||
return edge(a, b, SIBLING_OF);
|
||||
}
|
||||
|
||||
private static PersonRelationship edge(Person a, Person b, RelationType type) {
|
||||
return PersonRelationship.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.person(a)
|
||||
.relatedPerson(b)
|
||||
.relationType(type)
|
||||
.createdAt(Instant.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package org.raddatz.familienarchiv.relationship;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
|
||||
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
|
||||
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.raddatz.familienarchiv.service.PersonService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Sara blocker 1 — service+DB integration over the family-network constraints.
|
||||
* Hits the real Postgres so unique_rel, ON DELETE CASCADE, and the partial
|
||||
* sibling index actually fire.
|
||||
*/
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({
|
||||
PostgresContainerConfig.class,
|
||||
FlywayConfig.class,
|
||||
RelationshipService.class,
|
||||
RelationshipInferenceService.class,
|
||||
PersonService.class
|
||||
})
|
||||
class RelationshipServiceIntegrationTest {
|
||||
|
||||
@Autowired RelationshipService relationshipService;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired PersonRelationshipRepository relationshipRepository;
|
||||
// PersonService → PersonNameAliasRepository; @DataJpaTest auto-loads it.
|
||||
@Autowired PersonNameAliasRepository aliasRepository;
|
||||
@Autowired EntityManager entityManager;
|
||||
|
||||
Person alice;
|
||||
Person bob;
|
||||
Person charlie;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
relationshipRepository.deleteAll();
|
||||
aliasRepository.deleteAll();
|
||||
personRepository.deleteAll();
|
||||
alice = personRepository.save(Person.builder().firstName("Alice").lastName("Müller").familyMember(true).build());
|
||||
bob = personRepository.save(Person.builder().firstName("Bob").lastName("Müller").familyMember(true).build());
|
||||
charlie = personRepository.save(Person.builder().firstName("Charlie").lastName("Schmidt").familyMember(false).build());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addRelationship_stores_and_is_readable() {
|
||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, null);
|
||||
|
||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
|
||||
|
||||
assertThat(created.id()).isNotNull();
|
||||
assertThat(created.personId()).isEqualTo(alice.getId());
|
||||
assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
|
||||
|
||||
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
|
||||
assertThat(rels).hasSize(1);
|
||||
assertThat(rels.get(0).relationType()).isEqualTo(RelationType.PARENT_OF);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addRelationship_throws_409_when_duplicate() {
|
||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
||||
relationshipService.addRelationship(alice.getId(), dto);
|
||||
|
||||
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addRelationship_throws_409_when_circular_parent() {
|
||||
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
|
||||
relationshipService.addRelationship(alice.getId(),
|
||||
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
||||
|
||||
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
|
||||
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() {
|
||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
||||
|
||||
// Charlie is unrelated to this row.
|
||||
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.FORBIDDEN);
|
||||
|
||||
// The row is still there.
|
||||
assertThat(relationshipRepository.findById(created.id())).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
|
||||
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF)
|
||||
// and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
|
||||
relationshipService.addRelationship(alice.getId(),
|
||||
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
|
||||
|
||||
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null);
|
||||
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
|
||||
// alice SPOUSE_OF bob. Bob deletes from his side.
|
||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
|
||||
|
||||
relationshipService.deleteRelationship(bob.getId(), created.id());
|
||||
|
||||
assertThat(relationshipRepository.findById(created.id())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void setFamilyMember_true_makes_person_appear_in_network() {
|
||||
// charlie starts with familyMember = false. Add a PARENT_OF edge alice→charlie
|
||||
// so the edge exists, then flip charlie's flag and verify he appears in nodes.
|
||||
relationshipService.addRelationship(alice.getId(),
|
||||
new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null));
|
||||
|
||||
NetworkDTO before = relationshipService.getFamilyNetwork();
|
||||
assertThat(before.nodes()).extracting("id").doesNotContain(charlie.getId());
|
||||
|
||||
relationshipService.setFamilyMember(charlie.getId(), true);
|
||||
|
||||
NetworkDTO after = relationshipService.getFamilyNetwork();
|
||||
assertThat(after.nodes()).extracting("id").contains(charlie.getId());
|
||||
assertThat(after.edges())
|
||||
.anyMatch(e -> e.personId().equals(alice.getId()) && e.relatedPersonId().equals(charlie.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_person_cascades_to_relationships() {
|
||||
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
|
||||
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
|
||||
UUID relId = created.id();
|
||||
assertThat(relationshipRepository.findById(relId)).isPresent();
|
||||
|
||||
// Detach managed entities so deleteById's cascade isn't fought by the
|
||||
// persistence context (the rel row still references bob in memory).
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
// Delete bob (the relatedPerson) — DB CASCADE must remove the row.
|
||||
personRepository.deleteById(bob.getId());
|
||||
personRepository.flush();
|
||||
|
||||
assertThat(relationshipRepository.findById(relId)).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package org.raddatz.familienarchiv.relationship;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
|
||||
import org.raddatz.familienarchiv.service.PersonService;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Felix Brandt — TDD red for RelationshipService domain rules.
|
||||
*
|
||||
* <p>Required by the plan (Nora blockers 1 + 2):
|
||||
* <ul>
|
||||
* <li>{@code deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person}</li>
|
||||
* <li>{@code addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists}</li>
|
||||
* </ul>
|
||||
* Plus: duplicate constraint, self-relationship, year-range, happy-path persistence,
|
||||
* and ownership permitted from either side.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class RelationshipServiceTest {
|
||||
|
||||
@Mock PersonRelationshipRepository relationshipRepository;
|
||||
@Mock PersonService personService;
|
||||
@Mock RelationshipInferenceService inferenceService;
|
||||
@InjectMocks RelationshipService service;
|
||||
|
||||
Person alice;
|
||||
Person bob;
|
||||
Person charlie;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
alice = person("Alice");
|
||||
bob = person("Bob");
|
||||
charlie = person("Charlie");
|
||||
}
|
||||
|
||||
// --- Nora blocker 1 ---
|
||||
@Test
|
||||
void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() {
|
||||
UUID relId = UUID.randomUUID();
|
||||
PersonRelationship rel = parentOf(alice, bob, relId);
|
||||
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||
|
||||
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.FORBIDDEN);
|
||||
verify(relationshipRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
// --- Nora blocker 2 ---
|
||||
@Test
|
||||
void addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() {
|
||||
// alice PARENT_OF bob already exists. Now we try to add bob PARENT_OF alice.
|
||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
|
||||
|
||||
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
|
||||
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
|
||||
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addRelationship_throws_DUPLICATE_when_db_constraint_violated() {
|
||||
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
||||
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
|
||||
|
||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
|
||||
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.FRIEND, null, null, null);
|
||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() {
|
||||
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, 1950, 1940, null);
|
||||
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||
verify(relationshipRepository, never()).saveAndFlush(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addRelationship_persists_with_storage_truth() {
|
||||
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
||||
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> {
|
||||
PersonRelationship r = inv.getArgument(0);
|
||||
r.setId(UUID.randomUUID());
|
||||
r.setCreatedAt(Instant.now());
|
||||
return r;
|
||||
});
|
||||
|
||||
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, "first born");
|
||||
var result = service.addRelationship(alice.getId(), dto);
|
||||
|
||||
assertThat(result.personId()).isEqualTo(alice.getId());
|
||||
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
|
||||
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
|
||||
assertThat(result.fromYear()).isEqualTo(1900);
|
||||
assertThat(result.notes()).isEqualTo("first born");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRelationship_succeeds_when_viewpoint_is_object() {
|
||||
UUID relId = UUID.randomUUID();
|
||||
PersonRelationship rel = parentOf(alice, bob, relId);
|
||||
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
|
||||
|
||||
// Bob is the storage related_person; deleting from his viewpoint should work.
|
||||
service.deleteRelationship(bob.getId(), relId);
|
||||
verify(relationshipRepository).delete(rel);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRelationship_throws_NOT_FOUND_when_relId_unknown() {
|
||||
UUID relId = UUID.randomUUID();
|
||||
when(relationshipRepository.findById(relId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.deleteRelationship(alice.getId(), relId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() {
|
||||
// alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result).
|
||||
// Two edges exist: alice→bob (both family) and alice→charlie (one non-family).
|
||||
// Only the alice→bob edge must appear in the returned NetworkDTO.
|
||||
UUID aliceBobRelId = UUID.randomUUID();
|
||||
UUID aliceCharlieRelId = UUID.randomUUID();
|
||||
PersonRelationship aliceBob = parentOf(alice, bob, aliceBobRelId);
|
||||
PersonRelationship aliceCharlie = parentOf(alice, charlie, aliceCharlieRelId);
|
||||
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(alice, bob));
|
||||
when(relationshipRepository.findAllByRelationTypeIn(any())).thenReturn(List.of(aliceBob, aliceCharlie));
|
||||
|
||||
NetworkDTO result = service.getFamilyNetwork();
|
||||
|
||||
assertThat(result.nodes()).hasSize(2);
|
||||
assertThat(result.edges()).hasSize(1);
|
||||
assertThat(result.edges().get(0).personId()).isEqualTo(alice.getId());
|
||||
assertThat(result.edges().get(0).relatedPersonId()).isEqualTo(bob.getId());
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static Person person(String name) {
|
||||
return Person.builder().id(UUID.randomUUID()).lastName(name).familyMember(true).build();
|
||||
}
|
||||
|
||||
private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
|
||||
return PersonRelationship.builder()
|
||||
.id(id)
|
||||
.person(parent)
|
||||
.relatedPerson(child)
|
||||
.relationType(RelationType.PARENT_OF)
|
||||
.createdAt(Instant.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@ import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.model.*;
|
||||
import java.util.Set;
|
||||
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.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -24,6 +25,7 @@ class TrainingBlockQueryTest {
|
||||
@Autowired TranscriptionBlockRepository blockRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired AnnotationRepository annotationRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
|
||||
private UUID kurrentDocId;
|
||||
private UUID typewriterDocId;
|
||||
@@ -36,7 +38,7 @@ class TrainingBlockQueryTest {
|
||||
.title("Kurrent Brief")
|
||||
.originalFilename("kurrent.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.trainingLabels(new java.util.HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
|
||||
.trainingLabels(kurrentLabels())
|
||||
.build());
|
||||
kurrentDocId = kurrentDoc.getId();
|
||||
|
||||
@@ -111,8 +113,115 @@ class TrainingBlockQueryTest {
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static Set<TrainingLabel> kurrentLabels() {
|
||||
return new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION));
|
||||
}
|
||||
|
||||
private DocumentAnnotation annotation(UUID docId) {
|
||||
return DocumentAnnotation.builder()
|
||||
.documentId(docId)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package org.raddatz.familienarchiv.repository;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.PersonMention;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class TranscriptionBlockMentionsRepositoryTest {
|
||||
|
||||
@Autowired TranscriptionBlockRepository blockRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired AnnotationRepository annotationRepository;
|
||||
@Autowired EntityManager em;
|
||||
|
||||
private UUID documentId;
|
||||
private UUID annotationId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Letter")
|
||||
.originalFilename("letter.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
documentId = doc.getId();
|
||||
|
||||
DocumentAnnotation annotation = annotationRepository.save(DocumentAnnotation.builder()
|
||||
.documentId(documentId)
|
||||
.pageNumber(1)
|
||||
.x(0.1).y(0.2).width(0.3).height(0.4)
|
||||
.color("#00C7B1")
|
||||
.build());
|
||||
annotationId = annotation.getId();
|
||||
}
|
||||
|
||||
@Test
|
||||
void mentionedPersons_roundTripsTwoEntries() {
|
||||
UUID auguste = UUID.randomUUID();
|
||||
UUID hermann = UUID.randomUUID();
|
||||
|
||||
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||
.annotationId(annotationId)
|
||||
.documentId(documentId)
|
||||
.text("Liebe Tante @Auguste Raddatz, Onkel @Hermann Müller schreibt …")
|
||||
.sortOrder(0)
|
||||
.mentionedPersons(List.of(
|
||||
new PersonMention(auguste, "Auguste Raddatz"),
|
||||
new PersonMention(hermann, "Hermann Müller")
|
||||
))
|
||||
.build());
|
||||
|
||||
em.clear();
|
||||
|
||||
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
|
||||
|
||||
assertThat(reloaded.getMentionedPersons())
|
||||
.extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
|
||||
.containsExactlyInAnyOrder(
|
||||
org.assertj.core.groups.Tuple.tuple(auguste, "Auguste Raddatz"),
|
||||
org.assertj.core.groups.Tuple.tuple(hermann, "Hermann Müller"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mentionedPersons_defaultsToEmptyList_whenNotSet() {
|
||||
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||
.annotationId(annotationId)
|
||||
.documentId(documentId)
|
||||
.text("Plain text without mentions")
|
||||
.sortOrder(0)
|
||||
.build());
|
||||
|
||||
em.clear();
|
||||
|
||||
TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
|
||||
assertThat(reloaded.getMentionedPersons()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByPersonIdWithMentionsFetched_returnsOnlyBlocksReferencingPerson_withMentionsLoaded() {
|
||||
UUID augusteId = UUID.randomUUID();
|
||||
UUID hermannId = UUID.randomUUID();
|
||||
|
||||
blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||
.annotationId(annotationId).documentId(documentId)
|
||||
.text("Brief von @Auguste Raddatz an @Hermann Müller.")
|
||||
.sortOrder(0)
|
||||
.mentionedPersons(List.of(
|
||||
new PersonMention(augusteId, "Auguste Raddatz"),
|
||||
new PersonMention(hermannId, "Hermann Müller")))
|
||||
.build());
|
||||
blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||
.annotationId(annotationId).documentId(documentId)
|
||||
.text("Unrelated block without Auguste.")
|
||||
.sortOrder(1)
|
||||
.mentionedPersons(List.of(new PersonMention(hermannId, "Hermann Müller")))
|
||||
.build());
|
||||
em.clear();
|
||||
|
||||
List<TranscriptionBlock> result =
|
||||
blockRepository.findByPersonIdWithMentionsFetched(augusteId);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getMentionedPersons())
|
||||
.extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
|
||||
.containsExactlyInAnyOrder(
|
||||
org.assertj.core.groups.Tuple.tuple(augusteId, "Auguste Raddatz"),
|
||||
org.assertj.core.groups.Tuple.tuple(hermannId, "Hermann Müller"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
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,7 +24,10 @@ 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.*;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PersonServiceTest {
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.raddatz.familienarchiv.model.BlockSource;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonMention;
|
||||
import org.raddatz.familienarchiv.model.ScriptType;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
||||
@@ -98,7 +99,9 @@ class TranscriptionServiceTest {
|
||||
return b;
|
||||
});
|
||||
|
||||
CreateTranscriptionBlockDTO dto = new CreateTranscriptionBlockDTO(1, 0.1, 0.2, 0.3, 0.4, "hello", null);
|
||||
CreateTranscriptionBlockDTO dto = CreateTranscriptionBlockDTO.builder()
|
||||
.pageNumber(1).x(0.1).y(0.2).width(0.3).height(0.4)
|
||||
.text("hello").build();
|
||||
|
||||
TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId);
|
||||
|
||||
@@ -168,7 +171,7 @@ class TranscriptionServiceTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(
|
||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||
|
||||
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("new text", null);
|
||||
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder().text("new text").build();
|
||||
|
||||
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId);
|
||||
|
||||
@@ -189,7 +192,7 @@ class TranscriptionServiceTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(
|
||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||
|
||||
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("text", "Anrede");
|
||||
UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder().text("text").label("Anrede").build();
|
||||
|
||||
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
|
||||
|
||||
@@ -208,11 +211,65 @@ class TranscriptionServiceTest {
|
||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||
|
||||
TranscriptionBlock result = transcriptionService.updateBlock(
|
||||
docId, blockId, new UpdateTranscriptionBlockDTO("new", null), UUID.randomUUID());
|
||||
docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new").build(), UUID.randomUUID());
|
||||
|
||||
assertThat(result.getSource()).isEqualTo(BlockSource.MANUAL);
|
||||
}
|
||||
|
||||
@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
|
||||
void updateBlock_triggersTraining_whenKurrentSenderPresent() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
@@ -226,7 +283,7 @@ class TranscriptionServiceTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(
|
||||
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).sender(sender).build());
|
||||
|
||||
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null), UUID.randomUUID());
|
||||
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("text").build(), UUID.randomUUID());
|
||||
|
||||
verify(senderModelService).checkAndTriggerTraining(senderId);
|
||||
}
|
||||
@@ -242,7 +299,7 @@ class TranscriptionServiceTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(
|
||||
Document.builder().scriptType(ScriptType.HANDWRITING_KURRENT).build());
|
||||
|
||||
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("text", null), UUID.randomUUID());
|
||||
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("text").build(), UUID.randomUUID());
|
||||
|
||||
verify(senderModelService, never()).checkAndTriggerTraining(any());
|
||||
}
|
||||
@@ -477,7 +534,7 @@ class TranscriptionServiceTest {
|
||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||
when(annotationRepository.findById(annotId)).thenReturn(Optional.of(annotation));
|
||||
|
||||
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("new text", null), userId);
|
||||
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new text").build(), userId);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
|
||||
@@ -502,8 +559,90 @@ class TranscriptionServiceTest {
|
||||
when(documentService.getDocumentById(any())).thenReturn(
|
||||
Document.builder().scriptType(ScriptType.TYPEWRITER).build());
|
||||
|
||||
transcriptionService.updateBlock(docId, blockId, new UpdateTranscriptionBlockDTO("same text", null), userId);
|
||||
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("same text").build(), userId);
|
||||
|
||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ─── markAllBlocksReviewed ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_setsAllUnreviewedBlocksToReviewed() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
TranscriptionBlock block1 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||
TranscriptionBlock block2 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||
.thenReturn(List.of(block1, block2));
|
||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||
|
||||
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
|
||||
verify(blockRepository).saveAll(List.of(block1, block2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_isIdempotent_whenAllBlocksAlreadyReviewed() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
|
||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||
.thenReturn(List.of(block));
|
||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||
|
||||
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
|
||||
verify(blockRepository).saveAll(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_emitsBlockReviewedAuditEvent_forEachUnreviewedBlock() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
TranscriptionBlock block1 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||
TranscriptionBlock block2 = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||
.thenReturn(List.of(block1, block2));
|
||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||
|
||||
verify(auditService, times(2)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_doesNotEmitAuditEvent_forAlreadyReviewedBlocks() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
TranscriptionBlock alreadyReviewed = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
|
||||
TranscriptionBlock unreviewed = TranscriptionBlock.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||
.thenReturn(List.of(alreadyReviewed, unreviewed));
|
||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||
|
||||
verify(auditService, times(1)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void markAllBlocksReviewed_returnsEmptyList_whenNoBlocksExist() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of());
|
||||
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
55
docs/adr/006-synchronous-domain-events-in-transaction.md
Normal file
55
docs/adr/006-synchronous-domain-events-in-transaction.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# ADR-006: Synchronous domain events inside the publisher's transaction
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Issue #362 introduced the first cross-domain side-effect in this codebase: when a Person's display name changes, every transcription block that mentions the person must be rewritten — both `block.text` (the literal `@OldName` substring) and the `mentionedPersons` sidecar (the `displayName` field on the matching `PersonMention`). The rewrite is bidirectionally referential — Person depends on Transcription to make the rename atomic, and Transcription depends on Person to know what the new display name is.
|
||||
|
||||
A direct method call from `PersonService` into `TranscriptionBlockService` would invert the existing dependency arrow (Document → Person, not Person → Transcription) and introduce a runtime-circular reference at the package level. Avoiding the cycle while keeping the rename atomic is the constraint this ADR addresses.
|
||||
|
||||
Two prior pieces of infrastructure constrain the solution:
|
||||
|
||||
- `transcription_blocks.version` (JPA `@Version`) — concurrent autosave on a referenced block must roll back the rename instead of silently overwriting the autosave.
|
||||
- `OcrTrainingService.recoverOrphanedRuns` is the only existing `@EventListener` and it consumes Spring's built-in `ApplicationReadyEvent` — no precedent for a custom domain event in this codebase before now.
|
||||
|
||||
## Decision
|
||||
|
||||
`PersonService.updatePerson` publishes `PersonDisplayNameChangedEvent(personId, oldDisplayName, newDisplayName)` via `ApplicationEventPublisher` whenever `Person.getDisplayName()` flips between the pre-save snapshot and the post-save value. `PersonMentionPropagationListener` (in the transcription package's `service/` layer) handles the event with `@EventListener @Transactional`, finds blocks via `findByMentionedPersons_PersonId`, rewrites text + sidecar, and calls `saveAllAndFlush`.
|
||||
|
||||
**Synchronous on purpose.** Spring's default event dispatcher invokes listeners on the publishing thread, inside the publisher's transaction. The propagation runs as part of the same `@Transactional` boundary as the rename — `OptimisticLockingFailureException` from a referenced block bubbles back up, the surrounding transaction rolls back, and `PersonService.updatePerson` translates it to `DomainException(PERSON_RENAME_CONFLICT, 409)`.
|
||||
|
||||
**Pattern for future cross-domain decoupling:**
|
||||
1. Event record in `model/` of the publishing domain (e.g. `PersonDisplayNameChangedEvent`).
|
||||
2. Listener in `service/` of the consuming domain (e.g. `PersonMentionPropagationListener`).
|
||||
3. `@EventListener @Transactional` on the listener method — no `@TransactionalEventListener` unless the work genuinely doesn't need to commit with the publisher.
|
||||
4. `saveAllAndFlush` (not `saveAll`) on any write where exceptions must surface inside the listener call so the publisher can catch and translate them — `saveAll` defers exceptions to commit time, after the publisher's `try` block has exited.
|
||||
5. Audit log line at `INFO` level on the listener method — historical-text mutation needs an audit trail.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| `PersonService` calls `TranscriptionBlockService.propagateDisplayNameChange(...)` directly | Inverts the dependency arrow. Person becomes runtime-coupled to Transcription; future domains that also care about renames (Comments, Notifications) compound the coupling. Events keep Person agnostic of who consumes them. |
|
||||
| `@TransactionalEventListener(AFTER_COMMIT) + @Async` | The propagation would run after the rename commits, on a separate transaction. A failed propagation could leave block text out of sync with the renamed person until manual repair. Atomic transactional coupling is the safer default for historical-text mutation; switch to async only when the block count makes sync latency unacceptable (rough threshold: tens of thousands of blocks per renamed person). |
|
||||
| Database trigger on `persons.last_name` | PL/pgSQL trigger would have to reach into `transcription_block_mentioned_persons` and `transcription_blocks.text`, smearing domain logic across SQL and Java. JPA's `@Version` would also be invisible to the trigger, so concurrent block autosaves would race silently. |
|
||||
| Hibernate entity listener (`@PostUpdate` on Person) | Couples to Hibernate internals; harder to test in isolation; mixes lifecycle hooks with cross-domain side effects. Spring's `ApplicationEventPublisher` keeps the integration declarative and unit-testable. |
|
||||
|
||||
## Consequences
|
||||
|
||||
**Easier:**
|
||||
- Person domain stays free of any compile-time dependency on Transcription. Future consumers (Comments, Notifications) subscribe to the same event without `PersonService` knowing they exist.
|
||||
- Rename + propagation share one transaction → no half-applied state visible to readers, no orphaned rewrites if the rename fails after propagation, no "eventually-consistent" window for an archive that prizes historical fidelity.
|
||||
- Concurrent autosaves on referenced blocks raise a structured 409 the frontend can render meaningfully (`error_person_rename_conflict`) instead of a generic 500.
|
||||
- The pattern itself (record event in `model/`, listener in consumer's `service/`, sync `@EventListener @Transactional`, `saveAllAndFlush`) is reusable for the next cross-domain side effect.
|
||||
|
||||
**Harder:**
|
||||
- Listener latency adds to the rename request's response time. The 200-block latency floor (< 2 s) is a merge-blocking regression test; if archive growth pushes it up, the migration path is one-annotation: switch to `@TransactionalEventListener(AFTER_COMMIT) + @Async` and add a manual-repair tool for propagation failures.
|
||||
- Tests for the listener path require routing the publisher mock through a real listener (see `PersonServiceTest#updatePerson_throwsConflict_whenBlockSaveAllAndFlushHitsOptimisticLock`). Slightly more setup than a pure-Mockito test, but exercises the production call chain.
|
||||
- `saveAllAndFlush` is mandatory in any synchronous listener that must surface JPA exceptions to the publisher's `try`-block. `saveAll` alone defers the flush to transaction commit, which happens after the publisher returns.
|
||||
|
||||
## Future Direction
|
||||
|
||||
If a single rename starts touching tens of thousands of blocks, switch the listener to `@TransactionalEventListener(phase = AFTER_COMMIT)` paired with `@Async` and add (a) an idempotency key to the event so a retry doesn't double-rewrite, (b) an admin tool that scans for sidecar entries whose `displayName` doesn't match the current `Person.getDisplayName()` and repairs them. At that point the orphan-guard path (existsById check before the rewrite) re-enters the listener as a deliberate piece of the async machinery rather than dead code.
|
||||
987
docs/specs/stammbaum-doc-badge-spec.html
Normal file
987
docs/specs/stammbaum-doc-badge-spec.html
Normal file
@@ -0,0 +1,987 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Stammbaum — Document Badge · Inline Pill Variant · Familienarchiv</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
|
||||
.doc{max-width:1300px;margin:0 auto;padding:48px 32px 120px}
|
||||
|
||||
/* ── Masthead ── */
|
||||
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:60px}
|
||||
.mh h1{font-size:23px;font-weight:900;color:#012851;letter-spacing:-.4px}
|
||||
.mh p{font-size:13px;color:#555;max-width:740px;line-height:1.75;margin-top:8px}
|
||||
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:10px}
|
||||
.tag-row{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
|
||||
.tag{background:#012851;color:#A1DCD8;padding:2px 8px;border-radius:2px;font-size:8px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
|
||||
.tag.amber{background:#7c4a00;color:#fde68a}
|
||||
|
||||
/* ── Section headers ── */
|
||||
.sh{margin:0 0 28px}
|
||||
.sh h2{font-size:16px;font-weight:900;color:#012851;letter-spacing:-.2px}
|
||||
.sh p{font-size:12.5px;color:#666;max-width:720px;line-height:1.7;margin-top:5px}
|
||||
.section{margin-bottom:80px;padding-bottom:80px;border-bottom:2px dashed #C8C4BE}
|
||||
.section:last-of-type{border-bottom:none;margin-bottom:0;padding-bottom:0}
|
||||
|
||||
/* ── Token tables ── */
|
||||
.token-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
|
||||
.token-table{border-radius:6px;overflow:hidden}
|
||||
.token-table.light{background:#fff;border:1px solid #E0DDD6}
|
||||
.token-table.dark{background:#0F1923;border:1px solid #1E2D3D}
|
||||
.token-head{padding:8px 14px;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;border-bottom:1px solid #E0DDD6}
|
||||
.token-table.light .token-head{background:#F4F2EC;color:#888;border-bottom-color:#E0DDD6}
|
||||
.token-table.dark .token-head{background:#0A1218;color:#4E6070;border-bottom-color:#1E2D3D}
|
||||
.token-table table{width:100%;border-collapse:collapse;font-size:11px}
|
||||
.token-table.light td{padding:6px 14px;border-bottom:1px solid #F0EEE8;vertical-align:middle}
|
||||
.token-table.dark td{padding:6px 14px;border-bottom:1px solid #1A2830;vertical-align:middle;color:#8AAABB}
|
||||
.token-table tr:last-child td{border-bottom:none}
|
||||
.token-table.light td:first-child{font-size:9px;font-weight:700;color:#888;width:160px}
|
||||
.token-table.dark td:first-child{font-size:9px;font-weight:700;color:#4E6070;width:160px}
|
||||
.swatch{display:inline-block;width:12px;height:12px;border-radius:2px;vertical-align:middle;margin-right:6px}
|
||||
.swatch.bordered{border:1px solid #DDD}
|
||||
.warn{display:inline-block;background:#FEF3C7;color:#92400E;font-size:8px;font-weight:700;padding:1px 5px;border-radius:2px;margin-left:4px;vertical-align:middle}
|
||||
.pass{display:inline-block;background:#D1FAE5;color:#065F46;font-size:8px;font-weight:700;padding:1px 5px;border-radius:2px;margin-left:4px;vertical-align:middle}
|
||||
|
||||
/* ── Browser chrome ── */
|
||||
.chrome{border:1.5px solid #C4C0BA;border-radius:8px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.1)}
|
||||
.chrome.dark{background:#010e1e;border-color:#0d3358}
|
||||
.chrome-bar{height:20px;background:#E8E6E0;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;padding:0 8px;gap:4px;flex-shrink:0}
|
||||
.chrome.dark .chrome-bar{background:#010a18;border-bottom-color:#0d3358}
|
||||
.chrome-dot{width:6px;height:6px;border-radius:50%;background:#BDB8B1}
|
||||
.chrome.dark .chrome-dot{background:#1a2a3a}
|
||||
.chrome-url{flex:1;height:9px;background:#CCC8C2;border-radius:5px;margin-left:6px}
|
||||
.chrome.dark .chrome-url{background:#1a2a3a}
|
||||
|
||||
/* ── App nav ── */
|
||||
.app-nav{height:34px;background:#012851;border-top:4px solid #A1DCD8;display:flex;align-items:center;padding:0 12px;gap:10px;flex-shrink:0}
|
||||
.app-logo{font-family:'Tinos',Georgia,serif;font-size:7px;font-weight:700;color:#fff;letter-spacing:.5px}
|
||||
.app-link{font-size:5.5px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:rgba(255,255,255,.4);white-space:nowrap}
|
||||
.app-link.on{color:rgba(255,255,255,.9)}
|
||||
.app-nav-r{margin-left:auto;display:flex;gap:6px;align-items:center}
|
||||
.app-av{width:16px;height:16px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5)}
|
||||
|
||||
/* ── Sub-header bar ── */
|
||||
.sub-header{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 12px;gap:6px;flex-shrink:0}
|
||||
.chrome.dark .sub-header{background:#011526;border-bottom-color:#0d3358}
|
||||
.back-btn{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0}
|
||||
.chrome.dark .back-btn{color:#8b97a5}
|
||||
.sh-divider{width:1px;height:18px;background:#E4E2D7;flex-shrink:0;margin:0 4px}
|
||||
.chrome.dark .sh-divider{background:#0d3358}
|
||||
.sh-doc-title{font-family:'Tinos',Georgia,serif;font-size:10px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
|
||||
.chrome.dark .sh-doc-title{color:#f0efe9}
|
||||
/* person chips in sub-header */
|
||||
.sh-persons{display:flex;align-items:center;gap:5px;flex-shrink:0}
|
||||
.sh-chip{display:flex;align-items:center;gap:4px}
|
||||
.sh-av{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0}
|
||||
.sh-name{font-size:8px;font-weight:600;color:#4b5563;white-space:nowrap}
|
||||
.chrome.dark .sh-name{color:#9ca3af}
|
||||
.sh-arrow{color:#A1DCD8;flex-shrink:0}
|
||||
.chrome.dark .sh-arrow{color:#00c7b1}
|
||||
/* INLINE PILL */
|
||||
.pill{display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;margin-left:5px;vertical-align:middle;line-height:1.5}
|
||||
.chrome.dark .pill{background:rgba(0,199,177,.10);border-color:#00c7b1;color:#f0efe9}
|
||||
/* sub-header actions */
|
||||
.sh-actions{display:flex;align-items:center;gap:5px;flex-shrink:0;margin-left:8px}
|
||||
.sh-btn-ghost{height:22px;padding:0 7px;border:1.5px solid #E4E2D7;border-radius:3px;font-size:6.5px;font-weight:700;color:#4b5563;display:flex;align-items:center;gap:3px;flex-shrink:0}
|
||||
.chrome.dark .sh-btn-ghost{border-color:#0d3358;color:#8b97a5}
|
||||
.sh-btn-primary{height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center;gap:3px;flex-shrink:0}
|
||||
.chrome.dark .sh-btn-primary{background:#A1DCD8;color:#012851}
|
||||
.sh-btn-icon{width:22px;height:22px;border:1.5px solid #E4E2D7;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0}
|
||||
.chrome.dark .sh-btn-icon{border-color:#0d3358;color:#8b97a5}
|
||||
|
||||
/* ── Metadata drawer ── */
|
||||
.meta-drawer{background:#ffffff;border-bottom:1px solid #E4E2D7;padding:14px 16px;flex-shrink:0}
|
||||
.chrome.dark .meta-drawer{background:#011526;border-bottom-color:#0d3358}
|
||||
.meta-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
|
||||
.meta-col-head{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#6b7280;margin-bottom:8px}
|
||||
.chrome.dark .meta-col-head{color:#8b97a5}
|
||||
.meta-field{margin-bottom:8px}
|
||||
.meta-label{font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px}
|
||||
.chrome.dark .meta-label{color:#8b97a5}
|
||||
.meta-value{font-family:'Tinos',Georgia,serif;font-size:10px;color:#012851}
|
||||
.chrome.dark .meta-value{color:#f0efe9}
|
||||
|
||||
/* ── Person card in metadata ── */
|
||||
.person-card{display:flex;align-items:center;gap:5px;padding:3px 5px;border-radius:3px}
|
||||
.p-av{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6.5px;font-weight:800;color:#fff;flex-shrink:0}
|
||||
.p-name{font-family:'Tinos',Georgia,serif;font-size:9.5px;color:#012851}
|
||||
.chrome.dark .p-name{color:#f0efe9}
|
||||
|
||||
/* ── PDF placeholder ── */
|
||||
.pdf-area{background:#d4d0c8;flex:1;display:flex;align-items:center;justify-content:center;min-height:80px}
|
||||
.chrome.dark .pdf-area{background:#010e1e}
|
||||
.paper{background:#FFFEF8;width:40%;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:8px 10px;display:flex;flex-direction:column;gap:2px}
|
||||
.chrome.dark .paper{background:#0d1820}
|
||||
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px}
|
||||
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px}
|
||||
.chrome.dark .pl,.chrome.dark .ps{background:#1E2D3D}
|
||||
|
||||
/* ── Side-by-side layout ── */
|
||||
.split-screens{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:16px}
|
||||
.screen-lbl{font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:8px;display:flex;align-items:center;gap:5px}
|
||||
.lbl-dot{width:8px;height:8px;border-radius:50%;display:inline-block}
|
||||
.cap{font-size:10px;color:#999;font-style:italic;line-height:1.6;margin-top:10px;max-width:460px}
|
||||
|
||||
/* ── Edge-case cards ── */
|
||||
.edge-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:12px}
|
||||
.edge-card{background:#fff;border:1px solid #E0DDD6;border-radius:6px;overflow:hidden}
|
||||
.edge-head{background:#F4F2EC;padding:8px 12px;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;border-bottom:1px solid #E0DDD6}
|
||||
.edge-body{padding:10px 12px}
|
||||
.edge-note{font-size:10.5px;color:#555;line-height:1.65;margin-top:8px}
|
||||
.no-badge{font-family:'Tinos',Georgia,serif;font-size:9px;color:#aaa;font-style:italic;padding:4px 5px}
|
||||
|
||||
/* ── Rules / implementation table ── */
|
||||
.rules{background:#fff;border:1px solid #E0DDD6;border-radius:6px;overflow:hidden}
|
||||
.rules table{width:100%;border-collapse:collapse}
|
||||
.rules th{background:#F4F2EC;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;padding:8px 12px;text-align:left;border-bottom:1px solid #E0DDD6}
|
||||
.rules td{font-size:11px;color:#444;padding:8px 12px;border-bottom:1px solid #F0EEE8;vertical-align:top;line-height:1.6}
|
||||
.rules tr:last-child td{border-bottom:none}
|
||||
.rules td:first-child{font-size:9px;font-weight:700;color:#012851;white-space:nowrap;width:200px}
|
||||
.rules td code{font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px;color:#555;white-space:nowrap}
|
||||
|
||||
/* ── Pill anatomy callout ── */
|
||||
.pill-anatomy{display:flex;align-items:center;gap:20px;background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:18px 24px;margin-bottom:16px;flex-wrap:wrap}
|
||||
.pill-demo-light{display:flex;align-items:center;gap:10px;padding:10px 16px;background:#f9f8f4;border-radius:4px}
|
||||
.pill-demo-dark{display:flex;align-items:center;gap:10px;padding:10px 16px;background:#011526;border-radius:4px}
|
||||
.pill-annotation{font-size:9.5px;color:#888;line-height:1.7}
|
||||
.pill-annotation strong{color:#012851;font-weight:700}
|
||||
|
||||
/* ── Responsive preview containers ── */
|
||||
.responsive-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:16px}
|
||||
.responsive-grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:24px;margin-bottom:16px}
|
||||
|
||||
/* ── Tablet sub-header ── */
|
||||
.sub-header-tablet{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0}
|
||||
.sh-title-truncated{font-family:'Tinos',Georgia,serif;font-size:9px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
|
||||
.sh-overflow-btn{width:22px;height:22px;border:1.5px solid #E4E2D7;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#6b7280;font-size:9px;font-weight:700;flex-shrink:0}
|
||||
.meta-stacked{padding:12px 14px;background:#fff;border-bottom:1px solid #E4E2D7;font-size:9px}
|
||||
.meta-stacked .meta-label{font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px}
|
||||
.meta-stacked .meta-value{font-family:'Tinos',Georgia,serif;font-size:9.5px;color:#012851;margin-bottom:10px}
|
||||
.meta-stacked .meta-section-head{font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#6b7280;margin-bottom:8px}
|
||||
|
||||
/* ── Mobile sub-header ── */
|
||||
.sub-header-mobile{height:48px;background:#ffffff;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 10px;gap:5px;flex-shrink:0}
|
||||
.sh-title-mobile{font-family:'Tinos',Georgia,serif;font-size:9px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
|
||||
.meta-mobile{padding:10px 12px;background:#fff;border-bottom:1px solid #E4E2D7;font-size:8.5px}
|
||||
.meta-mobile .m-label{font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:2px;margin-top:8px}
|
||||
.meta-mobile .m-label:first-child{margin-top:0}
|
||||
.meta-mobile .m-value{font-family:'Tinos',Georgia,serif;font-size:9px;color:#012851;margin-bottom:2px}
|
||||
.person-row-mobile{display:flex;align-items:center;gap:4px;flex-wrap:nowrap}
|
||||
.person-row-mobile .p-av-sm{width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0}
|
||||
.person-row-mobile .p-nm{font-family:'Tinos',Georgia,serif;font-size:9px;color:#012851;white-space:nowrap}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<!-- ══ MASTHEAD ══════════════════════════════════════════════════════════════ -->
|
||||
<div class="mh">
|
||||
<h1>Stammbaum — Document Badge · Inline Pill Variant</h1>
|
||||
<p>
|
||||
Design spec for the inline relationship pill on the Document Detail page. Relationship labels appear
|
||||
as <strong>inline pills directly next to each person's name</strong> — both in the 48 px sub-header bar
|
||||
and in the Personen column of the 3-column metadata drawer. Example: Karl Raddatz
|
||||
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 7px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin:0 2px">ELTERNTEIL</span>
|
||||
→ Hans Raddatz
|
||||
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 7px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin:0 2px">KIND</span>.
|
||||
This is View 2 of 3 in the Stammbaum document-badge feature set.
|
||||
</p>
|
||||
<div class="byline">Familienarchiv · 2026-04-27 · Leonie Voss, UX Lead</div>
|
||||
<div class="tag-row">
|
||||
<span class="tag">Stammbaum Feature</span>
|
||||
<span class="tag">View 2 of 3 — Document Badge</span>
|
||||
<span class="tag">Inline Pill Variant</span>
|
||||
<span class="tag">Desktop / Tablet / Mobile</span>
|
||||
<span class="tag">Light + Dark</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ SECTION 1 — DESIGN TOKENS ════════════════════════════════════════════ -->
|
||||
<div class="section">
|
||||
<div class="sh">
|
||||
<h2>1 · Design tokens</h2>
|
||||
<p>All colour values used by the inline pill and its surrounding context. Light and dark themes are shown side by side. Contrast ratios are against the respective surface colour.</p>
|
||||
</div>
|
||||
|
||||
<!-- Pill anatomy callout -->
|
||||
<div class="pill-anatomy">
|
||||
<div class="pill-demo-light">
|
||||
<span style="font-family:'Tinos',Georgia,serif;font-size:11px;color:#012851;font-weight:700">Karl Raddatz</span>
|
||||
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle">ELTERNTEIL</span>
|
||||
</div>
|
||||
<div class="pill-demo-dark">
|
||||
<span style="font-family:'Tinos',Georgia,serif;font-size:11px;color:#f0efe9;font-weight:700">Karl Raddatz</span>
|
||||
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 8px;font-family:'Montserrat',sans-serif;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle">ELTERNTEIL</span>
|
||||
</div>
|
||||
<div class="pill-annotation">
|
||||
<strong>Pill anatomy</strong><br>
|
||||
border-radius: 9999px · padding: 1px 8px<br>
|
||||
font: Montserrat 9px 700 uppercase letter-spacing .07em<br>
|
||||
margin-left: 8px from name span · vertical-align: middle
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="token-grid">
|
||||
<!-- Light -->
|
||||
<div class="token-table light">
|
||||
<div class="token-head">Light theme — surface #ffffff</div>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Pill bg</td>
|
||||
<td><span class="swatch bordered" style="background:rgba(161,220,216,.25)"></span>rgba(161,220,216,.25) — near-white on white<span class="pass">~14:1 AAA ✓ (text on near-white)</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Pill border</td>
|
||||
<td><span class="swatch" style="background:#a1dcd8"></span>#a1dcd8 — mint accent outline</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Pill text</td>
|
||||
<td><span class="swatch" style="background:#012851"></span>#012851 — navy ink<span class="pass">14.5:1 AAA ✓</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Person name</td>
|
||||
<td><span class="swatch" style="background:#4b5563"></span>#4b5563 — Montserrat 11px (sub-header)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Meta person name</td>
|
||||
<td><span class="swatch" style="background:#012851"></span>#012851 — Tinos 9.5px (metadata drawer)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sub-header bg</td>
|
||||
<td><span class="swatch bordered" style="background:#ffffff"></span>#ffffff</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sub-header border</td>
|
||||
<td><span class="swatch" style="background:#e4e2d7"></span>#e4e2d7</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Arrow (decorative)</td>
|
||||
<td><span class="swatch" style="background:#a1dcd8"></span>#a1dcd8 — <code>aria-hidden</code><span class="warn">non-text only</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Meta label</td>
|
||||
<td><span class="swatch" style="background:#6b7280"></span>#6b7280 — Montserrat 9px 700 uppercase<span class="pass">4.8:1 AA ✓</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Meta value</td>
|
||||
<td><span class="swatch" style="background:#012851"></span>#012851 — Tinos 13px<span class="pass">14.5:1 AAA ✓</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Doc title</td>
|
||||
<td>Tinos serif 18px · #012851</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Avatar KR</td>
|
||||
<td><span class="swatch" style="background:#012851"></span>#012851 — navy</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Avatar HR</td>
|
||||
<td><span class="swatch" style="background:#5a2d6f"></span>#5a2d6f — purple</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Dark -->
|
||||
<div class="token-table dark">
|
||||
<div class="token-head">Dark theme — surface #011526</div>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Pill bg</td>
|
||||
<td><span class="swatch bordered" style="background:rgba(0,199,177,.10);border-color:#0d3358"></span>rgba(0,199,177,.10) — dark teal wash<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">passes AA ✓</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Pill border</td>
|
||||
<td><span class="swatch" style="background:#00c7b1"></span>#00c7b1 — turquoise</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Pill text</td>
|
||||
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9 — warm white<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">14.5:1 AAA ✓</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Person name</td>
|
||||
<td><span class="swatch" style="background:#9ca3af"></span>#9ca3af — (sub-header)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Meta person name</td>
|
||||
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9 — (metadata drawer)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sub-header bg</td>
|
||||
<td><span class="swatch" style="background:#011526;border:1px solid #0d3358"></span>#011526</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sub-header border</td>
|
||||
<td><span class="swatch" style="background:#0d3358"></span>#0d3358</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Arrow (decorative)</td>
|
||||
<td><span class="swatch" style="background:#00c7b1"></span>#00c7b1 — <code>aria-hidden</code><span class="warn" style="background:rgba(254,243,199,.1);color:#FDE68A;border:none">non-text only</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Meta label</td>
|
||||
<td><span class="swatch" style="background:#8b97a5"></span>#8b97a5<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">7.1:1 AAA ✓</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Meta value</td>
|
||||
<td><span class="swatch" style="background:#f0efe9"></span>#f0efe9<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7;border:none">14.5:1 AAA ✓</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Doc title</td>
|
||||
<td>Tinos serif 18px · #f0efe9</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="font-size:10.5px;color:#888;font-style:italic;margin-top:6px">
|
||||
⚠ Pill background rgba(161,220,216,.25) is nearly transparent on white — the effective contrast for the text is calculated against the near-white composite, yielding ~14:1.
|
||||
The arrow between sender and receiver chips in the sub-header is <code>aria-hidden="true"</code> — directional meaning is conveyed by DOM order (sender before receiver) and the visual left-to-right reading order.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ SECTION 2 — DESKTOP LIGHT & DARK ═════════════════════════════════════ -->
|
||||
<div class="section">
|
||||
<div class="sh">
|
||||
<h2>2 · Desktop (1280 px) — light & dark</h2>
|
||||
<p>
|
||||
Full document detail page at ~65% scale. Sub-header bar (48 px) shows inline pills next to avatar chips.
|
||||
Metadata drawer is open, showing pills next to person names in the Personen column.
|
||||
Both light and dark themes shown side by side.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="split-screens">
|
||||
|
||||
<!-- ── LIGHT ── -->
|
||||
<div>
|
||||
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Light theme</div>
|
||||
<div class="chrome">
|
||||
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||
<!-- App header -->
|
||||
<div class="app-nav">
|
||||
<div class="app-logo">FAMILIENARCHIV</div>
|
||||
<div style="width:1px;height:14px;background:rgba(255,255,255,.12);margin:0 4px;flex-shrink:0"></div>
|
||||
<div class="app-link on">Dokumente</div>
|
||||
<div class="app-link">Personen</div>
|
||||
<div class="app-link">Stammbaum</div>
|
||||
<div class="app-link">Admin</div>
|
||||
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||
</div>
|
||||
<!-- Sub-header -->
|
||||
<div class="sub-header">
|
||||
<div class="back-btn">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||
</div>
|
||||
<div class="sh-divider"></div>
|
||||
<div class="sh-doc-title">W-0311 · Divacca</div>
|
||||
<div class="sh-persons">
|
||||
<!-- Sender chip + pill -->
|
||||
<div class="sh-chip">
|
||||
<div class="sh-av" style="background:#012851">KR</div>
|
||||
<span class="sh-name">Karl Raddatz</span>
|
||||
<span class="pill">ELTERNTEIL</span>
|
||||
</div>
|
||||
<!-- Arrow -->
|
||||
<svg class="sh-arrow" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5-5 5M6 12h12"/></svg>
|
||||
<!-- Receiver chip + pill -->
|
||||
<div class="sh-chip">
|
||||
<div class="sh-av" style="background:#5a2d6f">HR</div>
|
||||
<span class="sh-name">Hans Raddatz</span>
|
||||
<span class="pill">KIND</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sh-actions">
|
||||
<div class="sh-btn-ghost">Details ▾</div>
|
||||
<div class="sh-btn-ghost">Transkribieren</div>
|
||||
<div class="sh-btn-primary">Bearbeiten</div>
|
||||
<div class="sh-btn-icon">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Metadata drawer -->
|
||||
<div class="meta-drawer">
|
||||
<div class="meta-grid">
|
||||
<!-- Col 1: Details -->
|
||||
<div>
|
||||
<div class="meta-col-head">Details</div>
|
||||
<div class="meta-field">
|
||||
<div class="meta-label">Datum</div>
|
||||
<div class="meta-value">—</div>
|
||||
</div>
|
||||
<div class="meta-field">
|
||||
<div class="meta-label">Ort</div>
|
||||
<div class="meta-value">Divacca</div>
|
||||
</div>
|
||||
<div class="meta-field">
|
||||
<div class="meta-label">Status</div>
|
||||
<div class="meta-value">Hochgeladen</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Col 2: Personen with inline pills -->
|
||||
<div>
|
||||
<div class="meta-col-head">Personen</div>
|
||||
<div class="meta-field">
|
||||
<div class="meta-label">Absender</div>
|
||||
<div class="person-card">
|
||||
<div class="p-av" style="background:#012851">KR</div>
|
||||
<span class="p-name">Karl Raddatz</span>
|
||||
<span class="pill">ELTERNTEIL</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-field">
|
||||
<div class="meta-label">Empfänger</div>
|
||||
<div class="person-card">
|
||||
<div class="p-av" style="background:#5a2d6f">HR</div>
|
||||
<span class="p-name">Hans Raddatz</span>
|
||||
<span class="pill">KIND</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Col 3: Tags -->
|
||||
<div>
|
||||
<div class="meta-col-head">Schlagwörter</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px">
|
||||
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
|
||||
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
|
||||
<span style="background:#f5f4ef;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#012851">Berlin</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- PDF area -->
|
||||
<div class="pdf-area">
|
||||
<div class="paper"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div><div class="pl"></div><div class="ps" style="width:75%"></div><div class="pl" style="width:88%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="cap">Light. Pills appear in both the sub-header chip row and the metadata Personen column. Arrow between chips is mint-coloured and aria-hidden.</p>
|
||||
</div>
|
||||
|
||||
<!-- ── DARK ── -->
|
||||
<div>
|
||||
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Dark theme</div>
|
||||
<div class="chrome dark">
|
||||
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||
<div class="app-nav">
|
||||
<div class="app-logo">FAMILIENARCHIV</div>
|
||||
<div style="width:1px;height:14px;background:rgba(255,255,255,.12);margin:0 4px;flex-shrink:0"></div>
|
||||
<div class="app-link on">Dokumente</div>
|
||||
<div class="app-link">Personen</div>
|
||||
<div class="app-link">Stammbaum</div>
|
||||
<div class="app-link">Admin</div>
|
||||
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||
</div>
|
||||
<div class="sub-header">
|
||||
<div class="back-btn">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||
</div>
|
||||
<div class="sh-divider"></div>
|
||||
<div class="sh-doc-title">W-0311 · Divacca</div>
|
||||
<div class="sh-persons">
|
||||
<div class="sh-chip">
|
||||
<div class="sh-av" style="background:#012851">KR</div>
|
||||
<span class="sh-name">Karl Raddatz</span>
|
||||
<span class="pill">ELTERNTEIL</span>
|
||||
</div>
|
||||
<svg class="sh-arrow" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5-5 5M6 12h12"/></svg>
|
||||
<div class="sh-chip">
|
||||
<div class="sh-av" style="background:#5a2d6f">HR</div>
|
||||
<span class="sh-name">Hans Raddatz</span>
|
||||
<span class="pill">KIND</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sh-actions">
|
||||
<div class="sh-btn-ghost">Details ▾</div>
|
||||
<div class="sh-btn-ghost">Transkribieren</div>
|
||||
<div class="sh-btn-primary">Bearbeiten</div>
|
||||
<div class="sh-btn-icon">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-drawer">
|
||||
<div class="meta-grid">
|
||||
<div>
|
||||
<div class="meta-col-head">Details</div>
|
||||
<div class="meta-field">
|
||||
<div class="meta-label">Datum</div>
|
||||
<div class="meta-value">—</div>
|
||||
</div>
|
||||
<div class="meta-field">
|
||||
<div class="meta-label">Ort</div>
|
||||
<div class="meta-value">Divacca</div>
|
||||
</div>
|
||||
<div class="meta-field">
|
||||
<div class="meta-label">Status</div>
|
||||
<div class="meta-value">Hochgeladen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="meta-col-head">Personen</div>
|
||||
<div class="meta-field">
|
||||
<div class="meta-label">Absender</div>
|
||||
<div class="person-card">
|
||||
<div class="p-av" style="background:#012851">KR</div>
|
||||
<span class="p-name">Karl Raddatz</span>
|
||||
<span class="pill">ELTERNTEIL</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-field">
|
||||
<div class="meta-label">Empfänger</div>
|
||||
<div class="person-card">
|
||||
<div class="p-av" style="background:#5a2d6f">HR</div>
|
||||
<span class="p-name">Hans Raddatz</span>
|
||||
<span class="pill">KIND</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="meta-col-head">Schlagwörter</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:4px">
|
||||
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
|
||||
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
|
||||
<span style="background:#011a30;padding:2px 7px;border-radius:2px;font-size:7.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Berlin</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf-area">
|
||||
<div class="paper"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div><div class="pl"></div><div class="ps" style="width:75%"></div><div class="pl" style="width:88%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="cap">Dark. Pills flip to rgba(0,199,177,.10) bg, #00c7b1 border, #f0efe9 text. Sub-header and metadata surfaces both use #011526.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ SECTION 3 — TABLET (768 px) ══════════════════════════════════════════ -->
|
||||
<div class="section">
|
||||
<div class="sh">
|
||||
<h2>3 · Tablet (768 px)</h2>
|
||||
<p>
|
||||
The 3-column metadata grid collapses to a single stacked column. The sub-header truncates the document
|
||||
title and moves secondary actions behind a "…" overflow button. Pills remain inline next to person names in both locations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="responsive-grid">
|
||||
<!-- Tablet light -->
|
||||
<div>
|
||||
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Tablet · 768 px · Light</div>
|
||||
<div class="chrome" style="max-width:400px">
|
||||
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||
<div class="app-nav">
|
||||
<div class="app-logo">FAMILIENARCHIV</div>
|
||||
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||
</div>
|
||||
<!-- Tablet sub-header: back + title truncated + overflow -->
|
||||
<div class="sub-header-tablet">
|
||||
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#6b7280">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||
</div>
|
||||
<div style="width:1px;height:16px;background:#E4E2D7;margin:0 5px;flex-shrink:0"></div>
|
||||
<div class="sh-title-truncated">W-0311 · Divacca</div>
|
||||
<div style="display:flex;gap:4px;flex-shrink:0">
|
||||
<div class="sh-btn-primary" style="height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center">Bearbeiten</div>
|
||||
<div class="sh-overflow-btn">···</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stacked metadata — Personen section with pills -->
|
||||
<div class="meta-stacked">
|
||||
<div class="meta-section-head">Personen</div>
|
||||
<div style="margin-bottom:6px">
|
||||
<div class="meta-label">Absender</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||
<div class="p-av" style="background:#012851;width:18px;height:18px;font-size:6px">KR</div>
|
||||
<span style="font-family:'Tinos',serif;font-size:9px;color:#012851">Karl Raddatz</span>
|
||||
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">ELTERNTEIL</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px">
|
||||
<div class="meta-label">Empfänger</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||
<div class="p-av" style="background:#5a2d6f;width:18px;height:18px;font-size:6px">HR</div>
|
||||
<span style="font-family:'Tinos',serif;font-size:9px;color:#012851">Hans Raddatz</span>
|
||||
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KIND</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-section-head">Details</div>
|
||||
<div class="meta-label">Ort</div>
|
||||
<div class="meta-value">Divacca</div>
|
||||
<div class="meta-label" style="margin-top:6px">Status</div>
|
||||
<div class="meta-value">Hochgeladen</div>
|
||||
<div class="meta-label" style="margin-top:6px">Schlagwörter</div>
|
||||
<div style="display:flex;gap:4px;margin-top:3px">
|
||||
<span style="background:#f5f4ef;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
|
||||
<span style="background:#f5f4ef;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf-area" style="min-height:60px">
|
||||
<div class="paper" style="width:55%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="cap">Tablet light. 3-column metadata collapses to single column. Pills stay inline with names. Sub-header shows only title + primary action + overflow menu.</p>
|
||||
</div>
|
||||
|
||||
<!-- Tablet dark -->
|
||||
<div>
|
||||
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Tablet · 768 px · Dark</div>
|
||||
<div class="chrome dark" style="max-width:400px">
|
||||
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||
<div class="app-nav">
|
||||
<div class="app-logo">FAMILIENARCHIV</div>
|
||||
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||
</div>
|
||||
<div class="sub-header-tablet" style="background:#011526;border-bottom:1px solid #0d3358">
|
||||
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#8b97a5">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||
</div>
|
||||
<div style="width:1px;height:16px;background:#0d3358;margin:0 5px;flex-shrink:0"></div>
|
||||
<div style="font-family:'Tinos',serif;font-size:9px;font-weight:700;color:#f0efe9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0">W-0311 · Divacca</div>
|
||||
<div style="display:flex;gap:4px;flex-shrink:0">
|
||||
<div style="height:22px;padding:0 7px;background:#A1DCD8;border-radius:3px;font-size:6.5px;font-weight:700;color:#012851;display:flex;align-items:center">Bearbeiten</div>
|
||||
<div style="width:22px;height:22px;border:1.5px solid #0d3358;border-radius:3px;display:flex;align-items:center;justify-content:center;color:#8b97a5;font-size:9px;font-weight:700">···</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:12px 14px;background:#011526;border-bottom:1px solid #0d3358;font-size:9px">
|
||||
<div style="font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#8b97a5;margin-bottom:8px">Personen</div>
|
||||
<div style="margin-bottom:6px">
|
||||
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Absender</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||
<div style="width:18px;height:18px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">KR</div>
|
||||
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9">Karl Raddatz</span>
|
||||
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px">ELTERNTEIL</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px">
|
||||
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Empfänger</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||
<div style="width:18px;height:18px;border-radius:50%;background:#5a2d6f;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">HR</div>
|
||||
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9">Hans Raddatz</span>
|
||||
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px">KIND</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.09em;color:#8b97a5;margin-bottom:8px">Details</div>
|
||||
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Ort</div>
|
||||
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Divacca</div>
|
||||
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Status</div>
|
||||
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Hochgeladen</div>
|
||||
<div style="font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Schlagwörter</div>
|
||||
<div style="display:flex;gap:4px;margin-top:3px">
|
||||
<span style="background:#011a30;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
|
||||
<span style="background:#011a30;padding:2px 6px;border-radius:2px;font-size:7px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf-area" style="min-height:60px">
|
||||
<div class="paper" style="width:55%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:80%"></div><div class="ps" style="width:65%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="cap">Tablet dark. Same collapse behaviour. Dark pill tokens apply throughout.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ SECTION 4 — MOBILE (375 px) ══════════════════════════════════════════ -->
|
||||
<div class="section">
|
||||
<div class="sh">
|
||||
<h2>4 · Mobile (375 px)</h2>
|
||||
<p>
|
||||
Sub-header is simplified to back arrow and document title only — no person chips in the bar.
|
||||
Metadata is full-width single column. Each person row is <code>flex; align-items: center; flex-wrap: nowrap</code>
|
||||
— avatar, name, and pill on one line. If the name is very long the row wraps gracefully before the pill.
|
||||
Only primary action buttons are shown.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="responsive-grid">
|
||||
<!-- Mobile light -->
|
||||
<div>
|
||||
<div class="screen-lbl"><span class="lbl-dot" style="background:#A1DCD8;border:1px solid #ccc"></span>Mobile · 375 px · Light</div>
|
||||
<div class="chrome" style="max-width:260px">
|
||||
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||
<div class="app-nav">
|
||||
<div class="app-logo">FAMILIENARCHIV</div>
|
||||
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||
</div>
|
||||
<!-- Mobile sub-header: back + title only -->
|
||||
<div class="sub-header-mobile">
|
||||
<div class="back-btn" style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#6b7280;flex-shrink:0">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||
</div>
|
||||
<div style="width:1px;height:16px;background:#E4E2D7;margin:0 5px;flex-shrink:0"></div>
|
||||
<div class="sh-title-mobile">W-0311 · Divacca</div>
|
||||
<div style="height:22px;padding:0 7px;background:#012851;border-radius:3px;font-size:6.5px;font-weight:700;color:#A1DCD8;display:flex;align-items:center;flex-shrink:0">Bearbeiten</div>
|
||||
</div>
|
||||
<!-- Mobile metadata: full-width stacked -->
|
||||
<div class="meta-mobile">
|
||||
<div class="m-label">Absender</div>
|
||||
<div class="person-row-mobile">
|
||||
<div class="p-av-sm" style="background:#012851">KR</div>
|
||||
<span class="p-nm">Karl Raddatz</span>
|
||||
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px;white-space:nowrap">ELTERNTEIL</span>
|
||||
</div>
|
||||
<div class="m-label">Empfänger</div>
|
||||
<div class="person-row-mobile">
|
||||
<div class="p-av-sm" style="background:#5a2d6f">HR</div>
|
||||
<span class="p-nm">Hans Raddatz</span>
|
||||
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px;white-space:nowrap">KIND</span>
|
||||
</div>
|
||||
<div class="m-label">Ort</div>
|
||||
<div class="m-value">Divacca</div>
|
||||
<div class="m-label">Status</div>
|
||||
<div class="m-value">Hochgeladen</div>
|
||||
<div class="m-label">Schlagwörter</div>
|
||||
<div style="display:flex;gap:3px;margin-top:3px;flex-wrap:wrap">
|
||||
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">Familie</span>
|
||||
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">1923</span>
|
||||
<span style="background:#f5f4ef;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#012851">Berlin</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf-area" style="min-height:60px">
|
||||
<div class="paper" style="width:60%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:78%"></div><div class="ps" style="width:62%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="cap">Mobile light. No chips in sub-header — only title + primary action. Person rows: avatar + name + pill, flex-wrap:nowrap. Pill text drops to 6px to fit.</p>
|
||||
</div>
|
||||
|
||||
<!-- Mobile dark -->
|
||||
<div>
|
||||
<div class="screen-lbl"><span class="lbl-dot" style="background:#1a2a3a"></span>Mobile · 375 px · Dark</div>
|
||||
<div class="chrome dark" style="max-width:260px">
|
||||
<div class="chrome-bar"><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-url"></div></div>
|
||||
<div class="app-nav">
|
||||
<div class="app-logo">FAMILIENARCHIV</div>
|
||||
<div class="app-nav-r"><div class="app-av">MR</div></div>
|
||||
</div>
|
||||
<div style="height:48px;background:#011526;border-bottom:1px solid #0d3358;display:flex;align-items:center;padding:0 10px;gap:5px;flex-shrink:0">
|
||||
<div style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:#8b97a5;flex-shrink:0">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||
</div>
|
||||
<div style="width:1px;height:16px;background:#0d3358;margin:0 5px;flex-shrink:0"></div>
|
||||
<div style="font-family:'Tinos',serif;font-size:9px;font-weight:700;color:#f0efe9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0">W-0311 · Divacca</div>
|
||||
<div style="height:22px;padding:0 7px;background:#A1DCD8;border-radius:3px;font-size:6.5px;font-weight:700;color:#012851;display:flex;align-items:center;flex-shrink:0">Bearbeiten</div>
|
||||
</div>
|
||||
<div style="padding:10px 12px;background:#011526;border-bottom:1px solid #0d3358;font-size:8.5px">
|
||||
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Absender</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;margin-bottom:2px">
|
||||
<div style="width:18px;height:18px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">KR</div>
|
||||
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;white-space:nowrap">Karl Raddatz</span>
|
||||
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px;white-space:nowrap">ELTERNTEIL</span>
|
||||
</div>
|
||||
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px;margin-top:6px">Empfänger</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;margin-bottom:8px">
|
||||
<div style="width:18px;height:18px;border-radius:50%;background:#5a2d6f;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:#fff;flex-shrink:0">HR</div>
|
||||
<span style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;white-space:nowrap">Hans Raddatz</span>
|
||||
<span style="display:inline-block;background:rgba(0,199,177,.10);border:1px solid #00c7b1;border-radius:9999px;padding:1px 5px;font-family:'Montserrat',sans-serif;font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#f0efe9;vertical-align:middle;margin-left:4px;white-space:nowrap">KIND</span>
|
||||
</div>
|
||||
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Ort</div>
|
||||
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Divacca</div>
|
||||
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:2px">Status</div>
|
||||
<div style="font-family:'Tinos',serif;font-size:9px;color:#f0efe9;margin-bottom:6px">Hochgeladen</div>
|
||||
<div style="font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#8b97a5;margin-bottom:3px">Schlagwörter</div>
|
||||
<div style="display:flex;gap:3px;margin-top:3px;flex-wrap:wrap">
|
||||
<span style="background:#011a30;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">Familie</span>
|
||||
<span style="background:#011a30;padding:2px 5px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;color:#A1DCD8">1923</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf-area" style="min-height:60px">
|
||||
<div class="paper" style="width:60%"><div class="pl"></div><div class="ps"></div><div class="pl" style="width:78%"></div><div class="ps" style="width:62%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="cap">Mobile dark. Pill tokens #00c7b1/#f0efe9 at reduced 6px font — still passes AA on dark surface.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ SECTION 5 — EDGE CASES ════════════════════════════════════════════════ -->
|
||||
<div class="section">
|
||||
<div class="sh">
|
||||
<h2>5 · Edge cases — when no pill is rendered</h2>
|
||||
<p>Three cases where the pill is silently omitted. The person name renders as normal — no gap, no placeholder.</p>
|
||||
</div>
|
||||
|
||||
<div class="edge-grid">
|
||||
|
||||
<!-- Edge 1: no family relationship -->
|
||||
<div class="edge-card">
|
||||
<div class="edge-head">No family relationship → no pill</div>
|
||||
<div class="edge-body">
|
||||
<div class="meta-field">
|
||||
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||
<div class="p-av" style="background:#012851">KR</div>
|
||||
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
|
||||
<!-- no pill -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-field">
|
||||
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||
<div class="p-av" style="background:#888">ME</div>
|
||||
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Maria Engel</span>
|
||||
<!-- no pill -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="no-badge">— no pill —</div>
|
||||
<div class="edge-note"><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">inferredRelationship === null</code> because the backend returns 404 (no kinship path). Name renders without trailing pill.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edge 2: social relationship (Kollegen) → pill shows label -->
|
||||
<div class="edge-card">
|
||||
<div class="edge-head">Social relationship (Kollegen) → pill shows label</div>
|
||||
<div class="edge-body">
|
||||
<div class="meta-field">
|
||||
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||
<div class="p-av" style="background:#012851">KR</div>
|
||||
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
|
||||
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KOLLEGE</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-field">
|
||||
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||
<div class="p-av" style="background:#3d6b5a">FW</div>
|
||||
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Fritz Weber</span>
|
||||
<span style="display:inline-block;background:rgba(161,220,216,.25);border:1px solid #a1dcd8;border-radius:9999px;padding:1px 6px;font-family:'Montserrat',sans-serif;font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#012851;vertical-align:middle;margin-left:4px">KOLLEGE</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edge-note">Non-family relationships (Kollege, Freund, etc.) returned by the inference endpoint still render as pills. The pill component is label-agnostic — it renders whatever <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">inferredRelationship</code> provides.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edge 3: multiple receivers → no pill -->
|
||||
<div class="edge-card">
|
||||
<div class="edge-head">Multiple receivers → no pill</div>
|
||||
<div class="edge-body">
|
||||
<div class="meta-field">
|
||||
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Absender</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||
<div class="p-av" style="background:#012851">KR</div>
|
||||
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Karl Raddatz</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-field">
|
||||
<div class="meta-label" style="font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#6b7280;margin-bottom:3px">Empfänger</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||
<div class="p-av" style="background:#5a2d6f">HR</div>
|
||||
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Hans Raddatz</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 0">
|
||||
<div class="p-av" style="background:#6a7a52">ER</div>
|
||||
<span style="font-family:'Tinos',serif;font-size:9.5px;color:#012851">Elfriede Raddatz</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="no-badge">— no pill —</div>
|
||||
<div class="edge-note"><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">receivers.length > 1</code> — inference endpoint is never called, <code>inferredRelationship</code> is <code>null</code>. No pill on any person chip.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ SECTION 6 — IMPLEMENTATION REFERENCE TABLE ═══════════════════════════ -->
|
||||
<div class="section">
|
||||
<div class="sh">
|
||||
<h2>6 · Implementation reference</h2>
|
||||
<p>Exact CSS/Tailwind values for every element of the pill and its context. Use these as the ground truth during implementation review.</p>
|
||||
</div>
|
||||
|
||||
<div class="rules">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Element</th>
|
||||
<th>Tailwind / CSS</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Inline pill (light)</td>
|
||||
<td><code>rounded-full border border-[#a1dcd8] bg-[rgba(161,220,216,.25)] px-2 py-0.5 text-[9px] font-bold uppercase tracking-[.07em] text-[#012851] ml-2 align-middle inline</code></td>
|
||||
<td>Montserrat 9px 700. <code>ml-2</code> = 8px from name span. <code>vertical-align: middle</code> aligns cap-height to person name.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Inline pill (dark)</td>
|
||||
<td><code>dark:bg-[rgba(0,199,177,.10)] dark:border-[#00c7b1] dark:text-[#f0efe9]</code></td>
|
||||
<td>All three dark overrides applied together. Rest of pill class unchanged.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Person name span</td>
|
||||
<td><code>font-sans text-[11px] text-[#4b5563] dark:text-[#9ca3af]</code> (sub-header) or <code>font-serif text-[9.5px] text-ink dark:text-[#f0efe9]</code> (metadata)</td>
|
||||
<td>Name and pill share a <code>flex items-center gap-0</code> wrapper. Pill is the immediate next sibling of the name <code><span></code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sub-header chip area</td>
|
||||
<td><code>flex items-center gap-1.5</code></td>
|
||||
<td>Wraps one sender chip + arrow + one receiver chip. Placed after the doc-title block, before action buttons.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Chip (avatar + name + pill)</td>
|
||||
<td><code>flex items-center gap-1</code></td>
|
||||
<td>Avatar, name span, and pill as three siblings inside the chip div.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Arrow between chips (sub-header)</td>
|
||||
<td><code>h-2.5 w-2.5 shrink-0 text-[#a1dcd8] dark:text-[#00c7b1]</code> with <code>aria-hidden="true"</code></td>
|
||||
<td>Arrow SVG carries no semantic information. DOM order (sender chip before receiver chip) conveys direction for assistive technology.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Person avatar (sub-header)</td>
|
||||
<td><code>w-5 h-5 rounded-full flex items-center justify-center text-[6px] font-bold text-white shrink-0</code></td>
|
||||
<td>20×20 px. Initials in 6px bold white. Background colour is person-specific (set inline).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Person avatar (metadata)</td>
|
||||
<td><code>w-5 h-5 rounded-full flex items-center justify-center text-[6.5px] font-extrabold text-white shrink-0</code></td>
|
||||
<td>Same 20×20 px. Slightly heavier weight (800) to match existing drawer card style.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Pill condition</td>
|
||||
<td><code>{#if inferredRelationship} … {/if}</code> wraps both the sender pill and the receiver pill</td>
|
||||
<td>Render only when <code>inferredRelationship !== null && receivers.length === 1</code>. The check lives in <code>+page.server.ts</code>, not in the component.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Pill label value</td>
|
||||
<td><code>inferredRelationship.labelFromA</code> next to sender, <code>inferredRelationship.labelFromB</code> next to receiver</td>
|
||||
<td>Labels are pre-translated strings from the backend. No frontend i18n key needed for the label text itself.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Mobile person row</td>
|
||||
<td><code>flex items-center gap-1 flex-nowrap</code></td>
|
||||
<td><code>flex-wrap: nowrap</code> keeps avatar + name + pill on one line. If name overflows container, truncate name with <code>truncate</code>, never truncate the pill.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Mobile pill font-size</td>
|
||||
<td><code>text-[6px]</code> at ≤375 px</td>
|
||||
<td>Reduced from 9px (desktop) to 6px on mobile to fit without overflow. Contrast still passes AA at 6px bold.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sub-header at mobile</td>
|
||||
<td>Chips removed entirely from sub-header at <code>max-width: 767px</code></td>
|
||||
<td>Sub-header shows only back arrow + document title + primary action button. Person chips with pills appear only in the metadata section on mobile.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p style="font-size:10.5px;color:#888;margin-top:12px;line-height:1.7">
|
||||
<strong style="color:#012851">Accessibility note:</strong> The pill text ("ELTERNTEIL", "KIND") is uppercase visually but the accessible name should be the mixed-case label from the backend (<code>labelFromA</code>). Apply <code>aria-label={labelFromA}</code> on the pill span so screen readers announce "Elternteil" not "E-L-T-E-R-N-T-E-I-L". The visual uppercase is achieved with CSS <code>text-transform: uppercase</code>, not by changing the source string.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
</div><!-- /doc -->
|
||||
</body>
|
||||
</html>
|
||||
1135
docs/specs/stammbaum-person-edit-spec.html
Normal file
1135
docs/specs/stammbaum-person-edit-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
1043
docs/specs/stammbaum-tree-spec.html
Normal file
1043
docs/specs/stammbaum-tree-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ bun.lockb
|
||||
# Build artifacts
|
||||
/.svelte-kit/
|
||||
/.svelte-kit-backup/
|
||||
/.svelte-kit.old/
|
||||
|
||||
# Generated files
|
||||
/.svelte-kit-backup/
|
||||
@@ -22,5 +23,6 @@ bun.lockb
|
||||
|
||||
# Test artifacts
|
||||
/test-results/
|
||||
/test-results.locked/
|
||||
/e2e/.auth/
|
||||
/coverage/
|
||||
|
||||
197
frontend/CLAUDE.md
Normal file
197
frontend/CLAUDE.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Frontend — Familienarchiv
|
||||
|
||||
## Overview
|
||||
|
||||
SvelteKit 2 application providing the Familienarchiv web UI. Server-side rendered (SSR) where beneficial, with client-side interactivity for document viewing, transcription, annotation, and admin workflows.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: SvelteKit 2 with Svelte 5 (runes mode)
|
||||
- **Language**: TypeScript 5.9
|
||||
- **Styling**: Tailwind CSS 4.1 + custom brand utilities
|
||||
- **Build Tool**: Vite 7
|
||||
- **Adapter**: `@sveltejs/adapter-node` (Node.js server, not static)
|
||||
- **i18n**: Paraglide.js 2.5 (`@inlang/paraglide-js`) — German (default), English, Spanish
|
||||
- **API Client**: `openapi-fetch` + `openapi-typescript` (generated from backend OpenAPI spec)
|
||||
- **PDF Rendering**: `pdfjs-dist` (PDF.js)
|
||||
- **Testing**:
|
||||
- Unit/Server: Vitest 4 (Node environment)
|
||||
- Component: Vitest Browser Mode with Playwright (Chromium)
|
||||
- E2E: Playwright (`frontend/e2e/`)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── routes/ # SvelteKit file-based routing
|
||||
│ ├── +layout.svelte # Global layout: header, nav, auth state
|
||||
│ ├── +layout.server.ts # Loads current user, injects auth cookie
|
||||
│ ├── +page.svelte # Home / document search dashboard
|
||||
│ ├── documents/ # Document CRUD, detail, edit, upload
|
||||
│ ├── persons/ # Person directory, detail, edit, merge
|
||||
│ ├── briefwechsel/ # Bilateral conversation timeline
|
||||
│ ├── chronik/ # Unified activity feed
|
||||
│ ├── admin/ # User, group, tag, OCR, system management
|
||||
│ ├── api/ # Internal API proxies (server-side only)
|
||||
│ ├── login/ logout/ # Auth pages
|
||||
│ └── ...
|
||||
├── lib/
|
||||
│ ├── components/ # Reusable Svelte components
|
||||
│ │ ├── document/ # Document-specific components
|
||||
│ │ ├── chronik/ # Activity feed components
|
||||
│ │ └── user/ # User-related components
|
||||
│ ├── generated/ # Auto-generated API types (openapi-typescript)
|
||||
│ ├── server/ # Server-only utilities (db, auth helpers)
|
||||
│ ├── services/ # Client-side service logic
|
||||
│ ├── stores/ # Svelte stores (global state)
|
||||
│ ├── types.ts # Shared TypeScript types
|
||||
│ ├── errors.ts # Error code mapping (mirrors backend ErrorCode)
|
||||
│ ├── api.server.ts # Typed API client factory
|
||||
│ ├── utils.ts # Shared utilities
|
||||
│ ├── relativeTime.ts # Time formatting
|
||||
│ ├── search.ts # Search utilities
|
||||
│ └── paraglide/ # Generated i18n code
|
||||
├── hooks/ # SvelteKit hooks (handle, handleFetch)
|
||||
└── actions/ # Custom Svelte actions (click outside, etc.)
|
||||
```
|
||||
|
||||
## API Client Pattern
|
||||
|
||||
All server-side API calls use the typed client from `$lib/api.server.ts`:
|
||||
|
||||
```typescript
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
|
||||
|
||||
// Always check via response.ok, NOT result.error
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
}
|
||||
return { person: result.data! };
|
||||
```
|
||||
|
||||
Key rules:
|
||||
|
||||
- Use `!result.response.ok` for error checking (not `if (result.error)` — breaks when spec has no error responses defined)
|
||||
- Cast errors as `result.error as unknown as { code?: string }` to extract backend error code
|
||||
- Use `result.data!` after an ok check
|
||||
|
||||
For multipart/form-data (file uploads), bypass the typed client and use raw `fetch`.
|
||||
|
||||
## Form Actions Pattern
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
export const actions = {
|
||||
default: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
// ...
|
||||
return fail(400, { error: 'message' }); // on error
|
||||
throw redirect(303, '/target'); // on success
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Date Handling
|
||||
|
||||
- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `<input type="hidden" name="documentDate" value={dateIso}>` sends ISO to the backend.
|
||||
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC off-by-one:
|
||||
```typescript
|
||||
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(
|
||||
new Date(doc.documentDate + 'T12:00:00')
|
||||
);
|
||||
```
|
||||
|
||||
## Styling Conventions (Tailwind CSS 4)
|
||||
|
||||
Brand color utilities (defined in `layout.css`):
|
||||
|
||||
| Class | Value | Usage |
|
||||
| ------------ | --------- | -------------------------------- |
|
||||
| `brand-navy` | `#002850` | Primary text, buttons, headers |
|
||||
| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons |
|
||||
| `brand-sand` | `#E4E2D7` | Page background, card borders |
|
||||
|
||||
Typography:
|
||||
|
||||
- `font-serif` (Merriweather) — body text, document titles, names
|
||||
- `font-sans` (Montserrat) — labels, metadata, UI chrome
|
||||
|
||||
Card pattern for content sections:
|
||||
|
||||
```svelte
|
||||
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6">
|
||||
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">Section</h2>
|
||||
<!-- content -->
|
||||
</div>
|
||||
```
|
||||
|
||||
## Key UI Components
|
||||
|
||||
| Component | Props | Description |
|
||||
| -------------------- | ---------------------------------------------------- | ------------------------------------- |
|
||||
| `PersonTypeahead` | `name`, `label`, `value`, `initialName`, `on:change` | Single-person selector with typeahead |
|
||||
| `PersonMultiSelect` | `selectedPersons` (bind) | Chip-based multi-person selector |
|
||||
| `TagInput` | `tags` (bind), `allowCreation?`, `on:change` | Tag chip input with typeahead |
|
||||
| `PdfViewer` | `url`, `annotations`, `on:annotation` | PDF rendering with annotation overlay |
|
||||
| `TranscriptionBlock` | `block`, `mode` | Read/edit transcription block |
|
||||
| `DocumentTopBar` | `document` | Responsive document metadata header |
|
||||
|
||||
## How to Run
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Dev server on port 5173 (or 3000 if --port 3000)
|
||||
```
|
||||
|
||||
### Build & Preview
|
||||
|
||||
```bash
|
||||
npm run build # Production build
|
||||
npm run preview # Preview production build
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
npm run lint # Prettier + ESLint check
|
||||
npm run format # Auto-fix formatting
|
||||
npm run check # svelte-check (type checking)
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
npm run test # Vitest unit + server tests (headless)
|
||||
npm run test:coverage # Coverage report (server project only)
|
||||
npm run test:e2e # Playwright E2E tests
|
||||
npm run test:e2e:headed # Playwright E2E with visible browser
|
||||
npm run test:e2e:ui # Playwright UI mode
|
||||
```
|
||||
|
||||
### Regenerate API Types
|
||||
|
||||
Requires backend running with `--spring.profiles.active=dev`:
|
||||
|
||||
```bash
|
||||
npm run generate:api
|
||||
```
|
||||
|
||||
## Vite Proxy
|
||||
|
||||
During development, `/api` calls are proxied to the Spring Boot backend. The proxy injects the `Authorization` header from the `auth_token` cookie automatically (see `vite.config.ts`).
|
||||
|
||||
## i18n (Paraglide)
|
||||
|
||||
Translations live in `messages/{de,en,es}.json`. The compiler generates type-safe helpers in `src/lib/paraglide/`. Run compilation manually with:
|
||||
|
||||
```bash
|
||||
npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
||||
```
|
||||
|
||||
Or let the Vite plugin handle it automatically during dev/build.
|
||||
141
frontend/e2e/CLAUDE.md
Normal file
141
frontend/e2e/CLAUDE.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# E2E Tests — Familienarchiv
|
||||
|
||||
## Overview
|
||||
|
||||
End-to-end tests for the Familienarchiv frontend using Playwright. These tests verify complete user flows across the full stack (SvelteKit frontend + Spring Boot backend + PostgreSQL + MinIO).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Test Runner**: Playwright (`@playwright/test`)
|
||||
- **Browser**: Chromium (desktop)
|
||||
- **Locale**: `de-DE` (ensures German language detection)
|
||||
- **Auth**: Shared session cookie stored after setup
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
frontend/e2e/
|
||||
├── auth.setup.ts # Authentication setup — logs in and saves session
|
||||
├── auth.spec.ts # Authentication flows (login, logout, register)
|
||||
├── admin.spec.ts # Admin panel CRUD operations
|
||||
├── annotations.spec.ts # Document annotation features
|
||||
├── bottom-panel.spec.ts # Bottom panel / transcription panel
|
||||
├── dashboard-*.spec.ts # Dashboard variants and screenshots
|
||||
├── documents.spec.ts # Document upload, edit, search
|
||||
├── focus-rings.spec.ts # Accessibility focus ring tests
|
||||
├── header.spec.ts # Navigation header
|
||||
├── history.spec.ts # Chronik / activity feed
|
||||
├── korrespondenz.spec.ts # Correspondence timeline
|
||||
├── lang.spec.ts # Language switching
|
||||
├── password-reset.spec.ts # Password reset flow
|
||||
├── permissions.spec.ts # Role-based access control
|
||||
├── persons.spec.ts # Person directory CRUD
|
||||
├── profile.spec.ts # User profile
|
||||
├── theme.spec.ts # Dark/light mode
|
||||
├── transcription.spec.ts # Transcription workflows
|
||||
├── accessibility.spec.ts # Axe accessibility scans
|
||||
├── fixtures/ # Test data fixtures
|
||||
└── helpers/ # Test helper utilities
|
||||
```
|
||||
|
||||
## Authentication Strategy
|
||||
|
||||
Tests share auth state via a stored session cookie:
|
||||
|
||||
1. **Setup** (`auth.setup.ts`): Logs in with test credentials and saves `storageState` to `e2e/.auth/user.json`
|
||||
2. **Tests**: All test projects depend on `setup` and reuse the stored session
|
||||
|
||||
This avoids re-logging in for every test, but means tests **must run sequentially** (`fullyParallel: false`, `workers: 1`).
|
||||
|
||||
## Configuration
|
||||
|
||||
Config lives in `frontend/playwright.config.ts`:
|
||||
|
||||
| Setting | Value | Notes |
|
||||
| --------------- | ----------------------- | ------------------------------ |
|
||||
| `testDir` | `./e2e` | Test file location |
|
||||
| `fullyParallel` | `false` | Shared auth state |
|
||||
| `workers` | `1` | Sequential execution |
|
||||
| `screenshot` | `'on'` | Always capture |
|
||||
| `video` | `'retain-on-failure'` | Keep on failure |
|
||||
| `trace` | `'retain-on-failure'` | Keep on failure |
|
||||
| `baseURL` | `http://localhost:3000` | Overridable via `E2E_BASE_URL` |
|
||||
|
||||
The `webServer` config auto-starts `npm run dev -- --port 3000` if no server is detected at the base URL.
|
||||
|
||||
## How to Run
|
||||
|
||||
### Prerequisites
|
||||
|
||||
The full stack must be running (or the `webServer` config will start the frontend dev server):
|
||||
|
||||
```bash
|
||||
# Start infrastructure
|
||||
docker-compose up -d
|
||||
|
||||
# Ensure backend is healthy
|
||||
curl http://localhost:8080/actuator/health
|
||||
```
|
||||
|
||||
### Run E2E Tests
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Headless (CI mode)
|
||||
npm run test:e2e
|
||||
|
||||
# With visible browser
|
||||
npm run test:e2e:headed
|
||||
|
||||
# Interactive UI mode
|
||||
npm run test:e2e:ui
|
||||
|
||||
# Run a specific test file
|
||||
npx playwright test documents.spec.ts
|
||||
|
||||
# Run with a different base URL (e.g., docker frontend on 5173)
|
||||
E2E_BASE_URL=http://localhost:5173 npx playwright test
|
||||
```
|
||||
|
||||
## Writing New E2E Tests
|
||||
|
||||
1. Create a new `.spec.ts` file in `frontend/e2e/`
|
||||
2. Use the shared auth state (no manual login needed)
|
||||
3. Use page object patterns or helper functions from `helpers/`
|
||||
4. Add `test-data-id` attributes to components for stable selectors
|
||||
5. Run with `--debug` or `--ui` to troubleshoot
|
||||
|
||||
### Example Test Pattern
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('user can create a document', async ({ page }) => {
|
||||
await page.goto('/documents/new');
|
||||
await page.getByTestId('document-title').fill('Test Document');
|
||||
await page.getByTestId('save-button').click();
|
||||
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||
});
|
||||
```
|
||||
|
||||
## Accessibility Testing
|
||||
|
||||
`accessibility.spec.ts` runs Axe scans on key pages. Violations fail the test.
|
||||
|
||||
```bash
|
||||
npx playwright test accessibility.spec.ts
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
| --------------------- | ---------------------------------------- |
|
||||
| Auth failures | Delete `e2e/.auth/user.json` and re-run |
|
||||
| Backend not reachable | Ensure `docker-compose up -d` is running |
|
||||
| Flaky tests | Increase timeout or add explicit waits |
|
||||
| Screenshots missing | Check `test-results/e2e/` |
|
||||
|
||||
## CI Integration
|
||||
|
||||
E2E tests are **not** currently run in CI (the pipeline stops at unit/component tests). To add them, extend `infra/gitea/workflows/ci.yml` with a Playwright job that starts the full Docker Compose stack first.
|
||||
151
frontend/e2e/geschichten.spec.ts
Normal file
151
frontend/e2e/geschichten.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
163
frontend/e2e/person-mention-read.spec.ts
Normal file
163
frontend/e2e/person-mention-read.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { test, expect, devices } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||
const STORAGE_STATE = path.resolve(__dirname, '.auth/user.json');
|
||||
|
||||
/**
|
||||
* E2E for issue #362 — Person @mentions, read-mode rendering + hover card (B20/B21).
|
||||
*
|
||||
* Strategy:
|
||||
* - Create a document, a Person, and a transcription block whose text contains
|
||||
* `@DisplayName` and whose mentionedPersons sidecar links to that person.
|
||||
* - Open the document in read mode.
|
||||
* - B20: page.hover() on the .person-mention link → hover card mounts.
|
||||
* - B21: with context.setHasTouch(true), page.tap() on the link → navigates
|
||||
* to /persons/{id} without ever showing the hover card.
|
||||
*/
|
||||
|
||||
let docId: string;
|
||||
let personId: string;
|
||||
let docHref: string;
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Person mention — read mode', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||
|
||||
// 1. Person we will mention.
|
||||
const personRes = await request.post('/api/persons', {
|
||||
data: {
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
personType: 'PERSON',
|
||||
birthYear: 1882,
|
||||
deathYear: 1944
|
||||
}
|
||||
});
|
||||
if (!personRes.ok()) throw new Error(`Create person failed: ${personRes.status()}`);
|
||||
const person = await personRes.json();
|
||||
personId = person.id;
|
||||
|
||||
// 2. Document with a PDF so the transcription panel is mountable.
|
||||
// Sara #3: timestamp the title so a previous run that crashed in beforeAll
|
||||
// (and therefore skipped afterAll cleanup) cannot collide with this one.
|
||||
const uniqueSuffix = Date.now();
|
||||
const docRes = await request.post('/api/documents', {
|
||||
multipart: {
|
||||
title: `E2E Person Mention Read ${uniqueSuffix}`,
|
||||
documentDate: '1945-05-08'
|
||||
}
|
||||
});
|
||||
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
|
||||
const doc = await docRes.json();
|
||||
docId = doc.id;
|
||||
docHref = `${baseURL}/documents/${docId}`;
|
||||
|
||||
await request.put(`/api/documents/${docId}`, {
|
||||
multipart: {
|
||||
title: doc.title as string,
|
||||
documentDate: '1945-05-08',
|
||||
file: {
|
||||
name: 'minimal.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Annotation to anchor the block on the page.
|
||||
const annRes = await request.post(`/api/documents/${docId}/annotations`, {
|
||||
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.5, height: 0.1, color: '#00C7B1' }
|
||||
});
|
||||
if (!annRes.ok()) throw new Error(`Create annotation failed: ${annRes.status()}`);
|
||||
|
||||
// 4. Block text contains @Auguste Raddatz; sidecar links it to personId.
|
||||
const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||
data: {
|
||||
pageNumber: 1,
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.5,
|
||||
height: 0.1,
|
||||
text: 'Brief an @Auguste Raddatz vom Mai 1944',
|
||||
label: null,
|
||||
mentionedPersons: [{ personId, displayName: 'Auguste Raddatz' }]
|
||||
}
|
||||
});
|
||||
if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (docId) await request.delete(`/api/documents/${docId}`);
|
||||
if (personId) await request.delete(`/api/persons/${personId}`);
|
||||
});
|
||||
|
||||
test('renders the @mention as an underlined anchor link to /persons/{id}', async ({ page }) => {
|
||||
await page.goto(docHref);
|
||||
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||
await expect(link).toBeVisible({ timeout: 5000 });
|
||||
await expect(link).toHaveAttribute('href', `/persons/${personId}`);
|
||||
// The @ trigger is stripped from the rendered text per spec
|
||||
await expect(link).toHaveText('Auguste Raddatz');
|
||||
});
|
||||
|
||||
test('B20: desktop hover mounts the hover card with loaded person data', async ({ page }) => {
|
||||
await page.goto(docHref);
|
||||
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||
await link.hover();
|
||||
|
||||
const card = page.getByTestId('person-hover-card');
|
||||
await expect(card).toBeVisible({ timeout: 5000 });
|
||||
// Loaded state: person displayName is rendered inside the card
|
||||
await expect(page.getByTestId('person-hover-card-name')).toHaveText('Auguste Raddatz');
|
||||
// Footer link points to /persons/{id}
|
||||
await expect(card.locator(`a[href="/persons/${personId}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('B20: hover card unmounts on mouseleave', async ({ page }) => {
|
||||
await page.goto(docHref);
|
||||
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||
await link.hover();
|
||||
await expect(page.getByTestId('person-hover-card')).toBeVisible();
|
||||
|
||||
// Move pointer away
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByTestId('person-hover-card')).toBeHidden({ timeout: 2000 });
|
||||
});
|
||||
|
||||
test('B21: touch-device tap navigates without showing the hover card', async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
...devices['Pixel 7'],
|
||||
storageState: STORAGE_STATE
|
||||
});
|
||||
const touchPage = await context.newPage();
|
||||
try {
|
||||
await touchPage.goto(docHref);
|
||||
await touchPage.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
const link = touchPage.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||
await expect(link).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Sara #2: assert no card *before* the tap so the test actually proves
|
||||
// the touch device suppression worked, not just that we navigated away.
|
||||
await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0);
|
||||
|
||||
await link.tap();
|
||||
// The card never mounted — the tap navigated directly per spec.
|
||||
await expect(touchPage).toHaveURL(new RegExp(`/persons/${personId}`));
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
202
frontend/e2e/person-typeahead.spec.ts
Normal file
202
frontend/e2e/person-typeahead.spec.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* E2E regression tests for PersonTypeahead dropdown visibility.
|
||||
*
|
||||
* These tests verify that the dropdown list is never clipped by a parent
|
||||
* container's stacking context — the root cause of issue #343.
|
||||
*
|
||||
* The tests run at both desktop (1280×720) and tablet (768×1024) viewports
|
||||
* as required by the acceptance criteria.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Find a document edit URL to use as the test page.
|
||||
* Falls back to /documents/new if no existing document is found.
|
||||
*/
|
||||
async function getDocumentEditUrl(page: Page): Promise<string> {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const firstDocLink = page.locator('a[href^="/documents/"]').first();
|
||||
const href = await firstDocLink.getAttribute('href').catch(() => null);
|
||||
if (href) {
|
||||
return `${href}/edit`;
|
||||
}
|
||||
return '/documents/new';
|
||||
}
|
||||
|
||||
/** Wait for the listbox to become visible after triggering a search. */
|
||||
async function waitForListbox(page: Page): Promise<void> {
|
||||
await page.waitForSelector('[role="listbox"]', { state: 'visible', timeout: 2000 });
|
||||
}
|
||||
|
||||
test.describe('PersonTypeahead — dropdown visibility (desktop)', () => {
|
||||
test.use({ viewport: { width: 1280, height: 720 } });
|
||||
|
||||
test('sender dropdown items are visible and not clipped in document edit', async ({ page }) => {
|
||||
const editUrl = await getDocumentEditUrl(page);
|
||||
await page.goto(editUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Find the sender typeahead input (the visible text input, not the hidden one)
|
||||
const senderInput = page.locator('#senderId-search');
|
||||
await expect(senderInput).toBeVisible();
|
||||
|
||||
// Type to trigger the dropdown
|
||||
await senderInput.click();
|
||||
await senderInput.fill('a');
|
||||
|
||||
// Wait for the dropdown to appear (handles debounce automatically)
|
||||
await waitForListbox(page);
|
||||
|
||||
const dropdown = page.locator('[role="listbox"]').first();
|
||||
await expect(dropdown).toBeVisible();
|
||||
|
||||
const firstOption = dropdown.locator('[role="option"]').first();
|
||||
await expect(firstOption).toBeVisible();
|
||||
|
||||
// Verify the bounding box is within the viewport (not clipped)
|
||||
const box = await firstOption.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.y).toBeGreaterThan(0);
|
||||
expect(box!.y + box!.height).toBeLessThan(720);
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-desktop.png' });
|
||||
});
|
||||
|
||||
test('dropdown is positioned below the input field (not hidden behind parent)', async ({
|
||||
page
|
||||
}) => {
|
||||
const editUrl = await getDocumentEditUrl(page);
|
||||
await page.goto(editUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const senderInput = page.locator('#senderId-search');
|
||||
await expect(senderInput).toBeVisible();
|
||||
|
||||
const inputBox = await senderInput.boundingBox();
|
||||
expect(inputBox).not.toBeNull();
|
||||
|
||||
await senderInput.click();
|
||||
await senderInput.fill('a');
|
||||
await waitForListbox(page);
|
||||
|
||||
const dropdown = page.locator('[role="listbox"]').first();
|
||||
await expect(dropdown).toBeVisible();
|
||||
|
||||
const dropdownBox = await dropdown.boundingBox();
|
||||
expect(dropdownBox).not.toBeNull();
|
||||
|
||||
// Dropdown must appear below the input, not on top or clipped behind it
|
||||
expect(dropdownBox!.y).toBeGreaterThanOrEqual(inputBox!.y + inputBox!.height - 5);
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-position.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('PersonTypeahead — dropdown visibility (tablet)', () => {
|
||||
test.use({ viewport: { width: 768, height: 1024 } });
|
||||
|
||||
test('sender dropdown items are visible and not clipped on tablet viewport', async ({ page }) => {
|
||||
const editUrl = await getDocumentEditUrl(page);
|
||||
await page.goto(editUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const senderInput = page.locator('#senderId-search');
|
||||
await expect(senderInput).toBeVisible();
|
||||
|
||||
await senderInput.click();
|
||||
await senderInput.fill('a');
|
||||
await waitForListbox(page);
|
||||
|
||||
const dropdown = page.locator('[role="listbox"]').first();
|
||||
await expect(dropdown).toBeVisible();
|
||||
|
||||
const firstOption = dropdown.locator('[role="option"]').first();
|
||||
await expect(firstOption).toBeVisible();
|
||||
|
||||
const box = await firstOption.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.y).toBeGreaterThan(0);
|
||||
expect(box!.y + box!.height).toBeLessThan(1024);
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-tablet.png' });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('PersonTypeahead — keyboard navigation', () => {
|
||||
test.use({ viewport: { width: 1280, height: 720 } });
|
||||
|
||||
test('ArrowDown moves focus to the first option', async ({ page }) => {
|
||||
const editUrl = await getDocumentEditUrl(page);
|
||||
await page.goto(editUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const senderInput = page.locator('#senderId-search');
|
||||
await senderInput.click();
|
||||
await senderInput.fill('a');
|
||||
await waitForListbox(page);
|
||||
|
||||
await senderInput.press('ArrowDown');
|
||||
// First option should now be the active descendant
|
||||
const activeDescendant = await senderInput.getAttribute('aria-activedescendant');
|
||||
expect(activeDescendant).toBeTruthy();
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/person-typeahead-keyboard-nav.png' });
|
||||
});
|
||||
|
||||
test('Escape key closes the dropdown', async ({ page }) => {
|
||||
const editUrl = await getDocumentEditUrl(page);
|
||||
await page.goto(editUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const senderInput = page.locator('#senderId-search');
|
||||
await senderInput.click();
|
||||
await senderInput.fill('a');
|
||||
await waitForListbox(page);
|
||||
|
||||
const dropdown = page.locator('[role="listbox"]').first();
|
||||
await expect(dropdown).toBeVisible();
|
||||
await senderInput.press('Escape');
|
||||
await expect(dropdown).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('aria-expanded is true when dropdown is open', async ({ page }) => {
|
||||
const editUrl = await getDocumentEditUrl(page);
|
||||
await page.goto(editUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const senderInput = page.locator('#senderId-search');
|
||||
|
||||
// Initially closed
|
||||
const initialExpanded = await senderInput.getAttribute('aria-expanded');
|
||||
expect(initialExpanded).toBe('false');
|
||||
|
||||
await senderInput.click();
|
||||
await senderInput.fill('a');
|
||||
await waitForListbox(page);
|
||||
|
||||
const expanded = await senderInput.getAttribute('aria-expanded');
|
||||
expect(expanded).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('PersonTypeahead — click-outside dismiss (fixed position)', () => {
|
||||
test.use({ viewport: { width: 1280, height: 720 } });
|
||||
|
||||
test('clicking outside a fixed-position dropdown closes it', async ({ page }) => {
|
||||
const editUrl = await getDocumentEditUrl(page);
|
||||
await page.goto(editUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const senderInput = page.locator('#senderId-search');
|
||||
await senderInput.click();
|
||||
await senderInput.fill('a');
|
||||
await waitForListbox(page);
|
||||
|
||||
const dropdown = page.locator('[role="listbox"]').first();
|
||||
await expect(dropdown).toBeVisible();
|
||||
// Click somewhere else on the page
|
||||
await page.click('body', { position: { x: 10, y: 10 } });
|
||||
await expect(dropdown).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
60
frontend/e2e/stammbaum.spec.ts
Normal file
60
frontend/e2e/stammbaum.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Tests skipped until Playwright Chromium is installed in CI — see issue #363.
|
||||
test.describe('Stammbaum — issue #358', () => {
|
||||
test.skip();
|
||||
|
||||
test('nav swap: /briefwechsel still renders without 404', async ({ page }) => {
|
||||
// Plan journey 4: the /briefwechsel route must stay intact even though the
|
||||
// AppNav now points at /stammbaum.
|
||||
const response = await page.goto('/briefwechsel');
|
||||
expect(response?.status()).toBeLessThan(400);
|
||||
await expect(page).toHaveURL(/\/briefwechsel/);
|
||||
});
|
||||
|
||||
test('/stammbaum renders the page heading', async ({ page }) => {
|
||||
await page.goto('/stammbaum');
|
||||
await expect(page.getByRole('heading', { name: 'Stammbaum' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('/stammbaum either shows an empty state or at least one node', async ({ page }) => {
|
||||
// Plan journey 3 (empty branch) and journey 1 (populated branch) covered jointly:
|
||||
// the test passes whenever the page renders one of the two coherent states.
|
||||
await page.goto('/stammbaum');
|
||||
const empty = page.getByRole('heading', { name: 'Noch keine Familienmitglieder' });
|
||||
const anyNode = page.locator('svg[role="img"][aria-label="Stammbaum"] g[role="button"]');
|
||||
await expect(async () => {
|
||||
const emptyVisible = await empty.isVisible().catch(() => false);
|
||||
const nodeCount = await anyNode.count();
|
||||
expect(emptyVisible || nodeCount > 0).toBe(true);
|
||||
}).toPass();
|
||||
|
||||
if (await empty.isVisible().catch(() => false)) {
|
||||
await expect(page.getByRole('link', { name: /Zur Personenliste/ })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('person edit Stammbaum card surfaces the year-range error', async ({ page }) => {
|
||||
// Plan task 36: Bis < Von triggers the inline error and keeps the form unsubmitted.
|
||||
// We pick the first person, open the edit page, expand the add-rel form, and
|
||||
// inspect the validation message bound to the Bis field.
|
||||
await page.goto('/persons');
|
||||
const firstPerson = page.locator('a[href^="/persons/"]').first();
|
||||
await firstPerson.click();
|
||||
await expect(page).toHaveURL(/\/persons\/[^/]+/);
|
||||
await page.goto(page.url() + '/edit');
|
||||
|
||||
// Open the add-rel form
|
||||
const addBtn = page.getByRole('button', { name: /Beziehung hinzufügen/i });
|
||||
await addBtn.click();
|
||||
|
||||
// Enter Von 1935, Bis 1920 → expect the year-range error
|
||||
const fromInput = page.locator('input[name="fromYear"]');
|
||||
const toInput = page.locator('input[name="toYear"]');
|
||||
await fromInput.fill('1935');
|
||||
await toInput.fill('1920');
|
||||
|
||||
await expect(page.locator('#add-rel-year-error')).toBeVisible();
|
||||
await expect(page.locator('#add-rel-year-error')).toContainText(/Bis.*Von|nicht vor/i);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default defineConfig(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
{ ignores: ['src/paraglide/**'] },
|
||||
{ ignores: ['src/paraglide/**', '.svelte-kit.old/**'] },
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
"admin_perm_read_all": "Nur lesen",
|
||||
"admin_perm_annotate_all": "Lesen & Annotieren",
|
||||
"admin_perm_write_all": "Lesen & Schreiben",
|
||||
"admin_perm_blog_write": "Geschichten schreiben",
|
||||
"admin_perm_admin": "Vollzugriff (Admin)",
|
||||
"admin_perm_admin_user": "Benutzer verwalten",
|
||||
"admin_perm_admin_tag": "Schlagworte verwalten",
|
||||
@@ -420,6 +421,15 @@
|
||||
"notification_unread": "ungelesen",
|
||||
"mention_btn_label": "Person erwähnen",
|
||||
"mention_popup_empty": "Keine Nutzer gefunden",
|
||||
"person_mention_open_link": "Zur Person",
|
||||
"person_mention_hover_hint": "Klick öffnet Seite",
|
||||
"person_mention_load_error": "Person konnte nicht geladen werden.",
|
||||
"person_mention_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_persons": "Personen",
|
||||
"page_title_admin": "Administration",
|
||||
@@ -493,7 +503,7 @@
|
||||
"doc_details_more_receivers": "+{count} weitere",
|
||||
"transcription_mode_label": "Transkribieren",
|
||||
"transcription_mode_stop": "Fertig",
|
||||
"transcription_block_placeholder": "Text hier eingeben...",
|
||||
"transcription_block_placeholder": "Text eingeben — mit @Name eine Person aus dem Archiv verknüpfen",
|
||||
"transcription_block_save_saving": "Speichere...",
|
||||
"transcription_block_save_saved": "Gespeichert",
|
||||
"transcription_block_save_error": "Nicht gespeichert",
|
||||
@@ -907,5 +917,133 @@
|
||||
"bulk_edit_loading": "Dokumente werden geladen…",
|
||||
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
|
||||
"bulk_edit_topbar_title": "Massenbearbeitung",
|
||||
"bulk_edit_count_pill": "{count} werden bearbeitet"
|
||||
"bulk_edit_count_pill": "{count} werden bearbeitet",
|
||||
|
||||
"nav_stammbaum": "Stammbaum",
|
||||
"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,6 +228,7 @@
|
||||
"admin_perm_read_all": "Read only",
|
||||
"admin_perm_annotate_all": "Read & Annotate",
|
||||
"admin_perm_write_all": "Read & Write",
|
||||
"admin_perm_blog_write": "Write stories",
|
||||
"admin_perm_admin": "Full access (Admin)",
|
||||
"admin_perm_admin_user": "Manage users",
|
||||
"admin_perm_admin_tag": "Manage tags",
|
||||
@@ -420,6 +421,15 @@
|
||||
"notification_unread": "unread",
|
||||
"mention_btn_label": "Mention person",
|
||||
"mention_popup_empty": "No users found",
|
||||
"person_mention_open_link": "Open person",
|
||||
"person_mention_hover_hint": "Click opens the page",
|
||||
"person_mention_load_error": "Could not load person.",
|
||||
"person_mention_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_persons": "Persons",
|
||||
"page_title_admin": "Administration",
|
||||
@@ -493,7 +503,7 @@
|
||||
"doc_details_more_receivers": "+{count} more",
|
||||
"transcription_mode_label": "Transcribe",
|
||||
"transcription_mode_stop": "Done",
|
||||
"transcription_block_placeholder": "Type text here...",
|
||||
"transcription_block_placeholder": "Type text — use @name to link a person from the archive",
|
||||
"transcription_block_save_saving": "Saving...",
|
||||
"transcription_block_save_saved": "Saved",
|
||||
"transcription_block_save_error": "Not saved",
|
||||
@@ -907,5 +917,133 @@
|
||||
"bulk_edit_loading": "Loading documents…",
|
||||
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
|
||||
"bulk_edit_topbar_title": "Bulk edit",
|
||||
"bulk_edit_count_pill": "{count} will be edited"
|
||||
"bulk_edit_count_pill": "{count} will be edited",
|
||||
|
||||
"nav_stammbaum": "Family tree",
|
||||
"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,6 +228,7 @@
|
||||
"admin_perm_read_all": "Solo lectura",
|
||||
"admin_perm_annotate_all": "Leer y anotar",
|
||||
"admin_perm_write_all": "Leer y escribir",
|
||||
"admin_perm_blog_write": "Escribir historias",
|
||||
"admin_perm_admin": "Acceso completo (Admin)",
|
||||
"admin_perm_admin_user": "Gestionar usuarios",
|
||||
"admin_perm_admin_tag": "Gestionar etiquetas",
|
||||
@@ -420,6 +421,15 @@
|
||||
"notification_unread": "no leído",
|
||||
"mention_btn_label": "Mencionar persona",
|
||||
"mention_popup_empty": "No se encontraron usuarios",
|
||||
"person_mention_open_link": "Ir a la persona",
|
||||
"person_mention_hover_hint": "Clic abre la página",
|
||||
"person_mention_load_error": "No se pudo cargar la persona.",
|
||||
"person_mention_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_persons": "Personas",
|
||||
"page_title_admin": "Administración",
|
||||
@@ -493,7 +503,7 @@
|
||||
"doc_details_more_receivers": "+{count} más",
|
||||
"transcription_mode_label": "Transcribir",
|
||||
"transcription_mode_stop": "Listo",
|
||||
"transcription_block_placeholder": "Escriba el texto aquí...",
|
||||
"transcription_block_placeholder": "Escriba el texto — use @nombre para vincular a una persona del archivo",
|
||||
"transcription_block_save_saving": "Guardando...",
|
||||
"transcription_block_save_saved": "Guardado",
|
||||
"transcription_block_save_error": "No guardado",
|
||||
@@ -907,5 +917,133 @@
|
||||
"bulk_edit_loading": "Cargando documentos…",
|
||||
"bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
|
||||
"bulk_edit_topbar_title": "Edición masiva",
|
||||
"bulk_edit_count_pill": "Se editarán {count}"
|
||||
"bulk_edit_count_pill": "Se editarán {count}",
|
||||
|
||||
"nav_stammbaum": "Árbol genealógico",
|
||||
"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,7 +21,11 @@
|
||||
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/core": "3.22.5",
|
||||
"@tiptap/extension-mention": "3.22.5",
|
||||
"@tiptap/starter-kit": "3.22.5",
|
||||
"diff": "^8.0.3",
|
||||
"isomorphic-dompurify": "^3.12.0",
|
||||
"openapi-fetch": "^0.13.5",
|
||||
"pdfjs-dist": "^5.5.207"
|
||||
},
|
||||
|
||||
202
frontend/src/lib/components/AddRelationshipForm.svelte
Normal file
202
frontend/src/lib/components/AddRelationshipForm.svelte
Normal file
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
type RelationType = NonNullable<RelationshipDTO['relationType']>;
|
||||
|
||||
export type RelFormData = {
|
||||
relatedPersonId: string;
|
||||
relationType: RelationType;
|
||||
fromYear?: number;
|
||||
toYear?: number;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
personId: string;
|
||||
onSubmit?: (data: RelFormData) => Promise<void>;
|
||||
}
|
||||
|
||||
let { personId, onSubmit }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let addType = $state<RelationType>('PARENT_OF');
|
||||
let addRelatedPersonId = $state('');
|
||||
let addRelatedPersonName = $state('');
|
||||
let addFromYear = $state('');
|
||||
let addToYear = $state('');
|
||||
let callbackError = $state<string | null>(null);
|
||||
|
||||
const yearError = $derived.by(() => {
|
||||
const from = addFromYear.trim();
|
||||
const to = addToYear.trim();
|
||||
if (!from || !to) return null;
|
||||
const fromInt = parseInt(from, 10);
|
||||
const toInt = parseInt(to, 10);
|
||||
if (Number.isNaN(fromInt) || Number.isNaN(toInt)) return null;
|
||||
return toInt < fromInt ? m.relation_year_error_bis_before_von() : null;
|
||||
});
|
||||
|
||||
const selfError = $derived(
|
||||
addRelatedPersonId !== '' && addRelatedPersonId === personId ? m.relation_error_self() : null
|
||||
);
|
||||
|
||||
const submitDisabled = $derived(
|
||||
yearError !== null || selfError !== null || addRelatedPersonId === ''
|
||||
);
|
||||
|
||||
function reset() {
|
||||
addType = 'PARENT_OF';
|
||||
addRelatedPersonId = '';
|
||||
addRelatedPersonName = '';
|
||||
addFromYear = '';
|
||||
addToYear = '';
|
||||
callbackError = null;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
open = false;
|
||||
reset();
|
||||
}
|
||||
|
||||
async function handleCallbackSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
if (submitDisabled || !onSubmit) return;
|
||||
const data: RelFormData = { relatedPersonId: addRelatedPersonId, relationType: addType };
|
||||
const from = parseInt(addFromYear.trim(), 10);
|
||||
if (!Number.isNaN(from)) data.fromYear = from;
|
||||
const to = parseInt(addToYear.trim(), 10);
|
||||
if (!Number.isNaN(to)) data.toYear = to;
|
||||
try {
|
||||
await onSubmit(data);
|
||||
open = false;
|
||||
reset();
|
||||
} catch {
|
||||
callbackError = m.error_internal_error();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet formFields()}
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_type()}</span>
|
||||
<select
|
||||
name="relationType"
|
||||
bind:value={addType}
|
||||
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
|
||||
>
|
||||
<optgroup label={m.relation_form_group_family()}>
|
||||
<option value="PARENT_OF">{m.relation_parent_of()}</option>
|
||||
<option value="SPOUSE_OF">{m.relation_spouse_of()}</option>
|
||||
<option value="SIBLING_OF">{m.relation_sibling_of()}</option>
|
||||
</optgroup>
|
||||
<optgroup label={m.relation_form_group_social()}>
|
||||
<option value="FRIEND">{m.relation_friend()}</option>
|
||||
<option value="COLLEAGUE">{m.relation_colleague()}</option>
|
||||
<option value="EMPLOYER">{m.relation_employer()}</option>
|
||||
<option value="DOCTOR">{m.relation_doctor()}</option>
|
||||
<option value="NEIGHBOR">{m.relation_neighbor()}</option>
|
||||
<option value="OTHER">{m.relation_other()}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
<div>
|
||||
<PersonTypeahead
|
||||
name="relatedPersonId"
|
||||
label="Person"
|
||||
bind:value={addRelatedPersonId}
|
||||
initialName={addRelatedPersonName}
|
||||
excludePersonId={personId}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="font-sans text-xs font-medium text-ink-2"
|
||||
>{m.relation_form_field_from_year()}</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="fromYear"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
bind:value={addFromYear}
|
||||
placeholder={m.relation_form_year_placeholder()}
|
||||
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="font-sans text-xs font-medium text-ink-2">{m.relation_form_field_to_year()}</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="toYear"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
bind:value={addToYear}
|
||||
aria-describedby={yearError ? 'add-rel-year-error' : undefined}
|
||||
class="mt-1 block w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink focus:border-primary focus:outline-none"
|
||||
/>
|
||||
{#if yearError}
|
||||
<p id="add-rel-year-error" class="mt-1 text-xs text-red-700" role="alert">
|
||||
{yearError}
|
||||
</p>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
{#if selfError}
|
||||
<p class="mt-2 text-xs text-red-700" role="alert">{selfError}</p>
|
||||
{/if}
|
||||
{#if callbackError}
|
||||
<p class="mt-2 text-xs text-red-700" role="alert">{callbackError}</p>
|
||||
{/if}
|
||||
<div class="mt-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancel}
|
||||
class="rounded-sm border border-line bg-surface px-3 py-1.5 font-sans text-xs font-medium text-ink-2 transition hover:bg-muted"
|
||||
>
|
||||
{m.relation_btn_cancel()}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitDisabled}
|
||||
class="rounded-sm bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:opacity-40"
|
||||
>
|
||||
{m.relation_btn_add()}
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if !open}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (open = true)}
|
||||
class="mt-2 inline-flex items-center gap-1 font-sans text-xs font-medium text-ink-2 hover:text-ink"
|
||||
>
|
||||
{m.stammbaum_panel_add_rel()}
|
||||
</button>
|
||||
{:else if onSubmit}
|
||||
<form onsubmit={handleCallbackSubmit} class="mt-3 rounded-sm border border-line bg-muted/40 p-3">
|
||||
{@render formFields()}
|
||||
</form>
|
||||
{:else}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addRelationship"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
await update();
|
||||
if (result.type === 'success') {
|
||||
open = false;
|
||||
reset();
|
||||
}
|
||||
};
|
||||
}}
|
||||
class="mt-3 rounded-sm border border-line bg-muted/40 p-3"
|
||||
>
|
||||
{@render formFields()}
|
||||
</form>
|
||||
{/if}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import AddRelationshipForm from './AddRelationshipForm.svelte';
|
||||
|
||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||
vi.mock('$lib/components/PersonTypeahead.svelte', () => ({ default: () => null }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('AddRelationshipForm', () => {
|
||||
it('shows add-relationship button initially and no form', async () => {
|
||||
render(AddRelationshipForm, { personId: 'person-1' });
|
||||
await expect.element(page.getByRole('button')).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows relationType select when add button is clicked', async () => {
|
||||
render(AddRelationshipForm, { personId: 'person-1' });
|
||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides form and shows button when cancel is clicked', async () => {
|
||||
render(AddRelationshipForm, { personId: 'person-1' });
|
||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||
const cancelBtn = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(b) => b.type === 'button' && /abbrechen/i.test(b.textContent ?? '')
|
||||
);
|
||||
cancelBtn!.click();
|
||||
await expect.element(page.getByRole('combobox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submit is disabled when no person is selected', async () => {
|
||||
render(AddRelationshipForm, { personId: 'person-1' });
|
||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||
await expect.element(page.getByRole('button', { name: /^Hinzufügen$/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('form has no server action when onSubmit prop is provided', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(AddRelationshipForm, { personId: 'person-1', onSubmit });
|
||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||
const form = document.querySelector('form');
|
||||
expect(form?.hasAttribute('action')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows year-range error when toYear is before fromYear', async () => {
|
||||
render(AddRelationshipForm, { personId: 'person-1' });
|
||||
document.querySelector<HTMLButtonElement>('button')!.click();
|
||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||
|
||||
const fromInput = document.querySelector<HTMLInputElement>('input[name="fromYear"]')!;
|
||||
fromInput.value = '1935';
|
||||
fromInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
|
||||
const toInput = document.querySelector<HTMLInputElement>('input[name="toYear"]')!;
|
||||
toInput.value = '1920';
|
||||
toInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,16 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
||||
import { getInitials, personAvatarColor } from '$lib/utils/personFormat';
|
||||
import RelationshipPill from '$lib/components/RelationshipPill.svelte';
|
||||
|
||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
type Tag = { id: string; name: string };
|
||||
type GeschichteSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
publishedAt?: string;
|
||||
author?: { firstName?: string; lastName?: string; email: string };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
documentDate: string | null;
|
||||
@@ -14,11 +21,43 @@ type Props = {
|
||||
sender: Person | null;
|
||||
receivers: Person[];
|
||||
tags: Tag[];
|
||||
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
||||
geschichten?: GeschichteSummary[];
|
||||
documentId?: string;
|
||||
canBlogWrite?: boolean;
|
||||
};
|
||||
|
||||
let { documentDate, location, status, sender, receivers, tags }: Props = $props();
|
||||
let {
|
||||
documentDate,
|
||||
location,
|
||||
status,
|
||||
sender,
|
||||
receivers,
|
||||
tags,
|
||||
inferredRelationship = null,
|
||||
geschichten = [],
|
||||
documentId,
|
||||
canBlogWrite = false
|
||||
}: Props = $props();
|
||||
|
||||
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 displayLocation = $derived(location ?? '—');
|
||||
@@ -37,7 +76,7 @@ function getFullName(person: Person): string {
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet personCard(person: Person)}
|
||||
{#snippet personCard(person: Person, relationLabel: string | null = null)}
|
||||
<a
|
||||
href="/persons/{person.id}"
|
||||
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
|
||||
@@ -49,12 +88,15 @@ function getFullName(person: Person): string {
|
||||
>
|
||||
{getInitials(person.displayName)}
|
||||
</span>
|
||||
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
|
||||
<span class="min-w-0 truncate font-serif text-sm text-ink">{getFullName(person)}</span>
|
||||
{#if relationLabel}
|
||||
<RelationshipPill label={relationLabel} />
|
||||
{/if}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<div class="border-b border-line p-6">
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div class="grid grid-cols-1 gap-6 {gridClass}">
|
||||
<!-- Column 1: Details -->
|
||||
<div>
|
||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
@@ -88,7 +130,7 @@ function getFullName(person: Person): string {
|
||||
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
|
||||
{m.doc_details_field_sender()}
|
||||
</p>
|
||||
{@render personCard(sender)}
|
||||
{@render personCard(sender, inferredRelationship?.labelFromA ?? null)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if receivers.length > 0}
|
||||
@@ -97,8 +139,16 @@ function getFullName(person: Person): string {
|
||||
{m.doc_details_field_receivers()}
|
||||
</p>
|
||||
<div class="space-y-0.5">
|
||||
{#each displayedReceivers as receiver (receiver.id)}
|
||||
{@render personCard(receiver)}
|
||||
{#each displayedReceivers as receiver, i (receiver.id)}
|
||||
{@render personCard(
|
||||
receiver,
|
||||
// Badge only shown when there is exactly one receiver — with multiple
|
||||
// receivers the inferred label is computed from the sender's viewpoint
|
||||
// and cannot be attributed to a specific receiver.
|
||||
i === 0 && receivers.length === 1
|
||||
? (inferredRelationship?.labelFromB ?? null)
|
||||
: null
|
||||
)}
|
||||
{/each}
|
||||
</div>
|
||||
{#if hiddenReceiverCount > 0 && !showAllReceivers}
|
||||
@@ -138,5 +188,51 @@ function getFullName(person: Person): string {
|
||||
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_tags()}</p>
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
@@ -81,6 +81,25 @@ describe('DocumentMetadataDrawer — persons column', () => {
|
||||
renderDrawer({ sender: null, receivers: [] });
|
||||
await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders inferred relationship pills inline next to sender and receiver', async () => {
|
||||
renderDrawer({
|
||||
receivers: [receivers[0]],
|
||||
inferredRelationship: { labelFromA: 'Elternteil', labelFromB: 'Kind' }
|
||||
});
|
||||
|
||||
// Sender link contains its pill, receiver link contains its pill.
|
||||
const senderLink = page.getByRole('link', { name: /Karl Müller.*Elternteil/i });
|
||||
await expect.element(senderLink).toBeInTheDocument();
|
||||
const receiverLink = page.getByRole('link', { name: /Anna Schmidt.*Kind/i });
|
||||
await expect.element(receiverLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the pills when no inferred relationship is provided', async () => {
|
||||
renderDrawer();
|
||||
const elternteil = page.getByText('Elternteil');
|
||||
expect(await elternteil.elements()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tags column ─────────────────────────────────────────────────────────────
|
||||
|
||||
147
frontend/src/lib/components/DocumentMultiSelect.svelte
Normal file
147
frontend/src/lib/components/DocumentMultiSelect.svelte
Normal file
@@ -0,0 +1,147 @@
|
||||
<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>
|
||||
126
frontend/src/lib/components/DocumentMultiSelect.svelte.spec.ts
Normal file
126
frontend/src/lib/components/DocumentMultiSelect.svelte.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
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,14 +25,32 @@ type Doc = {
|
||||
tags?: Tag[] | null;
|
||||
};
|
||||
|
||||
type GeschichteSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
publishedAt?: string;
|
||||
author?: { firstName?: string; lastName?: string; email: string };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
canWrite: boolean;
|
||||
fileUrl: string;
|
||||
transcribeMode: boolean;
|
||||
inferredRelationship?: { labelFromA: string; labelFromB: string } | null;
|
||||
geschichten?: GeschichteSummary[];
|
||||
canBlogWrite?: boolean;
|
||||
};
|
||||
|
||||
let { doc, canWrite, fileUrl, transcribeMode = $bindable() }: Props = $props();
|
||||
let {
|
||||
doc,
|
||||
canWrite,
|
||||
fileUrl,
|
||||
transcribeMode = $bindable(),
|
||||
inferredRelationship = null,
|
||||
geschichten = [],
|
||||
canBlogWrite = false
|
||||
}: Props = $props();
|
||||
|
||||
let detailsOpen = $state(false);
|
||||
|
||||
@@ -275,6 +293,10 @@ let mobileMenuOpen = $state(false);
|
||||
sender={doc.sender ?? null}
|
||||
receivers={doc.receivers ? [...doc.receivers] : []}
|
||||
tags={doc.tags ? [...doc.tags] : []}
|
||||
inferredRelationship={inferredRelationship}
|
||||
geschichten={geschichten}
|
||||
documentId={doc.id}
|
||||
canBlogWrite={canBlogWrite}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
329
frontend/src/lib/components/GeschichteEditor.svelte
Normal file
329
frontend/src/lib/components/GeschichteEditor.svelte
Normal file
@@ -0,0 +1,329 @@
|
||||
<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>
|
||||
150
frontend/src/lib/components/GeschichteEditor.svelte.spec.ts
Normal file
150
frontend/src/lib/components/GeschichteEditor.svelte.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
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']);
|
||||
});
|
||||
});
|
||||
89
frontend/src/lib/components/GeschichtenCard.svelte
Normal file
89
frontend/src/lib/components/GeschichtenCard.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<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}
|
||||
140
frontend/src/lib/components/GeschichtenCard.svelte.spec.ts
Normal file
140
frontend/src/lib/components/GeschichtenCard.svelte.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
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>');
|
||||
});
|
||||
});
|
||||
170
frontend/src/lib/components/MentionDropdown.svelte
Normal file
170
frontend/src/lib/components/MentionDropdown.svelte
Normal file
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
// The dropdown receives a single reactive state object. PersonMentionEditor
|
||||
// mutates fields on this object (model.items = ..., etc.) and Svelte's $state
|
||||
// proxy reactivity propagates the change here. This is the supported way to
|
||||
// update an imperatively-mounted Svelte 5 component — `mount` does not return
|
||||
// settable prop accessors.
|
||||
type DropdownState = {
|
||||
items: Person[];
|
||||
command: (item: Person) => void;
|
||||
clientRect: (() => DOMRect | null) | null;
|
||||
};
|
||||
|
||||
let { model }: { model: DropdownState } = $props();
|
||||
|
||||
// highlightedIndex must be both writable (keyboard handler mutates it) and
|
||||
// reset when `items` changes (so it never points past the end of a new list).
|
||||
// A pure $derived is read-only and cannot serve both needs, so $state + $effect
|
||||
// is the correct pattern here. The autofixer suggestion to use $derived does not
|
||||
// apply: the mutation in onKeyDown is not a derivation.
|
||||
let highlightedIndex = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
// Read model.items to subscribe; reset index whenever the list is replaced.
|
||||
void model.items;
|
||||
highlightedIndex = 0;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Positioning — flip strategy: open upward when there is not enough room
|
||||
// below the cursor to show the dropdown without clipping the viewport.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Position = {
|
||||
top: string | null;
|
||||
bottom: string | null;
|
||||
left: string;
|
||||
};
|
||||
|
||||
const DROPDOWN_CLEARANCE_PX = 200;
|
||||
|
||||
const position = $derived.by<Position>(() => {
|
||||
const cr = model.clientRect;
|
||||
if (!cr) return { top: '0px', bottom: null, left: '0px' };
|
||||
const rect = cr();
|
||||
if (!rect) return { top: '0px', bottom: null, left: '0px' };
|
||||
|
||||
// Some editors report a caret DOMRect with zero width; fall back to rect.x.
|
||||
const left = `${rect.width === 0 ? rect.x : rect.left}px`;
|
||||
|
||||
if (window.innerHeight - rect.bottom < DROPDOWN_CLEARANCE_PX) {
|
||||
// Not enough space below — anchor bottom of dropdown to top of caret.
|
||||
return {
|
||||
top: null,
|
||||
bottom: `${window.innerHeight - rect.top}px`,
|
||||
left
|
||||
};
|
||||
}
|
||||
|
||||
return { top: `${rect.bottom}px`, bottom: null, left };
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard handler — exported so Tiptap's render() can forward events.
|
||||
// Returns true when the event is consumed (prevents the editor's default).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function onKeyDown(event: KeyboardEvent): boolean {
|
||||
const len = model.items.length;
|
||||
if (event.key === 'ArrowDown') {
|
||||
highlightedIndex = (highlightedIndex + 1) % Math.max(len, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
highlightedIndex = (highlightedIndex - 1 + Math.max(len, 1)) % Math.max(len, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
const selected = model.items[highlightedIndex];
|
||||
if (selected) {
|
||||
model.command(selected);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Escape: let the suggestion plugin handle it (return false = not consumed).
|
||||
return false;
|
||||
}
|
||||
|
||||
function selectItem(item: Person) {
|
||||
model.command(item);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Mounted imperatively to document.body by the Tiptap suggestion plugin.
|
||||
Positioned absolutely relative to the viewport using inline styles derived
|
||||
from the Tiptap clientRect() callback.
|
||||
|
||||
SECURITY: This component receives pre-filtered Person[] items from the
|
||||
parent — it does NOT fetch. The parent's fetch relies on the SvelteKit Vite
|
||||
proxy injecting the auth_token cookie as the Authorization header.
|
||||
Mounted in transcribe mode behind WRITE_ALL — never reachable to
|
||||
unauthenticated users.
|
||||
-->
|
||||
<div
|
||||
class="fixed z-50 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
||||
role="listbox"
|
||||
aria-label={m.person_mention_btn_label()}
|
||||
style:top={position.top}
|
||||
style:bottom={position.bottom}
|
||||
style:left={position.left}
|
||||
>
|
||||
{#if model.items.length === 0}
|
||||
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
|
||||
{m.person_mention_popup_empty()}
|
||||
</p>
|
||||
<!--
|
||||
Empty-state escape hatch — without it the transcriber has to close
|
||||
the dropdown, navigate to /persons/new, come back, and re-type the
|
||||
query. target=_blank keeps the document and editor state intact;
|
||||
rel=noopener prevents reverse-tabnabbing on the new tab. Leonie #5621.
|
||||
-->
|
||||
<a
|
||||
href="/persons/new"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none"
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
>
|
||||
{m.person_mention_create_new()}
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
{:else}
|
||||
{#each model.items as person, i (person.id)}
|
||||
<div
|
||||
class={[
|
||||
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
|
||||
// brand-mint ring (≈2.5:1 on white) fails WCAG 1.4.11 Non-Text
|
||||
// Contrast for a meaningful keyboard-highlight indicator. brand-navy
|
||||
// gives ~14.5:1 against the bg-brand-mint/20 row. Leonie #5621.
|
||||
i === highlightedIndex && 'bg-brand-mint/20 ring-2 ring-brand-navy ring-inset'
|
||||
]}
|
||||
role="option"
|
||||
aria-selected={i === highlightedIndex}
|
||||
data-test-person-id={person.id}
|
||||
tabindex="-1"
|
||||
onmousedown={(e) => {
|
||||
// Prevent blur on the editor before the selection fires.
|
||||
e.preventDefault();
|
||||
selectItem(person);
|
||||
}}
|
||||
>
|
||||
<span class="truncate font-serif text-base text-ink">{person.displayName}</span>
|
||||
{#if formatLifeDateRange(person.birthYear, person.deathYear)}
|
||||
<span class="truncate font-sans text-xs text-ink-3">
|
||||
{formatLifeDateRange(person.birthYear, person.deathYear)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
303
frontend/src/lib/components/PersonHoverCard.svelte
Normal file
303
frontend/src/lib/components/PersonHoverCard.svelte
Normal file
@@ -0,0 +1,303 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||
import { chipLabel, otherName } from '$lib/relationshipLabels';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { LoadState } from '$lib/types/personHoverCard';
|
||||
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
type Props = {
|
||||
personId: string;
|
||||
cardId: string;
|
||||
position: { top: number; left: number };
|
||||
state: LoadState;
|
||||
onmouseenter?: () => void;
|
||||
onmouseleave?: () => void;
|
||||
};
|
||||
|
||||
let { personId, cardId, position, state, onmouseenter, onmouseleave }: Props = $props();
|
||||
|
||||
const FAMILY_REL_TYPES: ReadonlySet<RelationshipDTO['relationType']> = new Set([
|
||||
'PARENT_OF',
|
||||
'SPOUSE_OF',
|
||||
'SIBLING_OF'
|
||||
]);
|
||||
const NOTES_MAX = 120;
|
||||
|
||||
const familyChips = $derived(
|
||||
state.status === 'loaded'
|
||||
? state.relationships.filter((r) => FAMILY_REL_TYPES.has(r.relationType))
|
||||
: []
|
||||
);
|
||||
|
||||
const dateRange = $derived(
|
||||
state.status === 'loaded'
|
||||
? formatLifeDateRange(state.person.birthYear, state.person.deathYear)
|
||||
: ''
|
||||
);
|
||||
|
||||
/**
|
||||
* Cut the notes excerpt at the last word boundary inside the NOTES_MAX
|
||||
* window. Mid-word truncation is especially ugly in German compound nouns
|
||||
* ("…Familienzu…"), so prefer the previous space if there is one within
|
||||
* a reasonable distance. Fall back to a hard cut for strings with no
|
||||
* spaces at all (e.g. a single 150-char word). Leonie FINDING-04 / Elicit E5.
|
||||
*/
|
||||
function truncateAtWordBoundary(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
const window = text.slice(0, max);
|
||||
const lastSpace = window.lastIndexOf(' ');
|
||||
// If the last space is too close to the start (< 70% of the window) we'd
|
||||
// produce a near-empty excerpt — fall back to the hard cut instead.
|
||||
const minBoundary = Math.floor(max * 0.7);
|
||||
const cut = lastSpace >= minBoundary ? window.slice(0, lastSpace) : window;
|
||||
return cut + '…';
|
||||
}
|
||||
|
||||
const notesExcerpt = $derived.by(() => {
|
||||
if (state.status !== 'loaded') return null;
|
||||
const notes = state.person.notes;
|
||||
if (!notes) return null;
|
||||
return truncateAtWordBoundary(notes, NOTES_MAX);
|
||||
});
|
||||
|
||||
// Accessible name for the region landmark — required by WCAG 1.3.1.
|
||||
// Falls back to a localised loading label so axe-core never sees an unnamed
|
||||
// region (Leonie FINDING-02 / Elicit NFR concern).
|
||||
const ariaLabel = $derived(
|
||||
state.status === 'loaded' ? state.person.displayName : m.person_mention_loading()
|
||||
);
|
||||
|
||||
// aria-busy="true" while loading so SR clients know the region's contents
|
||||
// will change. Cleared on loaded/error so the new content is announced.
|
||||
const ariaBusy = $derived(state.status === 'loading');
|
||||
|
||||
const showMaidenName = $derived(
|
||||
state.status === 'loaded' &&
|
||||
!!state.person.alias &&
|
||||
state.person.alias !== state.person.lastName &&
|
||||
state.person.alias !== state.person.displayName
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="person-hover-card"
|
||||
data-testid="person-hover-card"
|
||||
id={cardId}
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={ariaBusy ? 'true' : undefined}
|
||||
style:position="fixed"
|
||||
style:top={`${position.top}px`}
|
||||
style:left={`${position.left}px`}
|
||||
onmouseenter={onmouseenter}
|
||||
onmouseleave={onmouseleave}
|
||||
onfocusin={onmouseenter}
|
||||
onfocusout={(e) => {
|
||||
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) {
|
||||
onmouseleave?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if state.status === 'loading'}
|
||||
<div
|
||||
data-testid="person-hover-card-skeleton"
|
||||
class="skeleton"
|
||||
role="status"
|
||||
aria-label={m.person_mention_loading()}
|
||||
>
|
||||
<div class="bar"></div>
|
||||
<div class="bar"></div>
|
||||
<div class="bar"></div>
|
||||
</div>
|
||||
{:else if state.status === 'error'}
|
||||
<div data-testid="person-hover-card-error" class="error-message">
|
||||
{m.person_mention_load_error()}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()} →</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div data-testid="person-hover-card-content" class="content">
|
||||
<div class="header">
|
||||
<div class="name" data-testid="person-hover-card-name">{state.person.displayName}</div>
|
||||
{#if dateRange}
|
||||
<div class="dates" data-testid="person-hover-card-dates">{dateRange}</div>
|
||||
{/if}
|
||||
{#if showMaidenName}
|
||||
<div class="maiden" data-testid="person-hover-card-maiden">
|
||||
{m.person_born_name_prefix()}
|
||||
{state.person.alias}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if familyChips.length > 0}
|
||||
<div class="chips" data-testid="person-hover-card-chips">
|
||||
{#each familyChips as chip (chip.id)}
|
||||
<span class="chip">
|
||||
<span class="chip-type">{chipLabel(chip, personId)}:</span>
|
||||
{otherName(chip, personId)}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if notesExcerpt}
|
||||
<p class="notes" data-testid="person-hover-card-notes">{notesExcerpt}</p>
|
||||
{/if}
|
||||
<div class="footer">
|
||||
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()} →</a>
|
||||
<span class="hint">{m.person_mention_hover_hint()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.person-hover-card {
|
||||
width: 320px;
|
||||
min-height: 180px;
|
||||
background-color: var(--c-surface);
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
padding: 14px 16px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
color: var(--c-ink);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* On touch devices the card is suppressed entirely — tap navigates directly. */
|
||||
@media (hover: none) {
|
||||
.person-hover-card {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.skeleton .bar {
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--c-line);
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton .bar:nth-child(1) {
|
||||
width: 60%;
|
||||
}
|
||||
.skeleton .bar:nth-child(2) {
|
||||
width: 40%;
|
||||
}
|
||||
.skeleton .bar:nth-child(3) {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton .bar {
|
||||
animation: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background-color: var(--c-ink);
|
||||
color: var(--c-surface);
|
||||
margin: -14px -16px 12px;
|
||||
padding: 12px 16px;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dates,
|
||||
.maiden {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--c-surface) 75%, transparent);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
background-color: var(--c-accent-bg);
|
||||
color: var(--c-ink);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.chip-type {
|
||||
font-weight: 600;
|
||||
/* opacity 0.7 on --c-ink: ~5.6:1 light, ~7.1:1 dark — WCAG AA ✓ */
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.notes {
|
||||
font-size: 13px;
|
||||
color: var(--c-ink-2);
|
||||
line-height: 1.4;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 13px;
|
||||
color: var(--c-ink-2);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--c-line);
|
||||
padding-top: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.open-link {
|
||||
color: var(--c-ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--c-ink-3);
|
||||
}
|
||||
</style>
|
||||
373
frontend/src/lib/components/PersonHoverCard.svelte.spec.ts
Normal file
373
frontend/src/lib/components/PersonHoverCard.svelte.spec.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import PersonHoverCard from './PersonHoverCard.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
const AUGUSTE: Person = {
|
||||
id: 'p-aug',
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Auguste Raddatz',
|
||||
personType: 'PERSON',
|
||||
familyMember: true,
|
||||
birthYear: 1882,
|
||||
deathYear: 1944
|
||||
} as unknown as Person;
|
||||
|
||||
const POSITION = { top: 100, left: 200 };
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('PersonHoverCard — loading state', () => {
|
||||
it('shows the skeleton when state.status is loading', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
await expect.element(page.getByTestId('person-hover-card-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders three skeleton bars', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const bars = document.querySelectorAll('[data-testid="person-hover-card-skeleton"] .bar');
|
||||
expect(bars.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersonHoverCard — error state', () => {
|
||||
it('shows a generic error message when state.status is error', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'error' }
|
||||
});
|
||||
await expect.element(page.getByTestId('person-hover-card-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still allows the link footer to navigate (link present in error state)', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'error' }
|
||||
});
|
||||
// The card root must show the footer link even when the body errored —
|
||||
// click navigation works regardless of fetch outcome.
|
||||
const link = document.querySelector('a[href="/persons/p-aug"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersonHoverCard — loaded state', () => {
|
||||
it('renders the person displayName as the header name', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the life-date range when birthYear and deathYear are present', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the life-date line when both years are missing', async () => {
|
||||
const noDates = { ...AUGUSTE, birthYear: undefined, deathYear: undefined } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: noDates, relationships: [] }
|
||||
});
|
||||
const dates = document.querySelector('[data-testid="person-hover-card-dates"]');
|
||||
expect(dates).toBeNull();
|
||||
});
|
||||
|
||||
it('renders "geb. <alias>" when alias is set', async () => {
|
||||
const withAlias = { ...AUGUSTE, alias: 'Müller' } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withAlias, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('geb. Müller')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the maiden name line when alias is null', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const maiden = document.querySelector('[data-testid="person-hover-card-maiden"]');
|
||||
expect(maiden).toBeNull();
|
||||
});
|
||||
|
||||
it('renders family relationship chips for PARENT_OF, SPOUSE_OF, SIBLING_OF only', async () => {
|
||||
const relationships: RelationshipDTO[] = [
|
||||
{
|
||||
id: 'r1',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-spouse',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Otto Raddatz',
|
||||
relationType: 'SPOUSE_OF'
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-friend',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Karl Friend',
|
||||
relationType: 'FRIEND'
|
||||
},
|
||||
{
|
||||
id: 'r3',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-sibling',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Marie Sister',
|
||||
relationType: 'SIBLING_OF'
|
||||
}
|
||||
];
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships }
|
||||
});
|
||||
await expect.element(page.getByText('Otto Raddatz')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Marie Sister')).toBeInTheDocument();
|
||||
// Non-family relationship type must be filtered out
|
||||
const friendChip = page.getByText('Karl Friend');
|
||||
await expect.element(friendChip).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the other person name when hovered person is the object (relatedPersonId) in a PARENT_OF row', async () => {
|
||||
// Storage: Heinrich PARENT_OF Auguste. When viewing Auguste's card,
|
||||
// the chip must show "Heinrich" (the parent), not "Auguste" (herself).
|
||||
const relationships: RelationshipDTO[] = [
|
||||
{
|
||||
id: 'r-parent',
|
||||
personId: 'p-heinrich',
|
||||
relatedPersonId: 'p-aug',
|
||||
personDisplayName: 'Heinrich Raddatz',
|
||||
relatedPersonDisplayName: 'Auguste Raddatz',
|
||||
relationType: 'PARENT_OF'
|
||||
}
|
||||
];
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships }
|
||||
});
|
||||
await expect.element(page.getByText('Heinrich Raddatz')).toBeInTheDocument();
|
||||
// Auguste must NOT appear as her own parent chip name
|
||||
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
|
||||
expect(chips?.textContent).not.toContain('Auguste Raddatz');
|
||||
});
|
||||
|
||||
it('omits the chips section entirely when no family relationships', async () => {
|
||||
const onlyFriend: RelationshipDTO[] = [
|
||||
{
|
||||
id: 'r1',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-friend',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Karl Friend',
|
||||
relationType: 'FRIEND'
|
||||
}
|
||||
];
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: onlyFriend }
|
||||
});
|
||||
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
|
||||
expect(chips).toBeNull();
|
||||
});
|
||||
|
||||
it('renders notes excerpt unchanged when notes ≤ 120 characters', async () => {
|
||||
const withNotes = { ...AUGUSTE, notes: 'Born in Berlin.' } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withNotes, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('truncates notes longer than 120 characters with an ellipsis (single long word)', async () => {
|
||||
// Single 150-char word with no spaces: word-boundary cut would yield nothing,
|
||||
// so fall back to a hard cut at 120 + ellipsis (Sara #7: pin the exact length).
|
||||
const long = 'x'.repeat(150);
|
||||
const withLongNotes = { ...AUGUSTE, notes: long } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
||||
});
|
||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
||||
expect(notes.textContent).toBe('x'.repeat(120) + '…');
|
||||
});
|
||||
|
||||
it('truncates at the last word boundary inside the 120-char window (Leonie FINDING-04)', async () => {
|
||||
// 150-char string with spaces — must cut at the last space, not mid-word.
|
||||
const sentence = 'Sie war eine bekannte Schriftstellerin und engagierte sich '.repeat(3);
|
||||
// length is 180, last space at idx ≤120
|
||||
const withLongNotes = { ...AUGUSTE, notes: sentence } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
||||
});
|
||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
||||
const text = notes.textContent ?? '';
|
||||
// Ends with ellipsis
|
||||
expect(text.endsWith('…')).toBe(true);
|
||||
// Last char before the ellipsis is NOT a half-word — verify by checking that
|
||||
// the position right before … is the end of a word (i.e., there's no letter
|
||||
// further along in the original text immediately after our cut point).
|
||||
const cut = text.slice(0, -1); // strip the …
|
||||
// Find this cut substring in the original sentence
|
||||
const idx = sentence.indexOf(cut);
|
||||
expect(idx).toBe(0);
|
||||
const charAfterCut = sentence[cut.length];
|
||||
// The next char should be a space — confirming we cut on a boundary
|
||||
expect(charAfterCut).toBe(' ');
|
||||
});
|
||||
|
||||
it('omits notes section when notes is null', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]');
|
||||
expect(notes).toBeNull();
|
||||
});
|
||||
|
||||
it('footer renders an anchor link to /persons/{personId}', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const link = document.querySelector('a[href="/persons/p-aug"]')!;
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersonHoverCard — accessibility', () => {
|
||||
it('uses aria-live="polite" so screen readers announce loaded content', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
expect(root.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('sets aria-busy="true" while loading so SR announces the state change on load', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
expect(root.getAttribute('aria-busy')).toBe('true');
|
||||
});
|
||||
|
||||
it('does not set aria-busy when loaded (so the loaded content is announced)', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
// aria-busy is either absent or "false"
|
||||
const busy = root.getAttribute('aria-busy');
|
||||
expect(busy === null || busy === 'false').toBe(true);
|
||||
});
|
||||
|
||||
it('names the region with the person displayName when loaded (WCAG 1.3.1)', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
expect(root.getAttribute('aria-label')).toBe('Auguste Raddatz');
|
||||
});
|
||||
|
||||
it('names the region with a generic loading label while loading', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
// Region must have an accessible name in every state — axe-core flags
|
||||
// role="region" without aria-label / aria-labelledby.
|
||||
expect(root.getAttribute('aria-label')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-xyz',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
expect(root.id).toBe('card-xyz');
|
||||
});
|
||||
|
||||
it('positions itself fixed at the given top/left', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: { top: 333, left: 444 },
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement;
|
||||
expect(root.style.top).toBe('333px');
|
||||
expect(root.style.left).toBe('444px');
|
||||
expect(root.style.position).toBe('fixed');
|
||||
});
|
||||
});
|
||||
295
frontend/src/lib/components/PersonMentionEditor.svelte
Normal file
295
frontend/src/lib/components/PersonMentionEditor.svelte
Normal file
@@ -0,0 +1,295 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, mount, unmount } from 'svelte';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { Mention } from '@tiptap/extension-mention';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { PersonMention } from '$lib/types';
|
||||
import { deserialize, serialize } from '$lib/utils/mentionSerializer';
|
||||
import MentionDropdown from './MentionDropdown.svelte';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
mentionedPersons: PersonMention[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
onfocus?: () => void;
|
||||
onblur?: () => void;
|
||||
onSelectionChange?: (text: string | null) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
mentionedPersons = $bindable([]),
|
||||
placeholder = '',
|
||||
disabled = false,
|
||||
onfocus,
|
||||
onblur,
|
||||
onSelectionChange
|
||||
}: Props = $props();
|
||||
|
||||
let editorEl: HTMLDivElement;
|
||||
let editor: Editor | null = null;
|
||||
|
||||
// Single reactive state object shared with MentionDropdown. Mutating these
|
||||
// fields propagates to the mounted dropdown via Svelte's $state proxy —
|
||||
// this is required because Svelte 5's `mount()` does NOT return prop
|
||||
// accessors; setting `instance.items = ...` does not update the component.
|
||||
let dropdownState = $state<{
|
||||
items: Person[];
|
||||
command: (item: Person) => void;
|
||||
clientRect: (() => DOMRect | null) | null;
|
||||
}>({
|
||||
items: [],
|
||||
command: () => {},
|
||||
clientRect: null
|
||||
});
|
||||
|
||||
type DropdownExports = {
|
||||
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// Custom Mention node: uses personId / displayName instead of the
|
||||
// default id / label attribute names so the mentionSerializer can
|
||||
// round-trip correctly without attribute remapping.
|
||||
const CustomMention = Mention.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
personId: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute('data-person-id'),
|
||||
renderHTML: (attrs) => ({ 'data-person-id': attrs.personId })
|
||||
},
|
||||
displayName: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute('data-display-name'),
|
||||
renderHTML: (attrs) => ({ 'data-display-name': attrs.displayName })
|
||||
},
|
||||
mentionSuggestionChar: {
|
||||
default: '@',
|
||||
parseHTML: (el) => el.getAttribute('data-mention-suggestion-char'),
|
||||
renderHTML: (attrs) => ({
|
||||
'data-mention-suggestion-char': attrs.mentionSuggestionChar
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
editor = new Editor({
|
||||
element: editorEl,
|
||||
// Initial editable state honors the `disabled` prop. The reactive
|
||||
// $effect below keeps it in sync if the prop flips after mount —
|
||||
// without this, a keyboard user can tab into the contenteditable
|
||||
// even when the wrapper has pointer-events-none (WCAG 2.1.1).
|
||||
editable: !disabled,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
bold: false,
|
||||
italic: false,
|
||||
strike: false,
|
||||
code: false,
|
||||
blockquote: false,
|
||||
codeBlock: false,
|
||||
bulletList: false,
|
||||
orderedList: false,
|
||||
hardBreak: false,
|
||||
horizontalRule: false
|
||||
}),
|
||||
CustomMention.configure({
|
||||
renderHTML({ node }) {
|
||||
// Underline color matches the read-mode .person-mention rule
|
||||
// (ink at ~50% alpha) — brand-mint on white fails WCAG 1.4.11
|
||||
// Non-Text Contrast (≈1.7:1, needs 3:1). Leonie #5621.
|
||||
return [
|
||||
'span',
|
||||
{
|
||||
'data-type': 'mention',
|
||||
'data-person-id': node.attrs.personId,
|
||||
'data-display-name': node.attrs.displayName,
|
||||
class:
|
||||
'mention-token underline decoration-ink/50 underline-offset-2 text-brand-navy font-medium'
|
||||
},
|
||||
`@${node.attrs.displayName}`
|
||||
];
|
||||
},
|
||||
renderText({ node }) {
|
||||
return `@${node.attrs.displayName}`;
|
||||
},
|
||||
suggestion: {
|
||||
char: '@',
|
||||
allowSpaces: true,
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// EXCEPTION to frontend/CLAUDE.md "no client-side API fetch":
|
||||
// Tiptap's suggestion plugin lives entirely on the client and
|
||||
// fires on every keystroke after `@`. Routing each query through
|
||||
// a SvelteKit form action would round-trip through SSR for a
|
||||
// dropdown that needs to feel instantaneous, and a +server.ts
|
||||
// endpoint would only proxy the same call. Auth flows through
|
||||
// the Vite proxy in dev and Caddy in prod (cookie-based), so the
|
||||
// network surface is identical to a server-driven call.
|
||||
// Markus #5616: an ADR will formalise this. Open follow-up:
|
||||
// "ADR: client-side fetch exception for editor suggestion plugins."
|
||||
// Nora #5618 #3 — separate issue tracks the GET /api/persons
|
||||
// response-shape audit (PersonSummaryDTO leaks `notes`).
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
items: async ({ query }: { query: string }) => {
|
||||
if (!query) return [];
|
||||
try {
|
||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
|
||||
if (!res.ok) return [];
|
||||
return ((await res.json()) as Person[]).slice(0, 5);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
// AC-1 fix: insert the typed query as displayName, not person.displayName.
|
||||
command({ editor: ed, range, props }) {
|
||||
const p = props as unknown as { personId: string; displayName: string };
|
||||
const nodeAfter = ed.view.state.selection.$to.nodeAfter;
|
||||
if (nodeAfter?.text?.startsWith(' ')) range.to += 1;
|
||||
ed.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{
|
||||
type: 'mention',
|
||||
attrs: { personId: p.personId, displayName: p.displayName }
|
||||
},
|
||||
{ type: 'text', text: ' ' }
|
||||
])
|
||||
.run();
|
||||
},
|
||||
render() {
|
||||
let component: object | null = null;
|
||||
let exports: DropdownExports | null = null;
|
||||
|
||||
// Tiptap's SuggestionProps types `command` against the default
|
||||
// MentionNodeAttrs (id/label). Our custom Mention extension uses
|
||||
// personId/displayName, so we cast the renderProps locally.
|
||||
type LooseRenderProps = {
|
||||
items: unknown;
|
||||
command: (props: { personId: string; displayName: string }) => void;
|
||||
query: string;
|
||||
clientRect?: (() => DOMRect | null) | null;
|
||||
};
|
||||
|
||||
const updateState = (renderProps: LooseRenderProps) => {
|
||||
dropdownState.items = renderProps.items as Person[];
|
||||
// AC-1: pass typed query as displayName, not person.displayName
|
||||
dropdownState.command = (item: Person) =>
|
||||
renderProps.command({
|
||||
personId: item.id,
|
||||
displayName: renderProps.query
|
||||
});
|
||||
dropdownState.clientRect = renderProps.clientRect ?? null;
|
||||
};
|
||||
|
||||
return {
|
||||
onStart(renderProps) {
|
||||
updateState(renderProps as unknown as LooseRenderProps);
|
||||
const mounted = mount(MentionDropdown, {
|
||||
target: document.body,
|
||||
props: { model: dropdownState }
|
||||
});
|
||||
component = mounted as object;
|
||||
exports = mounted as unknown as DropdownExports;
|
||||
},
|
||||
onUpdate(renderProps) {
|
||||
updateState(renderProps as unknown as LooseRenderProps);
|
||||
},
|
||||
onKeyDown({ event }) {
|
||||
// Escape is handled by the suggestion plugin itself.
|
||||
if (event.key === 'Escape') return false;
|
||||
return exports?.onKeyDown(event) ?? false;
|
||||
},
|
||||
onExit() {
|
||||
if (component) {
|
||||
unmount(component);
|
||||
component = null;
|
||||
exports = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
],
|
||||
content: deserialize(value, mentionedPersons),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
role: 'textbox',
|
||||
'aria-multiline': 'true',
|
||||
'aria-label': m.transcription_editor_aria_label(),
|
||||
'data-editor-inner': '',
|
||||
class: [
|
||||
'min-h-[120px] px-1 py-2.5',
|
||||
'font-serif text-base leading-relaxed text-ink',
|
||||
'focus:outline-none',
|
||||
'tiptap-editor-inner'
|
||||
].join(' ')
|
||||
}
|
||||
},
|
||||
onUpdate({ editor: ed }) {
|
||||
const { text, mentionedPersons: mp } = serialize(ed.getJSON());
|
||||
value = text;
|
||||
mentionedPersons = mp;
|
||||
},
|
||||
onFocus() {
|
||||
onfocus?.();
|
||||
},
|
||||
onBlur() {
|
||||
onblur?.();
|
||||
},
|
||||
onSelectionUpdate({ editor: ed }) {
|
||||
const { from, to } = ed.state.selection;
|
||||
onSelectionChange?.(from !== to ? ed.state.doc.textBetween(from, to) : null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
// Keep the data-placeholder attribute in sync with actual emptiness so the
|
||||
// placeholder CSS only fires when there is no content (not just on blur).
|
||||
$effect(() => {
|
||||
if (!editor || !placeholder) return;
|
||||
void value; // Tiptap's onUpdate always fires on content change, but $effect needs a
|
||||
// reactive read to re-run — void value registers value as a dependency without using it.
|
||||
const inner = editorEl?.querySelector('[data-editor-inner]') as HTMLElement | null;
|
||||
if (!inner) return;
|
||||
if (editor.isEmpty) {
|
||||
inner.setAttribute('data-placeholder', placeholder);
|
||||
} else {
|
||||
inner.removeAttribute('data-placeholder');
|
||||
}
|
||||
});
|
||||
|
||||
// Keep editor in sync with the reactive `disabled` prop. Tiptap's setEditable
|
||||
// flips contenteditable on the inner DOM and stops accepting input — matches
|
||||
// the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1).
|
||||
//
|
||||
// Guard: setEditable triggers a ProseMirror transaction which fires onUpdate;
|
||||
// onUpdate writes through bind:value / bind:mentionedPersons. Without this
|
||||
// idempotence check, the effect would loop on every prop pass-through.
|
||||
$effect(() => {
|
||||
const shouldBeEditable = !disabled;
|
||||
if (editor && editor.isEditable !== shouldBeEditable) {
|
||||
editor.setEditable(shouldBeEditable);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
|
||||
class:opacity-50={disabled}
|
||||
class:pointer-events-none={disabled}
|
||||
aria-disabled={disabled ? 'true' : undefined}
|
||||
bind:this={editorEl}
|
||||
></div>
|
||||
424
frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts
Normal file
424
frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* PersonMentionEditor — Tiptap-based component tests.
|
||||
*
|
||||
* All old tests used document.querySelector('textarea') which is dead after
|
||||
* the Tiptap migration. These tests drive the contenteditable via
|
||||
* userEvent.type() and inspect the serialized output from the test host.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type PersonMention = components['schemas']['PersonMention'];
|
||||
|
||||
const AUGUSTE: Person = {
|
||||
id: 'p-aug',
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Auguste Raddatz',
|
||||
birthYear: 1882,
|
||||
deathYear: 1944
|
||||
} as unknown as Person;
|
||||
|
||||
const ANNA: Person = {
|
||||
id: 'p-anna',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Schmidt',
|
||||
displayName: 'Anna Schmidt',
|
||||
birthYear: 1860
|
||||
} as unknown as Person;
|
||||
|
||||
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) })
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchEmpty() {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
|
||||
);
|
||||
}
|
||||
|
||||
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
|
||||
|
||||
function renderHost(
|
||||
initial: { value?: string; mentionedPersons?: PersonMention[]; disabled?: boolean } = {}
|
||||
) {
|
||||
let snapshot: Snapshot = {
|
||||
value: initial.value ?? '',
|
||||
mentionedPersons: initial.mentionedPersons ?? []
|
||||
};
|
||||
render(PersonMentionEditorHost, {
|
||||
initialValue: initial.value ?? '',
|
||||
initialMentions: initial.mentionedPersons ?? [],
|
||||
disabled: initial.disabled ?? false,
|
||||
onChange: (snap: Snapshot) => {
|
||||
snapshot = snap;
|
||||
}
|
||||
});
|
||||
return {
|
||||
get snapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — rendering', () => {
|
||||
it('renders the editor as a textbox (ARIA role from editorProps)', async () => {
|
||||
render(PersonMentionEditorHost, {
|
||||
initialValue: '',
|
||||
initialMentions: [],
|
||||
onChange: () => {}
|
||||
});
|
||||
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reflects bound initial value as visible text', async () => {
|
||||
render(PersonMentionEditorHost, {
|
||||
initialValue: 'Hallo Welt',
|
||||
initialMentions: [],
|
||||
onChange: () => {}
|
||||
});
|
||||
await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Typeahead opens on @ ─────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — typeahead', () => {
|
||||
it('opens the dropdown when typing @ + query and shows results', async () => {
|
||||
mockFetchWithPersons();
|
||||
renderHost();
|
||||
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hits /api/persons?q= with the typed query', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
renderHost();
|
||||
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
|
||||
});
|
||||
});
|
||||
|
||||
it('shows life dates next to the name in the dropdown', async () => {
|
||||
mockFetchWithPersons();
|
||||
renderHost();
|
||||
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty state when no persons match', async () => {
|
||||
mockFetchEmpty();
|
||||
renderHost();
|
||||
|
||||
await userEvent.type(page.getByRole('textbox'), '@xyz');
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('offers a "create new person" link in the empty state', async () => {
|
||||
mockFetchEmpty();
|
||||
renderHost();
|
||||
|
||||
await userEvent.type(page.getByRole('textbox'), '@xyz');
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const link = page.getByRole('link', { name: /Neue Person anlegen/ });
|
||||
await expect.element(link).toBeVisible();
|
||||
await expect.element(link).toHaveAttribute('href', '/persons/new');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
|
||||
|
||||
describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
|
||||
it('stores the typed query as displayName, not the person DB name', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = renderHost();
|
||||
|
||||
// User types "@Aug" (not the full "Auguste Raddatz") and selects Auguste Raddatz
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||
});
|
||||
|
||||
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
||||
expect(host.snapshot.mentionedPersons[0]).toEqual({
|
||||
personId: 'p-aug',
|
||||
displayName: 'Aug' // typed text, not "Auguste Raddatz"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('regression: text value contains the typed query, not the full DB name', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = renderHost();
|
||||
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||
});
|
||||
|
||||
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// Text should contain "@Aug " (typed text + space), not "@Auguste Raddatz "
|
||||
expect(host.snapshot.value).toContain('@Aug');
|
||||
expect(host.snapshot.value).not.toContain('@Auguste Raddatz');
|
||||
});
|
||||
});
|
||||
|
||||
it('pushes {personId, displayName} into mentionedPersons sidecar', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = renderHost();
|
||||
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||
});
|
||||
|
||||
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = renderHost({
|
||||
value: '@Aug ',
|
||||
mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }]
|
||||
});
|
||||
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||
});
|
||||
|
||||
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Keyboard navigation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — keyboard navigation', () => {
|
||||
it('Enter selects the highlighted result', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = renderHost();
|
||||
|
||||
await userEvent.type(page.getByRole('textbox'), '@A');
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('ArrowDown moves the highlight to the next result', async () => {
|
||||
mockFetchWithPersons();
|
||||
renderHost();
|
||||
|
||||
await userEvent.type(page.getByRole('textbox'), '@A');
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const annaOption = page.getByRole('option', { name: /Anna Schmidt/ });
|
||||
await expect.element(annaOption).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
it('Escape closes the dropdown without inserting', async () => {
|
||||
mockFetchWithPersons();
|
||||
const host = renderHost();
|
||||
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Escape}');
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(host.snapshot.mentionedPersons).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Disabled state (WCAG 2.1.1 — keyboard users) ────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — disabled state', () => {
|
||||
it('sets contenteditable=false on the editor when disabled', async () => {
|
||||
renderHost({ value: 'Bestehender Text', disabled: true });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||||
expect(textbox).not.toBeNull();
|
||||
expect(textbox!.getAttribute('contenteditable')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes aria-disabled=true on the editor wrapper when disabled', async () => {
|
||||
renderHost({ disabled: true });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const wrapper = document.querySelector('[aria-disabled="true"]');
|
||||
expect(wrapper).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the editor editable (contenteditable=true) when not disabled', async () => {
|
||||
renderHost({ disabled: false });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||||
expect(textbox).not.toBeNull();
|
||||
expect(textbox!.getAttribute('contenteditable')).toBe('true');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Security — XSS in displayName (CWE-79) ──────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — XSS resistance', () => {
|
||||
it('renders a malicious displayName as text, not as HTML elements', async () => {
|
||||
// A historical sidecar entry whose displayName contains an HTML payload
|
||||
// that would execute if interpolated as raw HTML. Tiptap's renderHTML
|
||||
// returns the @-prefixed string as the third tuple entry, which
|
||||
// ProseMirror's DOMSerializer treats as a Text node — escaping it.
|
||||
const maliciousMention: PersonMention = {
|
||||
personId: '00000000-0000-0000-0000-000000000001',
|
||||
displayName: '<img src=x onerror=alert(1)>'
|
||||
};
|
||||
|
||||
renderHost({
|
||||
value: '@<img src=x onerror=alert(1)>',
|
||||
mentionedPersons: [maliciousMention]
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||||
expect(textbox).not.toBeNull();
|
||||
// No element from the malicious payload should have appeared as a real
|
||||
// DOM node. (Tiptap inserts its own ProseMirror-separator <img> in empty
|
||||
// paragraphs — that is internal markup and never carries user attrs;
|
||||
// guard against the injection by checking the user-controlled attrs.)
|
||||
expect(textbox!.querySelector('img[onerror]')).toBeNull();
|
||||
expect(textbox!.querySelector('img[src="x"]')).toBeNull();
|
||||
expect(textbox!.querySelector('script')).toBeNull();
|
||||
// The payload should appear as visible text content instead.
|
||||
expect(textbox!.textContent ?? '').toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Placeholder behavior ─────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — placeholder behavior', () => {
|
||||
it('sets data-placeholder on the inner element when editor is empty', async () => {
|
||||
render(PersonMentionEditorHost, {
|
||||
initialValue: '',
|
||||
initialMentions: [],
|
||||
placeholder: 'Gib Text ein...',
|
||||
onChange: () => {}
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
|
||||
expect(inner).not.toBeNull();
|
||||
expect(inner!.getAttribute('data-placeholder')).toBe('Gib Text ein...');
|
||||
});
|
||||
});
|
||||
|
||||
it('omits data-placeholder on the inner element when editor has content', async () => {
|
||||
render(PersonMentionEditorHost, {
|
||||
initialValue: 'Bestehender Text',
|
||||
initialMentions: [],
|
||||
placeholder: 'Gib Text ein...',
|
||||
onChange: () => {}
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null;
|
||||
expect(inner).not.toBeNull();
|
||||
expect(inner!.hasAttribute('data-placeholder')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── i18n message content ─────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — i18n message content', () => {
|
||||
it('transcription_block_placeholder contains @ mention trigger for discoverability', () => {
|
||||
expect(m.transcription_block_placeholder()).toContain('@');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — touch target', () => {
|
||||
it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => {
|
||||
mockFetchWithPersons();
|
||||
renderHost();
|
||||
|
||||
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(page.getByRole('option').first()).toBeVisible();
|
||||
});
|
||||
|
||||
const option = document.querySelector('[role="option"]') as HTMLElement;
|
||||
expect(option).not.toBeNull();
|
||||
expect(option.className).toContain('min-h-[44px]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import PersonMentionEditor from './PersonMentionEditor.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type PersonMention = components['schemas']['PersonMention'];
|
||||
|
||||
type Props = {
|
||||
initialValue?: string;
|
||||
initialMentions?: PersonMention[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
initialValue = '',
|
||||
initialMentions = [],
|
||||
placeholder,
|
||||
disabled = false,
|
||||
onChange
|
||||
}: Props = $props();
|
||||
|
||||
// initial* props seed mount-time state; reading them inside untrack signals
|
||||
// the intentional one-shot capture and silences state_referenced_locally.
|
||||
let value = $state(untrack(() => initialValue));
|
||||
let mentionedPersons = $state<PersonMention[]>(untrack(() => [...initialMentions]));
|
||||
|
||||
$effect(() => {
|
||||
onChange({ value, mentionedPersons: [...mentionedPersons] });
|
||||
});
|
||||
</script>
|
||||
|
||||
<PersonMentionEditor
|
||||
bind:value={value}
|
||||
bind:mentionedPersons={mentionedPersons}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
@@ -12,16 +12,25 @@ const PERSONS = [
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{ id: '3', firstName: 'Karl', lastName: 'König', displayName: 'Karl König', personType: 'PERSON' }
|
||||
{
|
||||
id: '3',
|
||||
firstName: 'Karl',
|
||||
lastName: 'König',
|
||||
displayName: 'Karl König',
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
];
|
||||
|
||||
function mockFetch(persons = PERSONS) {
|
||||
@@ -62,14 +71,16 @@ describe('PersonMultiSelect – rendering', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -86,14 +97,16 @@ describe('PersonMultiSelect – rendering', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -112,7 +125,8 @@ describe('PersonMultiSelect – rendering', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -166,7 +180,8 @@ describe('PersonMultiSelect – selecting persons', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -187,7 +202,8 @@ describe('PersonMultiSelect – selecting persons', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]);
|
||||
render(PersonMultiSelect, { selectedPersons: [] });
|
||||
@@ -210,14 +226,16 @@ describe('PersonMultiSelect – removing persons', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -236,14 +254,16 @@ describe('PersonMultiSelect – removing persons', () => {
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Musterfrau',
|
||||
displayName: 'Anna Musterfrau',
|
||||
personType: 'PERSON'
|
||||
personType: 'PERSON',
|
||||
familyMember: false
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ interface Props {
|
||||
autofocus?: boolean;
|
||||
required?: boolean;
|
||||
restrictToCorrespondentsOf?: string;
|
||||
excludePersonId?: string;
|
||||
badge?: 'additive' | 'replace';
|
||||
onchange?: (value: string) => void;
|
||||
onfocused?: () => void;
|
||||
@@ -36,6 +37,7 @@ let {
|
||||
autofocus = false,
|
||||
required = false,
|
||||
restrictToCorrespondentsOf,
|
||||
excludePersonId,
|
||||
badge,
|
||||
onchange,
|
||||
onfocused
|
||||
@@ -61,21 +63,40 @@ $effect(() => {
|
||||
const typeahead = createTypeahead<Person>({
|
||||
fetchUrl: async (term) => {
|
||||
const personId = restrictToCorrespondentsOf;
|
||||
const excludeId = excludePersonId;
|
||||
const filter = (results: Person[]) =>
|
||||
excludeId ? results.filter((p) => p.id !== excludeId) : results;
|
||||
if (personId) {
|
||||
const url =
|
||||
term.length >= 1
|
||||
? `/api/persons/${personId}/correspondents?q=${encodeURIComponent(term)}`
|
||||
: `/api/persons/${personId}/correspondents`;
|
||||
const res = await fetch(url);
|
||||
return res.ok ? await res.json() : [];
|
||||
return res.ok ? filter(await res.json()) : [];
|
||||
}
|
||||
if (term.length < 1) return [];
|
||||
const res = await fetch(`/api/persons?q=${encodeURIComponent(term)}`);
|
||||
return res.ok ? await res.json() : [];
|
||||
return res.ok ? filter(await res.json()) : [];
|
||||
},
|
||||
debounceMs: 300
|
||||
});
|
||||
|
||||
// Fixed-position dropdown state — escapes any CSS stacking context that would clip it.
|
||||
let inputEl: HTMLInputElement;
|
||||
let dropdownStyle = $state('');
|
||||
let activeIndex = $state(-1);
|
||||
|
||||
// Stable id linking the input's aria-controls to the listbox element.
|
||||
const listboxId = `${name}-listbox`;
|
||||
|
||||
const isOpen = $derived(typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading));
|
||||
|
||||
function updateDropdownPosition() {
|
||||
if (!inputEl) return;
|
||||
const rect = inputEl.getBoundingClientRect();
|
||||
dropdownStyle = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;width:${rect.width}px`;
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
if (value && searchTerm !== initialName) {
|
||||
value = '';
|
||||
@@ -88,6 +109,7 @@ function handleInput() {
|
||||
|
||||
function handleFocus() {
|
||||
onfocused?.();
|
||||
updateDropdownPosition();
|
||||
if (restrictToCorrespondentsOf) {
|
||||
const personId = untrack(() => restrictToCorrespondentsOf)!;
|
||||
(async () => {
|
||||
@@ -109,13 +131,47 @@ function selectPerson(person: Person) {
|
||||
value = person.id!;
|
||||
searchTerm = person.displayName;
|
||||
typeahead.close();
|
||||
activeIndex = -1;
|
||||
onchange?.(person.id!);
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
typeahead.close();
|
||||
activeIndex = -1;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!isOpen) return;
|
||||
|
||||
const results = typeahead.results;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % results.length;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + results.length) % results.length;
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && results[activeIndex]) {
|
||||
selectPerson(results[activeIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
// Keep dropdown position current when user scrolls or resizes.
|
||||
// fixed positioning is intentional — it escapes any CSS stacking context (overflow, transform,
|
||||
// shadow-sm + z-index combinations) that would clip an absolute-positioned dropdown.
|
||||
</script>
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={closeDropdown}>
|
||||
<label
|
||||
for={name}
|
||||
for="{name}-search"
|
||||
class={compact
|
||||
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||
: 'block text-sm font-medium text-ink-2'}
|
||||
@@ -125,13 +181,22 @@ function selectPerson(person: Person) {
|
||||
<input type="hidden" name={name} bind:value={value} />
|
||||
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
id="{name}-search"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
autofocus={autofocus}
|
||||
bind:value={searchTerm}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeIndex >= 0 && typeahead.results[activeIndex]
|
||||
? `${listboxId}-option-${typeahead.results[activeIndex].id}`
|
||||
: undefined}
|
||||
oninput={handleInput}
|
||||
onfocus={handleFocus}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
||||
class={large
|
||||
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||
@@ -140,29 +205,34 @@ function selectPerson(person: Person) {
|
||||
: 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||
/>
|
||||
|
||||
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
||||
<div
|
||||
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
||||
{#if isOpen}
|
||||
<ul
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
style={dropdownStyle}
|
||||
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
||||
>
|
||||
{#if typeahead.loading}
|
||||
<div class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</div>
|
||||
<li class="p-2 text-sm text-ink-2">{m.comp_typeahead_loading()}</li>
|
||||
{:else}
|
||||
{#each typeahead.results as person (person.id)}
|
||||
<div
|
||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
|
||||
{#each typeahead.results as person, i (person.id)}
|
||||
<li
|
||||
id="{listboxId}-option-{person.id}"
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg {i === activeIndex ? 'bg-accent-bg' : ''}"
|
||||
onclick={() => selectPerson(person)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectPerson(person)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="block truncate font-medium">
|
||||
{person.displayName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import PersonTypeahead from './PersonTypeahead.svelte';
|
||||
|
||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
@@ -130,11 +130,11 @@ describe('PersonTypeahead – selection', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Max Mustermann' }))
|
||||
.element(page.getByRole('option', { name: 'Max Mustermann' }))
|
||||
.not.toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
||||
});
|
||||
@@ -145,7 +145,7 @@ describe('PersonTypeahead – selection', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
await tick();
|
||||
expect(hiddenInput('senderId')?.value).toBe('1');
|
||||
@@ -158,7 +158,7 @@ describe('PersonTypeahead – selection', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
expect(onchange).toHaveBeenCalledWith('1');
|
||||
});
|
||||
@@ -177,7 +177,7 @@ describe('PersonTypeahead – selection', () => {
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
});
|
||||
@@ -194,7 +194,7 @@ describe('PersonTypeahead – clearing a selection', () => {
|
||||
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
document.querySelector<HTMLElement>('[role="button"]')!.click();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
expect(onchange).toHaveBeenCalledWith('1');
|
||||
onchange.mockClear();
|
||||
@@ -285,3 +285,194 @@ describe('PersonTypeahead – click outside', () => {
|
||||
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ARIA roles ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – ARIA roles', () => {
|
||||
it('dropdown uses role="listbox" container and role="option" items', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
// Container must be a listbox
|
||||
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
// Items must be options, not buttons
|
||||
const options = page.getByRole('option');
|
||||
await expect.element(options.first()).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('option', { name: 'Max Mustermann' })).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('option', { name: 'Anna Musterfrau' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('input has aria-expanded="false" when dropdown is closed', async () => {
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await expect.element(input).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('input has aria-expanded="true" when dropdown is open', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(input).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('input has aria-controls pointing to the listbox id', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
const ariaControls = await input.element().getAttribute('aria-controls');
|
||||
expect(ariaControls).toBeTruthy();
|
||||
|
||||
// The listbox with that id must exist
|
||||
const listbox = document.getElementById(ariaControls!);
|
||||
expect(listbox).not.toBeNull();
|
||||
expect(listbox!.getAttribute('role')).toBe('listbox');
|
||||
});
|
||||
|
||||
it('input has aria-haspopup="listbox"', async () => {
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await expect.element(input).toHaveAttribute('aria-haspopup', 'listbox');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Keyboard navigation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – keyboard navigation', () => {
|
||||
it('ArrowDown moves highlight to the first option', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
|
||||
// First option should be highlighted (aria-selected="true")
|
||||
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
|
||||
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('ArrowDown then ArrowDown moves highlight to the second option', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
|
||||
const secondOption = page.getByRole('option', { name: 'Anna Musterfrau' });
|
||||
await expect.element(secondOption).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('ArrowUp from first wraps to last option', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}'); // highlight first
|
||||
await tick();
|
||||
await userEvent.keyboard('{ArrowUp}'); // wrap to last
|
||||
await tick();
|
||||
|
||||
const lastOption = page.getByRole('option', { name: 'Anna Musterfrau' });
|
||||
await expect.element(lastOption).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('ArrowDown from last wraps to first option', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}'); // highlight first (index 0)
|
||||
await tick();
|
||||
await userEvent.keyboard('{ArrowDown}'); // highlight second (index 1 = last)
|
||||
await tick();
|
||||
await userEvent.keyboard('{ArrowDown}'); // wrap to first (index 0)
|
||||
await tick();
|
||||
|
||||
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
|
||||
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('Enter selects the highlighted option', async () => {
|
||||
mockFetchWithPersons([
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
displayName: 'Max Mustermann',
|
||||
personType: 'PERSON'
|
||||
}
|
||||
]);
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Escape closes the dropdown without selecting', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await tick();
|
||||
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||
// Value unchanged — nothing was selected
|
||||
await expect.element(input).toHaveValue('Mu');
|
||||
});
|
||||
|
||||
it('active option id is set as aria-activedescendant on the input', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
|
||||
// No active option before pressing ArrowDown
|
||||
const beforeNav = await input.element().getAttribute('aria-activedescendant');
|
||||
expect(beforeNav).toBeFalsy();
|
||||
|
||||
await input.click();
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
|
||||
const afterNav = await input.element().getAttribute('aria-activedescendant');
|
||||
expect(afterNav).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
49
frontend/src/lib/components/RelationshipChip.svelte
Normal file
49
frontend/src/lib/components/RelationshipChip.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
chipLabel: string;
|
||||
otherName: string;
|
||||
yearRange?: string;
|
||||
canWrite: boolean;
|
||||
relId: string;
|
||||
}
|
||||
|
||||
let { chipLabel, otherName, yearRange = '', canWrite, relId }: Props = $props();
|
||||
</script>
|
||||
|
||||
<li class="flex items-center gap-2 py-2">
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-accent/40 bg-accent/15 px-2 py-0.5 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||
>
|
||||
{chipLabel}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
|
||||
{otherName}
|
||||
</span>
|
||||
{#if yearRange}
|
||||
<span class="shrink-0 font-sans text-xs text-ink-3" data-testid="year-range">{yearRange}</span>
|
||||
{/if}
|
||||
{#if canWrite}
|
||||
<form method="POST" action="?/deleteRelationship" use:enhance class="shrink-0">
|
||||
<input type="hidden" name="relId" value={relId} />
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="{m.btn_delete()} — {otherName}"
|
||||
class="inline-flex h-11 w-11 items-center justify-center text-ink-3 transition-colors hover:text-red-600"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</li>
|
||||
55
frontend/src/lib/components/RelationshipChip.svelte.spec.ts
Normal file
55
frontend/src/lib/components/RelationshipChip.svelte.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import RelationshipChip from './RelationshipChip.svelte';
|
||||
|
||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const baseProps = {
|
||||
chipLabel: 'Elternteil',
|
||||
otherName: 'Anna Schmidt',
|
||||
yearRange: '',
|
||||
canWrite: false,
|
||||
relId: 'rel-1'
|
||||
};
|
||||
|
||||
describe('RelationshipChip', () => {
|
||||
it('renders the chip label', async () => {
|
||||
render(RelationshipChip, baseProps);
|
||||
await expect.element(page.getByText('Elternteil')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the other person name', async () => {
|
||||
render(RelationshipChip, baseProps);
|
||||
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows year range when provided', async () => {
|
||||
render(RelationshipChip, { ...baseProps, yearRange: '1920–1980' });
|
||||
await expect.element(page.getByText('1920–1980')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show year range span when empty', async () => {
|
||||
render(RelationshipChip, { ...baseProps, yearRange: '' });
|
||||
expect(document.querySelector('[data-testid="year-range"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows delete button when canWrite is true', async () => {
|
||||
render(RelationshipChip, { ...baseProps, canWrite: true });
|
||||
await expect.element(page.getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides delete button when canWrite is false', async () => {
|
||||
render(RelationshipChip, { ...baseProps, canWrite: false });
|
||||
expect(document.querySelector('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('delete button has h-11 w-11 (44px) WCAG touch target class', async () => {
|
||||
render(RelationshipChip, { ...baseProps, canWrite: true });
|
||||
const btn = document.querySelector('button')!;
|
||||
expect(btn.className).toContain('h-11');
|
||||
expect(btn.className).toContain('w-11');
|
||||
});
|
||||
});
|
||||
10
frontend/src/lib/components/RelationshipPill.svelte
Normal file
10
frontend/src/lib/components/RelationshipPill.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
type Props = { label: string };
|
||||
let { label }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-accent bg-accent/25 px-2 py-px font-sans text-[10px] font-bold tracking-[0.07em] text-ink uppercase"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
@@ -36,7 +36,7 @@ let { docs, weeklyCount }: Props = $props();
|
||||
{#each docs as doc (doc.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
href="/documents/{doc.id}?task=transcribe"
|
||||
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
<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();
|
||||
});
|
||||
|
||||
it('links to /documents/{id}', async () => {
|
||||
it('links to /documents/{id}?task=transcribe', async () => {
|
||||
const doc = makeDoc({ id: 'abc-123', title: 'Verlinktes Dokument' });
|
||||
|
||||
render(SegmentationColumn, { props: { docs: [doc], weeklyCount: 0 } });
|
||||
|
||||
const link = page.getByRole('link', { name: /Verlinktes Dokument/ });
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/abc-123');
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/abc-123?task=transcribe');
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user