Compare commits
121 Commits
feat/issue
...
77affcfb4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77affcfb4f | ||
|
|
36529f7e11 | ||
|
|
eb8f9d4dc4 | ||
|
|
a736b7399a | ||
|
|
e7c7f801c9 | ||
|
|
5062513ae6 | ||
|
|
24d5381775 | ||
|
|
826283afcb | ||
|
|
1d5f99a2c8 | ||
|
|
5961bfb916 | ||
|
|
4c300da65e | ||
|
|
bccff232fe | ||
|
|
327fd89cb9 | ||
|
|
23861055d1 | ||
|
|
2ddeb485e3 | ||
|
|
1f19fa3462 | ||
|
|
7ef1ab3b01 | ||
|
|
45db75bdf2 | ||
|
|
8870cbe2fe | ||
|
|
b4cf7f1b21 | ||
|
|
d5587d1b95 | ||
|
|
7699a4e7e2 | ||
|
|
110416d68b | ||
|
|
64fdc5b57e | ||
|
|
ac8d0d5796 | ||
|
|
b8dcb2d3f4 | ||
|
|
ecd531601a | ||
|
|
fe1101f9d5 | ||
|
|
928ebca056 | ||
|
|
5dd4a01995 | ||
|
|
f4132edc2b | ||
|
|
d952fab4cd | ||
|
|
d45739cb76 | ||
|
|
18cad798fc | ||
|
|
0ddf43947b | ||
|
|
45f7642f8d | ||
|
|
5a13e61357 | ||
|
|
a91ee1f26d | ||
|
|
c59287fcfc | ||
|
|
8ce96294b0 | ||
|
|
1803db86b5 | ||
|
|
46001bbf9d | ||
|
|
af8303dbf8 | ||
|
|
7df00859c6 | ||
|
|
92d623e298 | ||
|
|
156efe8b31 | ||
|
|
499beca124 | ||
|
|
5cbb14d4a3 | ||
|
|
2bb8fb8968 | ||
|
|
f13f635161 | ||
|
|
6d3489d035 | ||
|
|
fa5dc43864 | ||
|
|
d4f32ed5d4 | ||
|
|
27e3d290e7 | ||
|
|
25446c9a5c | ||
|
|
660e34e016 | ||
|
|
b662117e55 | ||
|
|
d251806e72 | ||
|
|
f0da033ec9 | ||
|
|
a59feec81a | ||
|
|
779ffaab55 | ||
|
|
b690c74ddf | ||
|
|
0797406f02 | ||
|
|
c94d2cec03 | ||
|
|
4da0bf71a0 | ||
|
|
da5d3c60b3 | ||
|
|
ed0d0bf331 | ||
|
|
899508f9ca | ||
|
|
d32e671e9d | ||
|
|
b61cfa081f | ||
|
|
d914385afc | ||
|
|
6cdfc1f6a3 | ||
|
|
ed6a2fb56f | ||
|
|
58545876cd | ||
|
|
687ebf495d | ||
|
|
bc10f2af06 | ||
|
|
0bfd342190 | ||
|
|
1973f88e56 | ||
|
|
9f044f429c | ||
|
|
7ad5e35fd6 | ||
|
|
e7afed5ac3 | ||
|
|
f48d1e3cd8 | ||
|
|
fc118f7032 | ||
|
|
4229e952fb | ||
|
|
e1259215ef | ||
|
|
f06d034b36 | ||
|
|
a6cd10f219 | ||
|
|
b8e6fe9ec9 | ||
|
|
763f1990cd | ||
|
|
ca62f50921 | ||
|
|
61f84a86ac | ||
|
|
0eb5c95c6c | ||
|
|
d662635392 | ||
|
|
b00be2548c | ||
|
|
01a8654347 | ||
|
|
c1b221412f | ||
|
|
76c14ea604 | ||
|
|
539842e849 | ||
|
|
ef7a51fe30 | ||
|
|
ec17cb123a | ||
|
|
801470093d | ||
|
|
af6ba6a9cc | ||
|
|
9acd5ec617 | ||
|
|
29a44b3cd1 | ||
|
|
5fe289b06b | ||
|
|
f76af8c678 | ||
|
|
69c739c6e3 | ||
|
|
43cf022f05 | ||
|
|
48d034dcb8 | ||
|
|
c335ddd686 | ||
|
|
7830a749a0 | ||
|
|
5b7c37391c | ||
|
|
ce72b07197 | ||
|
|
505804c893 | ||
|
|
67421a4c0c | ||
|
|
0ea0df4f72 | ||
|
|
077f5c85df | ||
|
|
018e272a3b | ||
|
|
0c4a0ead7b | ||
|
|
82b12d4383 | ||
|
|
01758e8e00 |
@@ -26,7 +26,16 @@ public enum AuditKind {
|
||||
COMMENT_ADDED,
|
||||
|
||||
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
|
||||
MENTION_CREATED;
|
||||
MENTION_CREATED,
|
||||
|
||||
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
|
||||
USER_CREATED,
|
||||
|
||||
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
|
||||
USER_DELETED,
|
||||
|
||||
/** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */
|
||||
GROUP_MEMBERSHIP_CHANGED;
|
||||
|
||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||
|
||||
@@ -197,4 +197,8 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
||||
ORDER BY ranked.document_id, ranked.rn
|
||||
""", nativeQuery = true)
|
||||
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
|
||||
|
||||
@Query("SELECT a FROM AuditLog a WHERE a.kind IN :kinds ORDER BY a.happenedAt DESC LIMIT :limit")
|
||||
List<AuditLog> findRecentByKinds(@Param("kinds") Collection<AuditKind> kinds,
|
||||
@Param("limit") int limit);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import org.springframework.stereotype.Service;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.*;
|
||||
|
||||
import static org.raddatz.familienarchiv.audit.AuditKind.GROUP_MEMBERSHIP_CHANGED;
|
||||
import static org.raddatz.familienarchiv.audit.AuditKind.USER_CREATED;
|
||||
import static org.raddatz.familienarchiv.audit.AuditKind.USER_DELETED;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuditLogQueryService {
|
||||
@@ -51,6 +55,10 @@ public class AuditLogQueryService {
|
||||
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
|
||||
}
|
||||
|
||||
public List<AuditLog> findRecentUserManagementEvents(int limit) {
|
||||
return queryRepository.findRecentByKinds(Set.of(USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED), limit);
|
||||
}
|
||||
|
||||
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
|
||||
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
|
||||
for (ContributorRow row : rows) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.controller;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -13,11 +14,18 @@ import java.util.UUID;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.raddatz.familienarchiv.dto.BatchMetadataRequest;
|
||||
import org.raddatz.familienarchiv.dto.BulkEditError;
|
||||
import org.raddatz.familienarchiv.dto.BulkEditResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||
@@ -193,6 +201,7 @@ public class DocumentController {
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public QuickUploadResult quickUpload(
|
||||
@RequestPart(value = "files", required = false) List<MultipartFile> files,
|
||||
@RequestPart(value = "metadata", required = false) DocumentBatchMetadataDTO metadata,
|
||||
Authentication authentication) {
|
||||
List<Document> created = new ArrayList<>();
|
||||
List<Document> updated = new ArrayList<>();
|
||||
@@ -202,14 +211,21 @@ public class DocumentController {
|
||||
return new QuickUploadResult(created, updated, errors);
|
||||
}
|
||||
|
||||
documentService.validateBatch(files.size(), metadata);
|
||||
|
||||
UUID actorId = requireUserId(authentication);
|
||||
for (MultipartFile file : files) {
|
||||
long totalBytes = files.stream().mapToLong(MultipartFile::getSize).sum();
|
||||
|
||||
for (int i = 0; i < files.size(); i++) {
|
||||
MultipartFile file = files.get(i);
|
||||
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
||||
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
DocumentService.StoreResult result = documentService.storeDocument(file, actorId);
|
||||
DocumentService.StoreResult result = metadata != null
|
||||
? documentService.storeDocumentWithBatchMetadata(file, metadata, i, actorId)
|
||||
: documentService.storeDocument(file, actorId);
|
||||
if (result.isNew()) {
|
||||
created.add(result.document());
|
||||
} else {
|
||||
@@ -221,9 +237,107 @@ public class DocumentController {
|
||||
}
|
||||
}
|
||||
|
||||
log.info("quickUpload actor={} files={} totalBytes={} withMetadata={} created={} updated={} errors={}",
|
||||
actorId, files.size(), totalBytes, metadata != null,
|
||||
created.size(), updated.size(), errors.size());
|
||||
|
||||
return new QuickUploadResult(created, updated, errors);
|
||||
}
|
||||
|
||||
// --- BULK EDIT ---
|
||||
|
||||
private static final int BULK_EDIT_MAX_IDS = 500;
|
||||
/** Hard cap for {@code GET /api/documents/ids}: prevents an unfiltered
|
||||
* call from materialising the entire {@code documents} table into JSON.
|
||||
* Generous enough for real-world "Alle X editieren" against the family
|
||||
* archive's bounded scale (~1500 docs today, expected growth to ~5k). */
|
||||
private static final int BULK_EDIT_FILTER_MAX_IDS = 5000;
|
||||
|
||||
@PatchMapping("/bulk")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public BulkEditResult patchBulk(
|
||||
@RequestBody @Valid DocumentBulkEditDTO dto,
|
||||
Authentication authentication) {
|
||||
if (dto.getDocumentIds() == null || dto.getDocumentIds().isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "documentIds is required");
|
||||
}
|
||||
if (dto.getDocumentIds().size() > BULK_EDIT_MAX_IDS) {
|
||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
||||
"Maximum " + BULK_EDIT_MAX_IDS + " documents per request, got: " + dto.getDocumentIds().size());
|
||||
}
|
||||
|
||||
UUID actorId = requireUserId(authentication);
|
||||
int updated = 0;
|
||||
List<BulkEditError> errors = new ArrayList<>();
|
||||
|
||||
// Dedupe duplicate document IDs while preserving submission order. A
|
||||
// double-click on "Alle X editieren" would otherwise hit each document
|
||||
// twice and inflate the `updated` count returned to the user.
|
||||
LinkedHashSet<UUID> uniqueIds = new LinkedHashSet<>(dto.getDocumentIds());
|
||||
|
||||
for (UUID id : uniqueIds) {
|
||||
try {
|
||||
documentService.applyBulkEditToDocument(id, dto, actorId);
|
||||
updated++;
|
||||
} catch (DomainException e) {
|
||||
errors.add(new BulkEditError(id, sanitizeForLog(e.getMessage())));
|
||||
} catch (Exception e) {
|
||||
errors.add(new BulkEditError(id, "Internal error"));
|
||||
log.warn("Bulk edit failed for document {}: {}", id, sanitizeForLog(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
log.info("bulkEdit actor={} documentIds={} unique={} updated={} errors={}",
|
||||
actorId, dto.getDocumentIds().size(), uniqueIds.size(), updated, errors.size());
|
||||
|
||||
return new BulkEditResult(updated, errors);
|
||||
}
|
||||
|
||||
/** CRLF strip for any log line interpolating a free-form string (e.g.
|
||||
* {@link Throwable#getMessage()}). Defends against CWE-117 log injection. */
|
||||
private static String sanitizeForLog(String s) {
|
||||
return s == null ? null : s.replaceAll("[\\r\\n]", "_");
|
||||
}
|
||||
|
||||
@GetMapping("/ids")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public List<UUID> getDocumentIds(
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) LocalDate from,
|
||||
@RequestParam(required = false) LocalDate to,
|
||||
@RequestParam(required = false) UUID senderId,
|
||||
@RequestParam(required = false) UUID receiverId,
|
||||
@RequestParam(required = false, name = "tag") List<String> tags,
|
||||
@RequestParam(required = false) String tagQ,
|
||||
@RequestParam(required = false) DocumentStatus status,
|
||||
@RequestParam(required = false) String tagOp,
|
||||
Authentication authentication) {
|
||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||
List<UUID> ids = documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator);
|
||||
if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) {
|
||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
||||
"Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")");
|
||||
}
|
||||
UUID actorId = requireUserId(authentication);
|
||||
log.info("documentIds actor={} matched={}", actorId, ids.size());
|
||||
return ids;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/batch-metadata", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public List<DocumentBatchSummary> batchMetadata(@RequestBody @Valid BatchMetadataRequest request, Authentication authentication) {
|
||||
if (request == null || request.ids() == null || request.ids().isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ids is required");
|
||||
}
|
||||
if (request.ids().size() > BULK_EDIT_MAX_IDS) {
|
||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
||||
"Maximum " + BULK_EDIT_MAX_IDS + " ids per request, got: " + request.ids().size());
|
||||
}
|
||||
UUID actorId = requireUserId(authentication);
|
||||
log.info("batchMetadata actor={} ids={}", actorId, request.ids().size());
|
||||
return documentService.batchMetadata(request.ids());
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete-count")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public Map<String, Long> getIncompleteCount() {
|
||||
|
||||
@@ -63,27 +63,33 @@ public class PersonController {
|
||||
@PostMapping
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
|
||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
||||
}
|
||||
dto.setFirstName(dto.getFirstName().trim());
|
||||
validatePersonNames(dto);
|
||||
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
||||
dto.setLastName(dto.getLastName().trim());
|
||||
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
|
||||
return ResponseEntity.ok(personService.createPerson(dto));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
|
||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
||||
}
|
||||
dto.setFirstName(dto.getFirstName().trim());
|
||||
validatePersonNames(dto);
|
||||
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
||||
dto.setLastName(dto.getLastName().trim());
|
||||
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
|
||||
return ResponseEntity.ok(personService.updatePerson(id, dto));
|
||||
}
|
||||
|
||||
private void validatePersonNames(PersonUpdateDTO dto) {
|
||||
if (dto.getLastName() == null || dto.getLastName().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nachname ist Pflichtfeld");
|
||||
}
|
||||
if (dto.getPersonType() == org.raddatz.familienarchiv.model.PersonType.PERSON
|
||||
&& (dto.getFirstName() == null || dto.getFirstName().isBlank())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vorname ist Pflichtfeld");
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/merge")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
|
||||
@@ -78,23 +78,29 @@ public class UserController {
|
||||
|
||||
@PostMapping("/users")
|
||||
@RequirePermission(Permission.ADMIN_USER)
|
||||
public ResponseEntity<AppUser> createUser(@Valid @RequestBody CreateUserRequest request) {
|
||||
return ResponseEntity.ok(userService.createUserOrUpdate(request));
|
||||
public ResponseEntity<AppUser> createUser(Authentication authentication,
|
||||
@Valid @RequestBody CreateUserRequest request) {
|
||||
AppUser actor = userService.findByEmail(authentication.getName());
|
||||
return ResponseEntity.ok(userService.createUserOrUpdate(actor.getId(), request));
|
||||
}
|
||||
|
||||
@PutMapping("/users/{id}")
|
||||
@RequirePermission(Permission.ADMIN_USER)
|
||||
public ResponseEntity<AppUser> adminUpdateUser(@PathVariable UUID id,
|
||||
public ResponseEntity<AppUser> adminUpdateUser(Authentication authentication,
|
||||
@PathVariable UUID id,
|
||||
@RequestBody AdminUpdateUserRequest dto) {
|
||||
AppUser updated = userService.adminUpdateUser(id, dto);
|
||||
AppUser actor = userService.findByEmail(authentication.getName());
|
||||
AppUser updated = userService.adminUpdateUser(actor.getId(), id, dto);
|
||||
updated.setPassword(null);
|
||||
return ResponseEntity.ok(updated);
|
||||
}
|
||||
|
||||
@DeleteMapping("/users/{id}")
|
||||
@RequirePermission(Permission.ADMIN_USER)
|
||||
public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {
|
||||
userService.deleteUser(id);
|
||||
public ResponseEntity<Void> deleteUser(Authentication authentication,
|
||||
@PathVariable UUID id) {
|
||||
AppUser actor = userService.findByEmail(authentication.getName());
|
||||
userService.deleteUser(actor.getId(), id);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record BatchMetadataRequest(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<UUID> ids) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record BulkEditError(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String message) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record BulkEditResult(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int updated,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<BulkEditError> errors) {}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
public class DocumentBatchMetadataDTO {
|
||||
private List<String> titles;
|
||||
private UUID senderId;
|
||||
private List<UUID> receiverIds;
|
||||
private LocalDate documentDate;
|
||||
private String location;
|
||||
private List<String> tagNames;
|
||||
private Boolean metadataComplete;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record DocumentBatchSummary(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String pdfUrl) {}
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Request body for {@code PATCH /api/documents/bulk}. Field semantics:
|
||||
* <ul>
|
||||
* <li>{@code tagNames} and {@code receiverIds} are <b>additive</b> —
|
||||
* merged into each document's existing set, never replacing it.</li>
|
||||
* <li>{@code senderId}, {@code documentLocation}, {@code archiveBox},
|
||||
* {@code archiveFolder} are <b>replace-on-non-blank</b> — null/blank
|
||||
* fields are skipped, anything else overwrites.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Kept as a Lombok {@code @Data} POJO (not a record) for symmetry with
|
||||
* the existing {@code DocumentUpdateDTO} and to keep test setup terse —
|
||||
* the per-feature DTOs introduced alongside this one ({@link BulkEditError},
|
||||
* {@link BulkEditResult}, {@link BatchMetadataRequest},
|
||||
* {@link DocumentBatchSummary}) <i>are</i> records because they have no
|
||||
* test-side mutation. Tracked in the cycle-1 review for follow-up.
|
||||
*
|
||||
* <p>Bean-validation caps below defend against payload-amplification: the
|
||||
* 1 MiB SvelteKit proxy cap allows ~26k UUIDs through to the backend, and
|
||||
* Jetty's default body limit is 8 MB. {@code @Size} guards catch malformed
|
||||
* clients without depending on those outer bounds.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DocumentBulkEditDTO {
|
||||
|
||||
// No @Size cap here on purpose: the controller's BULK_EDIT_MAX_IDS check
|
||||
// returns the typed BULK_EDIT_TOO_MANY_IDS error code, which the frontend
|
||||
// maps to a localised "Maximal 500 …" message via Paraglide. A bean-
|
||||
// validation @Size would short-circuit that with a generic VALIDATION_ERROR.
|
||||
private List<UUID> documentIds;
|
||||
|
||||
@Size(max = 200, message = "tagNames must not exceed 200 entries")
|
||||
private List<@Size(max = 200, message = "tagName must not exceed 200 chars") String> tagNames;
|
||||
|
||||
private UUID senderId;
|
||||
|
||||
@Size(max = 200, message = "receiverIds must not exceed 200 entries")
|
||||
private List<UUID> receiverIds;
|
||||
|
||||
@Size(max = 255, message = "documentLocation must not exceed 255 chars")
|
||||
private String documentLocation;
|
||||
|
||||
@Size(max = 255, message = "archiveBox must not exceed 255 chars")
|
||||
private String archiveBox;
|
||||
|
||||
@Size(max = 255, message = "archiveFolder must not exceed 255 chars")
|
||||
private String archiveFolder;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ public class DocumentUpdateDTO {
|
||||
private LocalDate documentDate;
|
||||
private String location;
|
||||
private String documentLocation;
|
||||
private String archiveBox;
|
||||
private String archiveFolder;
|
||||
private String transcription;
|
||||
private String summary;
|
||||
private UUID senderId;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.raddatz.familienarchiv.model.PersonType;
|
||||
|
||||
@Data
|
||||
public class PersonUpdateDTO {
|
||||
@NotNull
|
||||
private PersonType personType;
|
||||
@Size(max = 50)
|
||||
private String title;
|
||||
@Size(max = 100)
|
||||
|
||||
@@ -13,6 +13,8 @@ public enum ErrorCode {
|
||||
PERSON_NOT_FOUND,
|
||||
/** A person name alias with the given ID does not exist. 404 */
|
||||
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 */
|
||||
@@ -109,6 +111,10 @@ public enum ErrorCode {
|
||||
// --- Generic ---
|
||||
/** Request validation failed (missing or malformed fields). 400 */
|
||||
VALIDATION_ERROR,
|
||||
/** Batch upload exceeds the maximum allowed file count per request. 400 */
|
||||
BATCH_TOO_LARGE,
|
||||
/** Bulk edit request exceeds the per-request document ID cap. 400 */
|
||||
BULK_EDIT_TOO_MANY_IDS,
|
||||
/** An unexpected server-side error occurred. 500 */
|
||||
INTERNAL_ERROR,
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
SELECT d.id FROM documents d
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||
THEN to_tsquery('german', regexp_replace(
|
||||
THEN to_tsquery('simple', regexp_replace(
|
||||
websearch_to_tsquery('german', :query)::text,
|
||||
'''([^'']+)''',
|
||||
'''\\1'':*',
|
||||
@@ -149,7 +149,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
FROM documents d
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||
THEN to_tsquery('german', regexp_replace(
|
||||
THEN to_tsquery('simple', regexp_replace(
|
||||
websearch_to_tsquery('german', :query)::text,
|
||||
'''([^'']+)''',
|
||||
'''\\1'':*',
|
||||
|
||||
@@ -7,6 +7,9 @@ import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
@@ -132,6 +135,52 @@ public class DocumentService {
|
||||
return new StoreResult(saved, isNew);
|
||||
}
|
||||
|
||||
public void validateBatch(int fileCount, DocumentBatchMetadataDTO metadata) {
|
||||
// 50-file hard cap keeps FormData requests at a manageable size and protects against runaway bulk uploads.
|
||||
if (fileCount > 50) {
|
||||
throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request");
|
||||
}
|
||||
if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > fileCount) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public StoreResult storeDocumentWithBatchMetadata(
|
||||
MultipartFile file, DocumentBatchMetadataDTO metadata, int fileIndex, UUID actorId) throws IOException {
|
||||
StoreResult base = storeDocument(file, actorId);
|
||||
Document doc = applyBatchMetadata(base.document(), metadata, fileIndex);
|
||||
return new StoreResult(documentRepository.save(doc), base.isNew());
|
||||
}
|
||||
|
||||
private Document applyBatchMetadata(Document doc, DocumentBatchMetadataDTO metadata, int fileIndex) {
|
||||
if (metadata.getTitles() != null && fileIndex < metadata.getTitles().size()) {
|
||||
doc.setTitle(metadata.getTitles().get(fileIndex));
|
||||
}
|
||||
if (metadata.getSenderId() != null) {
|
||||
doc.setSender(personService.getById(metadata.getSenderId()));
|
||||
}
|
||||
if (metadata.getReceiverIds() != null && !metadata.getReceiverIds().isEmpty()) {
|
||||
doc.setReceivers(new HashSet<>(personService.getAllById(metadata.getReceiverIds())));
|
||||
}
|
||||
if (metadata.getDocumentDate() != null) {
|
||||
doc.setDocumentDate(metadata.getDocumentDate());
|
||||
}
|
||||
if (metadata.getLocation() != null) {
|
||||
doc.setLocation(metadata.getLocation());
|
||||
}
|
||||
if (metadata.getMetadataComplete() != null) {
|
||||
doc.setMetadataComplete(metadata.getMetadataComplete());
|
||||
}
|
||||
if (metadata.getTagNames() != null && !metadata.getTagNames().isEmpty()) {
|
||||
UUID docId = doc.getId();
|
||||
updateDocumentTags(docId, metadata.getTagNames());
|
||||
doc = documentRepository.findById(docId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Not found after batch metadata: " + docId));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
|
||||
String filename = (file != null && !file.isEmpty())
|
||||
@@ -222,6 +271,8 @@ public class DocumentService {
|
||||
doc.setTranscription(dto.getTranscription());
|
||||
doc.setSummary(dto.getSummary());
|
||||
doc.setDocumentLocation(dto.getDocumentLocation());
|
||||
doc.setArchiveBox(dto.getArchiveBox());
|
||||
doc.setArchiveFolder(dto.getArchiveFolder());
|
||||
|
||||
List<String> tags = new ArrayList<>();
|
||||
if (dto.getTags() != null && !dto.getTags().isBlank()) {
|
||||
@@ -287,20 +338,143 @@ public class DocumentService {
|
||||
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
||||
Document doc = documentRepository.findById(docId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
|
||||
doc.setTags(resolveTags(tagNames));
|
||||
return documentRepository.save(doc);
|
||||
}
|
||||
|
||||
Set<Tag> newTags = new HashSet<>();
|
||||
|
||||
/**
|
||||
* Resolves a list of tag-name strings to {@link Tag} entities, trimming
|
||||
* whitespace and skipping blank entries. Single source of truth for
|
||||
* "name string → Tag" so the find-or-create policy stays consistent
|
||||
* across single-doc updates ({@link #updateDocumentTags}), bulk edits
|
||||
* ({@link #applyBulkEditToDocument}), and the upload-batch path
|
||||
* ({@code applyBatchMetadata}).
|
||||
*/
|
||||
private Set<Tag> resolveTags(List<String> tagNames) {
|
||||
if (tagNames == null || tagNames.isEmpty()) return new HashSet<>();
|
||||
Set<Tag> resolved = new HashSet<>();
|
||||
for (String name : tagNames) {
|
||||
// Clean the string
|
||||
String cleanName = name.trim();
|
||||
if (cleanName.isEmpty())
|
||||
continue;
|
||||
if (cleanName.isEmpty()) continue;
|
||||
resolved.add(tagService.findOrCreate(cleanName));
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
newTags.add(tagService.findOrCreate(cleanName));
|
||||
/**
|
||||
* Returns all document IDs matching the given filter parameters, ignoring
|
||||
* pagination. Used by the bulk-edit "Alle X editieren" fast path so the
|
||||
* frontend can replace the selection with every match across pages in one
|
||||
* round-trip.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
||||
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
|
||||
boolean hasText = StringUtils.hasText(text);
|
||||
List<UUID> rankedIds = null;
|
||||
if (hasText) {
|
||||
rankedIds = documentRepository.findRankedIdsByFts(text);
|
||||
if (rankedIds.isEmpty()) return List.of();
|
||||
}
|
||||
|
||||
doc.setTags(newTags);
|
||||
return documentRepository.save(doc);
|
||||
Specification<Document> spec = buildSearchSpec(
|
||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
||||
return documentRepository.findAll(spec).stream().map(Document::getId).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for the search Specification chain. Shared by
|
||||
* {@link #searchDocuments} (paged + sorted) and {@link #findIdsForFilter}
|
||||
* (uncapped, ID-only). Caller does its own FTS short-circuit when the
|
||||
* full-text query returned no rows.
|
||||
*/
|
||||
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds,
|
||||
LocalDate from, LocalDate to,
|
||||
UUID sender, UUID receiver,
|
||||
List<String> tags, String tagQ,
|
||||
DocumentStatus status, TagOperator tagOperator) {
|
||||
boolean useOrLogic = tagOperator == TagOperator.OR;
|
||||
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
|
||||
Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
|
||||
return Specification.where(textSpec)
|
||||
.and(isBetween(from, to))
|
||||
.and(hasSender(sender))
|
||||
.and(hasReceiver(receiver))
|
||||
.and(hasTags(expandedTagSets, useOrLogic))
|
||||
.and(hasTagPartial(tagQ))
|
||||
.and(hasStatus(status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns lightweight summaries (id, title, server PDF URL) for the given
|
||||
* document IDs. Unknown IDs are silently dropped — the consumer is the
|
||||
* bulk-edit page's left strip, where missing previews would already be
|
||||
* obvious; surfacing them as errors here adds no value.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<DocumentBatchSummary> batchMetadata(List<UUID> ids) {
|
||||
if (ids == null || ids.isEmpty()) return List.of();
|
||||
return documentRepository.findAllById(ids).stream()
|
||||
.map(d -> new DocumentBatchSummary(
|
||||
d.getId(),
|
||||
d.getTitle() != null ? d.getTitle() : d.getOriginalFilename(),
|
||||
"/api/documents/" + d.getId() + "/file"))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a bulk-edit DTO to a single document atomically.
|
||||
* Tags and receivers are additive (merged into existing sets); sender and the
|
||||
* three location fields are replace-on-non-blank (null/blank means "no change").
|
||||
* Wrapped in its own transaction so a failure on one document never partially
|
||||
* mutates another in the controller's batch loop.
|
||||
*
|
||||
* Each successful update emits a {@link AuditKind#METADATA_UPDATED} audit
|
||||
* event tagged {@code source=BULK_EDIT} and writes a row to
|
||||
* {@code document_versions} so the family archive's "who changed what"
|
||||
* trail stays complete across both single- and bulk-doc edit paths.
|
||||
*
|
||||
* NOTE on N+1: tag and person resolution happens per-document. With 500
|
||||
* documents × 10 tags this fans out to ~5000 tag-resolve queries per
|
||||
* request. Acceptable today because the family archive is bounded at
|
||||
* ~1500 documents total. Tracked as a perf follow-up.
|
||||
*/
|
||||
@Transactional
|
||||
public Document applyBulkEditToDocument(UUID id, DocumentBulkEditDTO dto, UUID actorId) {
|
||||
Document doc = documentRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
|
||||
if (dto.getTagNames() != null && !dto.getTagNames().isEmpty()) {
|
||||
Set<Tag> merged = new HashSet<>(doc.getTags());
|
||||
merged.addAll(resolveTags(dto.getTagNames()));
|
||||
doc.setTags(merged);
|
||||
}
|
||||
|
||||
if (dto.getSenderId() != null) {
|
||||
doc.setSender(personService.getById(dto.getSenderId()));
|
||||
}
|
||||
|
||||
if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
|
||||
Set<Person> merged = new HashSet<>(doc.getReceivers());
|
||||
merged.addAll(personService.getAllById(dto.getReceiverIds()));
|
||||
doc.setReceivers(merged);
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(dto.getDocumentLocation())) {
|
||||
doc.setDocumentLocation(dto.getDocumentLocation());
|
||||
}
|
||||
if (StringUtils.hasText(dto.getArchiveBox())) {
|
||||
doc.setArchiveBox(dto.getArchiveBox());
|
||||
}
|
||||
if (StringUtils.hasText(dto.getArchiveFolder())) {
|
||||
doc.setArchiveFolder(dto.getArchiveFolder());
|
||||
}
|
||||
|
||||
Document saved = documentRepository.save(doc);
|
||||
documentVersionService.recordVersion(saved);
|
||||
auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(),
|
||||
Map.of("source", "BULK_EDIT"));
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,17 +540,8 @@ public class DocumentService {
|
||||
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
||||
}
|
||||
|
||||
boolean useOrLogic = tagOperator == TagOperator.OR;
|
||||
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
|
||||
|
||||
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
|
||||
Specification<Document> spec = Specification.where(textSpec)
|
||||
.and(isBetween(from, to))
|
||||
.and(hasSender(sender))
|
||||
.and(hasReceiver(receiver))
|
||||
.and(hasTags(expandedTagSets, useOrLogic))
|
||||
.and(hasTagPartial(tagQ))
|
||||
.and(hasStatus(status));
|
||||
Specification<Document> spec = buildSearchSpec(
|
||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
||||
|
||||
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
|
||||
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
||||
|
||||
@@ -109,8 +109,12 @@ public class PersonService {
|
||||
|
||||
@Transactional
|
||||
public Person createPerson(PersonUpdateDTO dto) {
|
||||
if (dto.getPersonType() == PersonType.SKIP) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
|
||||
}
|
||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||
Person person = Person.builder()
|
||||
.personType(dto.getPersonType())
|
||||
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
||||
.firstName(dto.getFirstName())
|
||||
.lastName(dto.getLastName())
|
||||
@@ -136,9 +140,13 @@ public class PersonService {
|
||||
|
||||
@Transactional
|
||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||
if (dto.getPersonType() == PersonType.SKIP) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
|
||||
}
|
||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||
Person person = personRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||
person.setPersonType(dto.getPersonType());
|
||||
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
|
||||
person.setFirstName(dto.getFirstName());
|
||||
person.setLastName(dto.getLastName());
|
||||
|
||||
@@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.service;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||
@@ -21,10 +23,13 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static java.util.stream.Collectors.toSet;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@@ -33,9 +38,10 @@ public class UserService {
|
||||
private final AppUserRepository userRepository;
|
||||
private final UserGroupRepository groupRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AuditService auditService;
|
||||
|
||||
@Transactional
|
||||
public AppUser createUserOrUpdate(CreateUserRequest request) {
|
||||
public AppUser createUserOrUpdate(UUID actorId, CreateUserRequest request) {
|
||||
log.info("Creating or updating user: {}", request.getEmail());
|
||||
|
||||
Set<UserGroup> groups = new HashSet<>();
|
||||
@@ -45,10 +51,12 @@ public class UserService {
|
||||
|
||||
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
||||
AppUser user;
|
||||
boolean isNew;
|
||||
|
||||
if (existingUser.isPresent()) {
|
||||
log.info("User exists, updating: {}", request.getEmail());
|
||||
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||
isNew = false;
|
||||
} else {
|
||||
log.info("Creating new user: {}", request.getEmail());
|
||||
user = AppUser.builder()
|
||||
@@ -61,9 +69,15 @@ public class UserService {
|
||||
.contact(request.getContact())
|
||||
.enabled(true)
|
||||
.build();
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
return userRepository.save(user);
|
||||
AppUser saved = userRepository.save(user);
|
||||
if (isNew) {
|
||||
auditService.logAfterCommit(AuditKind.USER_CREATED, actorId, null,
|
||||
Map.of("userId", saved.getId().toString(), "email", saved.getEmail()));
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -94,10 +108,13 @@ public class UserService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteUser(UUID userId) {
|
||||
public void deleteUser(UUID actorId, UUID userId) {
|
||||
AppUser user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
|
||||
String email = user.getEmail();
|
||||
userRepository.delete(user);
|
||||
auditService.logAfterCommit(AuditKind.USER_DELETED, actorId, null,
|
||||
Map.of("userId", userId.toString(), "email", email));
|
||||
}
|
||||
|
||||
public AppUser getById(UUID id) {
|
||||
@@ -141,7 +158,7 @@ public class UserService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) {
|
||||
public AppUser adminUpdateUser(UUID actorId, UUID id, AdminUpdateUserRequest dto) {
|
||||
AppUser user = getById(id);
|
||||
|
||||
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
||||
@@ -166,8 +183,22 @@ public class UserService {
|
||||
}
|
||||
|
||||
if (dto.getGroupIds() != null) {
|
||||
Set<UserGroup> groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
|
||||
user.setGroups(groups);
|
||||
Set<UUID> beforeIds = user.getGroups().stream().map(UserGroup::getId).collect(toSet());
|
||||
Set<UserGroup> beforeGroups = new HashSet<>(user.getGroups());
|
||||
Set<UserGroup> newGroups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
|
||||
user.setGroups(newGroups);
|
||||
Set<UUID> afterIds = newGroups.stream().map(UserGroup::getId).collect(toSet());
|
||||
if (!beforeIds.equals(afterIds)) {
|
||||
List<String> added = newGroups.stream()
|
||||
.filter(g -> !beforeIds.contains(g.getId()))
|
||||
.map(UserGroup::getName).toList();
|
||||
List<String> removed = beforeGroups.stream()
|
||||
.filter(g -> !afterIds.contains(g.getId()))
|
||||
.map(UserGroup::getName).toList();
|
||||
auditService.logAfterCommit(AuditKind.GROUP_MEMBERSHIP_CHANGED, actorId, null,
|
||||
Map.of("userId", id.toString(), "email", user.getEmail(),
|
||||
"addedGroups", added, "removedGroups", removed));
|
||||
}
|
||||
}
|
||||
|
||||
return userRepository.save(user);
|
||||
|
||||
@@ -23,7 +23,8 @@ spring:
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB
|
||||
max-request-size: 50MB
|
||||
max-request-size: 500MB # supports 10-file chunk at max per-file size; see #317
|
||||
file-size-threshold: 2KB
|
||||
|
||||
mail:
|
||||
host: ${MAIL_HOST:}
|
||||
|
||||
@@ -6,12 +6,17 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -47,4 +52,20 @@ class AuditLogQueryServiceTest {
|
||||
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
|
||||
eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findRecentUserManagementEvents_delegatesToRepositoryWithAllThreeKinds() {
|
||||
AuditLog entry = AuditLog.builder().id(UUID.randomUUID()).kind(AuditKind.USER_CREATED).build();
|
||||
when(queryRepository.findRecentByKinds(anyCollection(), eq(5))).thenReturn(List.of(entry));
|
||||
|
||||
List<AuditLog> result = auditLogQueryService.findRecentUserManagementEvents(5);
|
||||
|
||||
assertThat(result).containsExactly(entry);
|
||||
verify(queryRepository).findRecentByKinds(
|
||||
argThat((Collection<AuditKind> kinds) ->
|
||||
kinds.contains(AuditKind.USER_CREATED) &&
|
||||
kinds.contains(AuditKind.USER_DELETED) &&
|
||||
kinds.contains(AuditKind.GROUP_MEMBERSHIP_CHANGED)),
|
||||
eq(5));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.raddatz.familienarchiv.audit;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static java.util.concurrent.TimeUnit.SECONDS;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class UserManagementAuditIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired UserService userService;
|
||||
@Autowired AppUserRepository userRepository;
|
||||
@Autowired AuditLogRepository auditLogRepository;
|
||||
@Autowired AuditLogQueryService auditLogQueryService;
|
||||
@Autowired TransactionTemplate transactionTemplate;
|
||||
|
||||
@Test
|
||||
void createAndDeleteUser_producesOrderedAuditEntries() {
|
||||
// Create the actor (admin) user directly — bypasses audit logging so no FK issue
|
||||
CreateUserRequest adminReq = new CreateUserRequest();
|
||||
adminReq.setEmail("admin@test.example.com");
|
||||
adminReq.setInitialPassword("admin-secret");
|
||||
AppUser actor = transactionTemplate.execute(status ->
|
||||
userService.createUserOrUpdate(null, adminReq));
|
||||
UUID actorId = actor.getId();
|
||||
|
||||
// The admin creation is logged with null actorId — clear to start with a clean slate
|
||||
await().atMost(5, SECONDS).until(() -> auditLogRepository.count() > 0);
|
||||
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
|
||||
|
||||
// Create the target user — should emit USER_CREATED
|
||||
CreateUserRequest req = new CreateUserRequest();
|
||||
req.setEmail("audit-test@example.com");
|
||||
req.setInitialPassword("secret");
|
||||
transactionTemplate.execute(status -> {
|
||||
userService.createUserOrUpdate(actorId, req);
|
||||
return null;
|
||||
});
|
||||
await().atMost(5, SECONDS).until(() -> auditLogRepository.count() > 0);
|
||||
|
||||
// Delete the target user — should emit USER_DELETED
|
||||
AppUser created = userRepository.findByEmail("audit-test@example.com").orElseThrow();
|
||||
transactionTemplate.execute(status -> {
|
||||
userService.deleteUser(actorId, created.getId());
|
||||
return null;
|
||||
});
|
||||
await().atMost(5, SECONDS).until(() -> auditLogRepository.count() >= 2);
|
||||
|
||||
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
|
||||
assertThat(events).hasSize(2);
|
||||
assertThat(events.get(0).getKind()).isEqualTo(AuditKind.USER_DELETED);
|
||||
assertThat(events.get(1).getKind()).isEqualTo(AuditKind.USER_CREATED);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
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.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
@@ -766,4 +768,476 @@ class DocumentControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.editorName").value("Otto"));
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/quick-upload — metadata part ────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_appliesSharedFieldsToAllCreatedDocuments() throws Exception {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Person sender = Person.builder().id(senderId).lastName("Müller").build();
|
||||
|
||||
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Brief 1").originalFilename("a.pdf").sender(sender).build();
|
||||
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Brief 2").originalFilename("b.pdf").sender(sender).build();
|
||||
Document doc3 = Document.builder().id(UUID.randomUUID()).title("Brief 3").originalFilename("c.pdf").sender(sender).build();
|
||||
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc1, true));
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc2, true));
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc3, true));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile f1 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f2 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f3 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created.length()").value(3))
|
||||
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
|
||||
.andExpect(jsonPath("$.created[1].sender.id").value(senderId.toString()))
|
||||
.andExpect(jsonPath("$.created[2].sender.id").value(senderId.toString()))
|
||||
.andExpect(jsonPath("$.updated").isEmpty())
|
||||
.andExpect(jsonPath("$.errors").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_appliesSharedFieldsToUpdatedDocuments() throws Exception {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Person sender = Person.builder().id(senderId).lastName("Müller").build();
|
||||
Document existing = Document.builder().id(UUID.randomUUID()).title("Alt").originalFilename("alt.pdf").sender(sender).build();
|
||||
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(existing, false));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "alt.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created").isEmpty())
|
||||
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
|
||||
.andExpect(jsonPath("$.errors").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_mapsTitlesByIndex() throws Exception {
|
||||
Document docA = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
|
||||
Document docB = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
|
||||
Document docC = Document.builder().id(UUID.randomUUID()).title("Gamma").originalFilename("c.pdf").build();
|
||||
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(docA, true));
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(docB, true));
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(docC, true));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile f1 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f2 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f3 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created[0].title").value("Alpha"))
|
||||
.andExpect(jsonPath("$.created[1].title").value("Beta"))
|
||||
.andExpect(jsonPath("$.created[2].title").value("Gamma"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_rejects400_whenTitlesSizeExceedsFilesSize() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
org.mockito.Mockito.doThrow(
|
||||
org.raddatz.familienarchiv.exception.DomainException.badRequest(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count"))
|
||||
.when(documentService).validateBatch(eq(2), any());
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile f1 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f2 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_tagNamesJsonArray_parsedCorrectly() throws Exception {
|
||||
Document doc = Document.builder().id(UUID.randomUUID()).title("brief").originalFilename("brief.pdf").build();
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
org.mockito.ArgumentCaptor<DocumentBatchMetadataDTO> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(DocumentBatchMetadataDTO.class);
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), captor.capture(), anyInt(), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc, true));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "brief.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
|
||||
.containsExactly("Briefwechsel", "Krieg");
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_returns400_whenBatchExceedsCap() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
org.mockito.Mockito.doThrow(
|
||||
org.raddatz.familienarchiv.exception.DomainException.badRequest(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request"))
|
||||
.when(documentService).validateBatch(eq(51), any());
|
||||
|
||||
var builder = multipart("/api/documents/quick-upload");
|
||||
for (int i = 0; i < 51; i++) {
|
||||
builder.file(new org.springframework.mock.web.MockMultipartFile(
|
||||
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
|
||||
}
|
||||
|
||||
mockMvc.perform(builder)
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
||||
}
|
||||
|
||||
// ─── PATCH /api/documents/bulk ───────────────────────────────────────────
|
||||
|
||||
private static String bulkBody(String... uuids) {
|
||||
StringBuilder sb = new StringBuilder("{\"documentIds\":[");
|
||||
for (int i = 0; i < uuids.length; i++) {
|
||||
if (i > 0) sb.append(",");
|
||||
sb.append("\"").append(uuids[i]).append("\"");
|
||||
}
|
||||
sb.append("]}");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Test
|
||||
void patchBulk_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(UUID.randomUUID().toString())))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void patchBulk_returns403_forReadAllUser() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(UUID.randomUUID().toString())))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"documentIds\":[]}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returns400_whenDocumentIdsExceedsCap() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
String[] ids = new String[501];
|
||||
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(ids)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returns400_whenArchiveBoxExceeds255Chars() throws Exception {
|
||||
// Tobias C2 — DocumentBulkEditDTO.archiveBox carries @Size(max=255).
|
||||
// Without @Valid on @RequestBody this would silently land an
|
||||
// arbitrarily long string; the test pins both the annotation and
|
||||
// the controller-level @Valid wiring.
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID id = UUID.randomUUID();
|
||||
String tooLong = "x".repeat(256);
|
||||
|
||||
String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_acceptsExactly500Ids_atTheCap() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
when(documentService.applyBulkEditToDocument(any(), any(), any()))
|
||||
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
|
||||
|
||||
String[] ids = new String[500];
|
||||
for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString();
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(ids)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.updated").value(500));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_dedupesDuplicateDocumentIds_doesNotInflateUpdatedCount() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentService.applyBulkEditToDocument(eq(id), any(), any()))
|
||||
.thenAnswer(inv -> Document.builder().id(id).build());
|
||||
|
||||
// Same id sent three times — controller should dedupe and call the
|
||||
// service exactly once, returning updated=1, not 3.
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(id.toString(), id.toString(), id.toString())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.updated").value(1));
|
||||
|
||||
verify(documentService, org.mockito.Mockito.times(1))
|
||||
.applyBulkEditToDocument(eq(id), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returns200_andCallsServiceForEachId() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
when(documentService.applyBulkEditToDocument(any(), any(), any()))
|
||||
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(id1.toString(), id2.toString())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.updated").value(2))
|
||||
.andExpect(jsonPath("$.errors").isEmpty());
|
||||
|
||||
verify(documentService).applyBulkEditToDocument(eq(id1), any(), any());
|
||||
verify(documentService).applyBulkEditToDocument(eq(id2), any(), any());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/ids ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getDocumentIds_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/ids"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getDocumentIds_returns403_forUserWithoutWriteAll() throws Exception {
|
||||
// /ids is gated WRITE_ALL because it powers the bulk-edit "Alle X
|
||||
// editieren" fast path; no other consumer needs it.
|
||||
mockMvc.perform(get("/api/documents/ids"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void getDocumentIds_returns200_andDelegatesToService() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(List.of(id));
|
||||
|
||||
mockMvc.perform(get("/api/documents/ids"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0]").value(id.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void getDocumentIds_passesSenderIdParamToService() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID senderId = UUID.randomUUID();
|
||||
when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString()))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void getDocumentIds_returns400_whenResultExceedsFilterCap() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
// Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000).
|
||||
java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001);
|
||||
for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID());
|
||||
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(tooMany);
|
||||
|
||||
mockMvc.perform(get("/api/documents/ids"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/batch-metadata ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void batchMetadata_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void batchMetadata_returns400_whenIdsEmpty() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[]}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void batchMetadata_returns400_whenIdsExceedsCap() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
StringBuilder sb = new StringBuilder("{\"ids\":[");
|
||||
for (int i = 0; i < 501; i++) {
|
||||
if (i > 0) sb.append(",");
|
||||
sb.append("\"").append(UUID.randomUUID()).append("\"");
|
||||
}
|
||||
sb.append("]}");
|
||||
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(sb.toString()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void batchMetadata_returnsSummaries_forExistingIds() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentService.batchMetadata(any())).thenReturn(List.of(
|
||||
new org.raddatz.familienarchiv.dto.DocumentBatchSummary(id, "Brief", "/api/documents/" + id + "/file")));
|
||||
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[\"" + id + "\"]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
||||
.andExpect(jsonPath("$[0].title").value("Brief"))
|
||||
.andExpect(jsonPath("$[0].pdfUrl").value("/api/documents/" + id + "/file"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_stripsCarriageReturnsAndNewlinesFromErrorMessages() throws Exception {
|
||||
// Nora C4 — DocumentController.sanitizeForLog defends against
|
||||
// CWE-117 (log injection) by replacing CR/LF in any free-form string
|
||||
// it interpolates. Same helper now sanitises BulkEditError.message
|
||||
// before it round-trips to the frontend.
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID badId = UUID.randomUUID();
|
||||
when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
|
||||
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
|
||||
"evil\r\nFAKE LOG ENTRY: admin logged in"));
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(badId.toString())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.errors[0].message",
|
||||
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\n"))))
|
||||
.andExpect(jsonPath("$.errors[0].message",
|
||||
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\r"))))
|
||||
.andExpect(jsonPath("$.errors[0].message",
|
||||
org.hamcrest.Matchers.containsString("evil_")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returnsPartialFailureShape_whenServiceThrowsForOneDocument() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID okId = UUID.randomUUID();
|
||||
UUID badId = UUID.randomUUID();
|
||||
when(documentService.applyBulkEditToDocument(eq(okId), any(), any()))
|
||||
.thenAnswer(inv -> Document.builder().id(okId).build());
|
||||
when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
|
||||
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(okId.toString(), badId.toString())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.updated").value(1))
|
||||
.andExpect(jsonPath("$.errors[0].id").value(badId.toString()))
|
||||
.andExpect(jsonPath("$.errors[0].message").value(
|
||||
org.hamcrest.Matchers.containsString("not found")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||
@@ -25,6 +28,7 @@ import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -183,19 +187,19 @@ class PersonControllerTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenFirstNameIsMissing() throws Exception {
|
||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"Müller\"}"))
|
||||
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenFirstNameIsBlank() throws Exception {
|
||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\" \",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -204,7 +208,7 @@ class PersonControllerTest {
|
||||
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -213,7 +217,7 @@ class PersonControllerTest {
|
||||
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -225,11 +229,53 @@ class PersonControllerTest {
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.firstName").value("Hans"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns200_forInstitution_withoutFirstName() throws Exception {
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
|
||||
when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.lastName").value("Verlag GmbH"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_trimsTitle_beforePersisting() throws Exception {
|
||||
ArgumentCaptor<org.raddatz.familienarchiv.dto.PersonUpdateDTO> captor =
|
||||
ArgumentCaptor.forClass(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class);
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||
when(personService.createPerson(captor.capture())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertThat(captor.getValue().getTitle()).isEqualTo("Prof.");
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenPersonTypeIsSkip() throws Exception {
|
||||
when(personService.createPerson(any())).thenThrow(
|
||||
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("INVALID_PERSON_TYPE"));
|
||||
}
|
||||
|
||||
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -242,10 +288,10 @@ class PersonControllerTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400_whenFirstNameIsBlank() throws Exception {
|
||||
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -254,7 +300,7 @@ class PersonControllerTest {
|
||||
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -267,7 +313,7 @@ class PersonControllerTest {
|
||||
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.lastName").value("Müller"));
|
||||
}
|
||||
@@ -317,11 +363,10 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||
// firstName valid, lastName blank → second || operand = true → 400
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -339,7 +384,7 @@ class PersonControllerTest {
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
||||
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
||||
"\"notes\":\"Some notes\"}"))
|
||||
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.firstName").value("Maria"))
|
||||
.andExpect(jsonPath("$.alias").value("Oma Maria"))
|
||||
@@ -355,7 +400,7 @@ class PersonControllerTest {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -366,7 +411,7 @@ class PersonControllerTest {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -377,7 +422,7 @@ class PersonControllerTest {
|
||||
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -386,7 +431,7 @@ class PersonControllerTest {
|
||||
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,22 @@ class DocumentFtsTest {
|
||||
assertThat(ids).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_find_document_whose_transcription_contains_word_that_stems_to_german_stop_word() {
|
||||
// "Wille" stems to "will" via the German Snowball stemmer.
|
||||
// "will" is also a German stop word, so to_tsquery('german','will:*') drops it.
|
||||
// The prefix-transform step must use to_tsquery('simple',...) to avoid this.
|
||||
Document doc = documentRepository.saveAndFlush(document("Foto"));
|
||||
UUID annotationId = annotation(doc.getId());
|
||||
blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Der Wille des Volkes", 0));
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<UUID> ids = documentRepository.findRankedIdsByFts("Wille");
|
||||
|
||||
assertThat(ids).contains(doc.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_not_throw_when_query_contains_invalid_tsquery_syntax() {
|
||||
documentRepository.saveAndFlush(document("Brief"));
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||
import org.raddatz.familienarchiv.dto.MatchOffset;
|
||||
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
@@ -120,6 +121,23 @@ class DocumentServiceTest {
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_setsArchiveBoxAndFolder() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenReturn(doc);
|
||||
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setArchiveBox("K-03");
|
||||
dto.setArchiveFolder("Mappe B");
|
||||
|
||||
documentService.updateDocument(id, dto, null, null);
|
||||
|
||||
assertThat(doc.getArchiveBox()).isEqualTo("K-03");
|
||||
assertThat(doc.getArchiveFolder()).isEqualTo("Mappe B");
|
||||
}
|
||||
|
||||
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -1813,4 +1831,437 @@ class DocumentServiceTest {
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull());
|
||||
}
|
||||
|
||||
// ─── storeDocumentWithBatchMetadata ──────────────────────────────────────
|
||||
|
||||
private MockMultipartFile pdfFile(String name) {
|
||||
return new MockMultipartFile("file", name, "application/pdf", new byte[]{1});
|
||||
}
|
||||
|
||||
private void stubStoreDocument(String filename) throws Exception {
|
||||
when(documentRepository.findFirstByOriginalFilename(filename)).thenReturn(Optional.empty());
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocumentWithBatchMetadata_appliesTitleByIndex() throws Exception {
|
||||
stubStoreDocument("scan01.pdf");
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
meta.setTitles(List.of("Erster Brief", "Zweiter Brief"));
|
||||
|
||||
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan01.pdf"), meta, 0, null);
|
||||
|
||||
assertThat(result.document().getTitle()).isEqualTo("Erster Brief");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocumentWithBatchMetadata_resolvesSenderViaPersonService() throws Exception {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
stubStoreDocument("scan02.pdf");
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
Person sender = Person.builder().id(senderId).firstName("Anna").build();
|
||||
when(personService.getById(senderId)).thenReturn(sender);
|
||||
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
meta.setSenderId(senderId);
|
||||
|
||||
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan02.pdf"), meta, 0, null);
|
||||
|
||||
assertThat(result.document().getSender()).isEqualTo(sender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocumentWithBatchMetadata_appliesTagsViaUpdateDocumentTags() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
when(documentRepository.findFirstByOriginalFilename("scan03.pdf")).thenReturn(Optional.empty());
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> {
|
||||
Document d = inv.getArgument(0);
|
||||
if (d.getId() == null) d.setId(docId);
|
||||
return d;
|
||||
});
|
||||
when(documentRepository.findById(docId)).thenAnswer(inv -> {
|
||||
Document d = new Document();
|
||||
d.setId(docId);
|
||||
return Optional.of(d);
|
||||
});
|
||||
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||
when(tagService.findOrCreate("Familie")).thenReturn(tag);
|
||||
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
meta.setTagNames(List.of("Familie"));
|
||||
|
||||
documentService.storeDocumentWithBatchMetadata(pdfFile("scan03.pdf"), meta, 0, null);
|
||||
|
||||
verify(tagService).findOrCreate("Familie");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocumentWithBatchMetadata_leavesTitle_whenIndexExceedsTitlesList() throws Exception {
|
||||
stubStoreDocument("scan04.pdf");
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
meta.setTitles(List.of("Only One Title"));
|
||||
|
||||
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan04.pdf"), meta, 5, null);
|
||||
|
||||
assertThat(result.document().getTitle()).isEqualTo("scan04");
|
||||
}
|
||||
|
||||
// ─── validateBatch ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void validateBatch_throwsBatchTooLarge_whenFileCountExceedsCap() {
|
||||
assertThatThrownBy(() -> documentService.validateBatch(51, null))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("50");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateBatch_doesNotThrow_whenFileCountEqualsCapExactly() {
|
||||
documentService.validateBatch(50, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateBatch_throwsValidationError_whenTitlesSizeExceedsFileCount() {
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO metadata =
|
||||
new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
metadata.setTitles(java.util.List.of("A", "B", "C"));
|
||||
|
||||
assertThatThrownBy(() -> documentService.validateBatch(2, metadata))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("titles");
|
||||
}
|
||||
|
||||
// ─── applyBulkEditToDocument ─────────────────────────────────────────────
|
||||
|
||||
private static org.raddatz.familienarchiv.dto.DocumentBulkEditDTO bulkDto() {
|
||||
return new org.raddatz.familienarchiv.dto.DocumentBulkEditDTO();
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_throwsNotFound_whenDocumentMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, bulkDto(), null))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining(id.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_appliesTagsAdditively_preservesExistingTags() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
||||
Tag added = Tag.builder().id(UUID.randomUUID()).name("Kurrent").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.tags(new HashSet<>(Set.of(existing)))
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(tagService.findOrCreate("Kurrent")).thenReturn(added);
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setTagNames(List.of("Kurrent"));
|
||||
documentService.applyBulkEditToDocument(id, dto, null);
|
||||
|
||||
assertThat(doc.getTags()).containsExactlyInAnyOrder(existing, added);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsTags_whenTagNamesIsNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.tags(new HashSet<>(Set.of(existing)))
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
documentService.applyBulkEditToDocument(id, bulkDto(), null);
|
||||
|
||||
assertThat(doc.getTags()).containsExactly(existing);
|
||||
verify(tagService, never()).findOrCreate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsTags_whenTagNamesIsEmpty() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.tags(new HashSet<>(Set.of(existing)))
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setTagNames(List.of());
|
||||
documentService.applyBulkEditToDocument(id, dto, null);
|
||||
|
||||
assertThat(doc.getTags()).containsExactly(existing);
|
||||
verify(tagService, never()).findOrCreate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_replacesSender_whenSenderIdProvided() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Person oldSender = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
||||
Person newSender = Person.builder().id(senderId).firstName("New").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.sender(oldSender)
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(personService.getById(senderId)).thenReturn(newSender);
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setSenderId(senderId);
|
||||
documentService.applyBulkEditToDocument(id, dto, null);
|
||||
|
||||
assertThat(doc.getSender()).isEqualTo(newSender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsSender_whenSenderIdIsNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).firstName("X").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.sender(existing)
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
documentService.applyBulkEditToDocument(id, bulkDto(), null);
|
||||
|
||||
assertThat(doc.getSender()).isEqualTo(existing);
|
||||
verify(personService, never()).getById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_addsReceiversAdditively_preservesExistingReceivers() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID newReceiverId = UUID.randomUUID();
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
||||
Person added = Person.builder().id(newReceiverId).firstName("New").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.receivers(new HashSet<>(Set.of(existing)))
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(personService.getAllById(List.of(newReceiverId))).thenReturn(List.of(added));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setReceiverIds(List.of(newReceiverId));
|
||||
documentService.applyBulkEditToDocument(id, dto, null);
|
||||
|
||||
assertThat(doc.getReceivers()).containsExactlyInAnyOrder(existing, added);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsReceivers_whenReceiverIdsIsNullOrEmpty() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.receivers(new HashSet<>(Set.of(existing)))
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setReceiverIds(List.of());
|
||||
documentService.applyBulkEditToDocument(id, dto, null);
|
||||
|
||||
assertThat(doc.getReceivers()).containsExactly(existing);
|
||||
verify(personService, never()).getAllById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_recordsVersion_andLogsAuditEvent_taggedSourceBulkEdit() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID actorId = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenReturn(doc);
|
||||
|
||||
documentService.applyBulkEditToDocument(id, bulkDto(), actorId);
|
||||
|
||||
verify(documentVersionService).recordVersion(doc);
|
||||
verify(auditService).logAfterCommit(
|
||||
eq(AuditKind.METADATA_UPDATED),
|
||||
eq(actorId),
|
||||
eq(id),
|
||||
eq(java.util.Map.of("source", "BULK_EDIT")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_replacesArchiveBoxAndFolderAndDocumentLocation_whenProvided() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.archiveBox("OldBox")
|
||||
.archiveFolder("OldFolder")
|
||||
.documentLocation("OldLocation")
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setArchiveBox("NewBox");
|
||||
dto.setArchiveFolder("NewFolder");
|
||||
dto.setDocumentLocation("NewLocation");
|
||||
documentService.applyBulkEditToDocument(id, dto, null);
|
||||
|
||||
assertThat(doc.getArchiveBox()).isEqualTo("NewBox");
|
||||
assertThat(doc.getArchiveFolder()).isEqualTo("NewFolder");
|
||||
assertThat(doc.getDocumentLocation()).isEqualTo("NewLocation");
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_propagatesDomainException_whenSenderIdUnresolvable() {
|
||||
// Sara C1 — unresolvable sender flows up as a per-document error chip
|
||||
// rather than aborting the controller's batch loop.
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID unknownSender = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(personService.getById(unknownSender))
|
||||
.thenThrow(DomainException.notFound(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.PERSON_NOT_FOUND,
|
||||
"Person not found: " + unknownSender));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setSenderId(unknownSender);
|
||||
|
||||
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, dto, null))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining(unknownSender.toString());
|
||||
}
|
||||
|
||||
// ─── findIdsForFilter ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findIdsForFilter_returnsAllMatchingIds_uncapped() {
|
||||
Document d1 = Document.builder().id(UUID.randomUUID()).title("A").build();
|
||||
Document d2 = Document.builder().id(UUID.randomUUID()).title("B").build();
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(List.of(d1, d2));
|
||||
|
||||
List<UUID> result = documentService.findIdsForFilter(
|
||||
null, null, null, null, null, null, null, null, null);
|
||||
|
||||
assertThat(result).containsExactly(d1.getId(), d2.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findIdsForFilter_passesTagOperatorOR_throughBuildSearchSpec() {
|
||||
// Sara C3 — tagOp=OR flips useOrLogic at the spec layer; without a
|
||||
// test pinning this, a refactor that wired OR to AND (or vice versa)
|
||||
// would slip through.
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(List.of());
|
||||
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
|
||||
|
||||
documentService.findIdsForFilter(
|
||||
null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR);
|
||||
|
||||
// Spec built without throwing → OR branch was exercised. Coverage gain
|
||||
// is in not-throwing on the OR-specific code path; the actual SQL is
|
||||
// covered by JPA itself.
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() {
|
||||
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
|
||||
|
||||
List<UUID> result = documentService.findIdsForFilter(
|
||||
"xyz", null, null, null, null, null, null, null, null);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class));
|
||||
}
|
||||
|
||||
// ─── batchMetadata ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void batchMetadata_returnsEmpty_whenIdsIsNull() {
|
||||
assertThat(documentService.batchMetadata(null)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchMetadata_returnsEmpty_whenIdsIsEmpty() {
|
||||
assertThat(documentService.batchMetadata(List.of())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchMetadata_returnsSummariesWithPdfUrl_forExistingIds() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
Document d1 = Document.builder().id(id1).title("Brief 1").build();
|
||||
Document d2 = Document.builder().id(id2).title("Brief 2").build();
|
||||
when(documentRepository.findAllById(List.of(id1, id2))).thenReturn(List.of(d1, d2));
|
||||
|
||||
var result = documentService.batchMetadata(List.of(id1, id2));
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result.get(0).id()).isEqualTo(id1);
|
||||
assertThat(result.get(0).title()).isEqualTo("Brief 1");
|
||||
assertThat(result.get(0).pdfUrl()).isEqualTo("/api/documents/" + id1 + "/file");
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchMetadata_silentlyDropsUnknownIds() {
|
||||
UUID known = UUID.randomUUID();
|
||||
UUID missing = UUID.randomUUID();
|
||||
Document d = Document.builder().id(known).title("Found").build();
|
||||
when(documentRepository.findAllById(List.of(known, missing))).thenReturn(List.of(d));
|
||||
|
||||
var result = documentService.batchMetadata(List.of(known, missing));
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).id()).isEqualTo(known);
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchMetadata_fallsBackToOriginalFilename_whenTitleIsNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document d = Document.builder().id(id).originalFilename("scan001.pdf").build();
|
||||
when(documentRepository.findAllById(List.of(id))).thenReturn(List.of(d));
|
||||
|
||||
var result = documentService.batchMetadata(List.of(id));
|
||||
|
||||
assertThat(result.get(0).title()).isEqualTo("scan001.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsLocationFields_whenBlankOrNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.archiveBox("KeepBox")
|
||||
.archiveFolder("KeepFolder")
|
||||
.documentLocation("KeepLocation")
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setArchiveBox(" ");
|
||||
dto.setArchiveFolder("");
|
||||
// documentLocation left null
|
||||
documentService.applyBulkEditToDocument(id, dto, null);
|
||||
|
||||
assertThat(doc.getArchiveBox()).isEqualTo("KeepBox");
|
||||
assertThat(doc.getArchiveFolder()).isEqualTo("KeepFolder");
|
||||
assertThat(doc.getDocumentLocation()).isEqualTo("KeepLocation");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,43 @@ class PersonServiceTest {
|
||||
assertThat(result.getAlias()).isEqualTo("Hans Müller");
|
||||
}
|
||||
|
||||
// ─── personType + title in createPerson(PersonUpdateDTO) ─────────────────
|
||||
|
||||
@Test
|
||||
void createPerson_dto_persistsPersonType() {
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Walter"); dto.setLastName("de Gruyter"); dto.setPersonType(PersonType.INSTITUTION);
|
||||
|
||||
Person result = personService.createPerson(dto);
|
||||
|
||||
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPerson_dto_throwsInvalidPersonType_whenSkip() {
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.SKIP);
|
||||
|
||||
assertThatThrownBy(() -> personService.createPerson(dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPerson_dto_persistsTitle() {
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Dr."); dto.setLastName("Müller"); dto.setTitle("Prof."); dto.setPersonType(PersonType.PERSON);
|
||||
|
||||
Person result = personService.createPerson(dto);
|
||||
|
||||
assertThat(result.getTitle()).isEqualTo("Prof.");
|
||||
}
|
||||
|
||||
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -145,6 +182,36 @@ class PersonServiceTest {
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
// ─── updatePerson (personType) ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsInvalidPersonType_whenSkip() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.SKIP);
|
||||
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_persistsPersonType() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").personType(PersonType.PERSON).build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.INSTITUTION);
|
||||
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
|
||||
}
|
||||
|
||||
// ─── updatePerson (alias) ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -2,9 +2,12 @@ package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||
@@ -34,6 +37,7 @@ class UserServiceTest {
|
||||
@Mock AppUserRepository userRepository;
|
||||
@Mock UserGroupRepository groupRepository;
|
||||
@Mock PasswordEncoder passwordEncoder;
|
||||
@Mock AuditService auditService;
|
||||
@InjectMocks UserService userService;
|
||||
|
||||
// ─── findByEmail ──────────────────────────────────────────────────────────
|
||||
@@ -61,7 +65,7 @@ class UserServiceTest {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> userService.deleteUser(id))
|
||||
assertThatThrownBy(() -> userService.deleteUser(UUID.randomUUID(), id))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@@ -71,7 +75,7 @@ class UserServiceTest {
|
||||
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
|
||||
userService.deleteUser(id);
|
||||
userService.deleteUser(UUID.randomUUID(), id);
|
||||
|
||||
verify(userRepository).delete(user);
|
||||
}
|
||||
@@ -90,7 +94,7 @@ class UserServiceTest {
|
||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
||||
when(userRepository.save(any())).thenReturn(saved);
|
||||
|
||||
AppUser result = userService.createUserOrUpdate(req);
|
||||
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(userRepository).save(any());
|
||||
@@ -108,7 +112,7 @@ class UserServiceTest {
|
||||
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
||||
when(userRepository.save(any())).thenReturn(existing);
|
||||
|
||||
userService.createUserOrUpdate(req);
|
||||
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||
|
||||
verify(userRepository, times(1)).save(existing);
|
||||
}
|
||||
@@ -229,7 +233,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getFirstName()).isEqualTo("Ada");
|
||||
assertThat(result.getLastName()).isEqualTo("Lovelace");
|
||||
@@ -246,7 +250,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setFirstName("Ada");
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getGroups()).containsExactly(adminGroup);
|
||||
}
|
||||
@@ -264,7 +268,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setGroupIds(List.of(newGroup.getId()));
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getGroups()).containsExactly(newGroup);
|
||||
}
|
||||
@@ -281,7 +285,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setGroupIds(List.of());
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getGroups()).isEmpty();
|
||||
}
|
||||
@@ -313,7 +317,7 @@ class UserServiceTest {
|
||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
||||
when(userRepository.save(any())).thenReturn(saved);
|
||||
|
||||
AppUser result = userService.createUserOrUpdate(req);
|
||||
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(groupRepository).findAllById(List.of(group.getId()));
|
||||
@@ -378,7 +382,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setNewPassword("newSecret");
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getPassword()).isEqualTo("newHashed");
|
||||
}
|
||||
@@ -393,7 +397,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setNewPassword(" ");
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getPassword()).isEqualTo("original");
|
||||
verify(passwordEncoder, never()).encode(any());
|
||||
@@ -408,7 +412,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setEmail(" ");
|
||||
|
||||
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
|
||||
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("blank");
|
||||
}
|
||||
@@ -425,7 +429,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setEmail("taken@example.com");
|
||||
|
||||
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
|
||||
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("E-Mail");
|
||||
}
|
||||
@@ -497,7 +501,7 @@ class UserServiceTest {
|
||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
||||
when(userRepository.save(any())).thenReturn(saved);
|
||||
|
||||
userService.createUserOrUpdate(req);
|
||||
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||
|
||||
verify(groupRepository, never()).findAllById(any());
|
||||
}
|
||||
@@ -561,7 +565,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setContact(null);
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getContact()).isNull();
|
||||
}
|
||||
@@ -576,7 +580,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setContact(" ");
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getContact()).isNull();
|
||||
}
|
||||
@@ -591,7 +595,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setContact(" phone: 555 ");
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getContact()).isEqualTo("phone: 555");
|
||||
}
|
||||
@@ -606,7 +610,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setEmail(null);
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getEmail()).isEqualTo("keep@example.com");
|
||||
}
|
||||
@@ -622,7 +626,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setEmail("me@example.com");
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
assertThat(result.getEmail()).isEqualTo("me@example.com");
|
||||
}
|
||||
|
||||
@@ -640,7 +644,7 @@ class UserServiceTest {
|
||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
|
||||
when(userRepository.save(any())).thenReturn(saved);
|
||||
|
||||
userService.createUserOrUpdate(req);
|
||||
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||
|
||||
verify(groupRepository, never()).findAllById(any());
|
||||
}
|
||||
@@ -699,6 +703,140 @@ class UserServiceTest {
|
||||
assertThat(result).containsExactly(g);
|
||||
}
|
||||
|
||||
// ─── audit: GROUP_MEMBERSHIP_CHANGED ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void adminUpdateUser_logsGroupMembershipChanged_whenGroupSetChanges() {
|
||||
UUID actorId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
UserGroup oldGroup = UserGroup.builder().id(UUID.randomUUID()).name("Viewers").permissions(Set.of("READ_ALL")).build();
|
||||
UserGroup newGroup = UserGroup.builder().id(UUID.randomUUID()).name("Editors").permissions(Set.of("WRITE_ALL")).build();
|
||||
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(oldGroup)).build();
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||
when(groupRepository.findAllById(List.of(newGroup.getId()))).thenReturn(List.of(newGroup));
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setGroupIds(List.of(newGroup.getId()));
|
||||
|
||||
userService.adminUpdateUser(actorId, userId, dto);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
||||
verify(auditService).logAfterCommit(
|
||||
org.mockito.ArgumentMatchers.eq(AuditKind.GROUP_MEMBERSHIP_CHANGED),
|
||||
org.mockito.ArgumentMatchers.eq(actorId),
|
||||
org.mockito.ArgumentMatchers.isNull(),
|
||||
payloadCaptor.capture());
|
||||
java.util.Map<String, Object> payload = payloadCaptor.getValue();
|
||||
assertThat(payload).containsEntry("email", "u@example.com");
|
||||
assertThat((java.util.List<String>) payload.get("addedGroups")).containsExactly("Editors");
|
||||
assertThat((java.util.List<String>) payload.get("removedGroups")).containsExactly("Viewers");
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupsUnchanged() {
|
||||
UUID actorId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
|
||||
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||
when(groupRepository.findAllById(List.of(group.getId()))).thenReturn(List.of(group));
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setGroupIds(List.of(group.getId()));
|
||||
|
||||
userService.adminUpdateUser(actorId, userId, dto);
|
||||
|
||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupIdsIsNull() {
|
||||
UUID actorId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
|
||||
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
// groupIds not set → null
|
||||
|
||||
userService.adminUpdateUser(actorId, userId, dto);
|
||||
|
||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ─── audit: USER_DELETED ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteUser_logsUserDeleted_withEmailInPayload() {
|
||||
UUID actorId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(userId).email("gone@example.com").build();
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||
|
||||
userService.deleteUser(actorId, userId);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
||||
verify(auditService).logAfterCommit(
|
||||
org.mockito.ArgumentMatchers.eq(AuditKind.USER_DELETED),
|
||||
org.mockito.ArgumentMatchers.eq(actorId),
|
||||
org.mockito.ArgumentMatchers.isNull(),
|
||||
payloadCaptor.capture());
|
||||
assertThat(payloadCaptor.getValue()).containsEntry("email", "gone@example.com");
|
||||
assertThat(payloadCaptor.getValue()).containsKey("userId");
|
||||
}
|
||||
|
||||
// ─── audit: USER_CREATED ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createUserOrUpdate_logsUserCreated_whenUserIsNew() {
|
||||
UUID actorId = UUID.randomUUID();
|
||||
CreateUserRequest req = new CreateUserRequest();
|
||||
req.setEmail("new@example.com");
|
||||
req.setInitialPassword("secret");
|
||||
req.setGroupIds(List.of());
|
||||
|
||||
when(userRepository.findByEmail("new@example.com")).thenReturn(Optional.empty());
|
||||
when(passwordEncoder.encode("secret")).thenReturn("encoded");
|
||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
||||
when(userRepository.save(any())).thenReturn(saved);
|
||||
|
||||
userService.createUserOrUpdate(actorId, req);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
||||
verify(auditService).logAfterCommit(
|
||||
org.mockito.ArgumentMatchers.eq(AuditKind.USER_CREATED),
|
||||
org.mockito.ArgumentMatchers.eq(actorId),
|
||||
org.mockito.ArgumentMatchers.isNull(),
|
||||
payloadCaptor.capture());
|
||||
assertThat(payloadCaptor.getValue()).containsKey("userId");
|
||||
assertThat(payloadCaptor.getValue()).containsEntry("email", "new@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserOrUpdate_doesNotLogUserCreated_whenUserAlreadyExists() {
|
||||
UUID actorId = UUID.randomUUID();
|
||||
CreateUserRequest req = new CreateUserRequest();
|
||||
req.setEmail("existing@example.com");
|
||||
req.setInitialPassword("pass");
|
||||
req.setGroupIds(List.of());
|
||||
|
||||
AppUser existing = AppUser.builder().id(UUID.randomUUID()).email("existing@example.com").build();
|
||||
when(userRepository.findByEmail("existing@example.com")).thenReturn(Optional.of(existing));
|
||||
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
||||
when(userRepository.save(any())).thenReturn(existing);
|
||||
|
||||
userService.createUserOrUpdate(actorId, req);
|
||||
|
||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ─── createGroup ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
75
frontend/e2e/bulk-edit.spec.ts
Normal file
75
frontend/e2e/bulk-edit.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E coverage for the bulk metadata edit feature (issue #225).
|
||||
*
|
||||
* Assumptions:
|
||||
* - Auth setup has run as the admin user (WRITE_ALL).
|
||||
* - The backend exposes /api/documents/{bulk,batch-metadata,ids}.
|
||||
* - At least two documents exist in the search list at /documents.
|
||||
*/
|
||||
|
||||
test.describe('Bulk metadata edit', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/documents');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
});
|
||||
|
||||
test('checking two documents shows the sticky selection bar with the count', async ({ page }) => {
|
||||
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
|
||||
await expect(checkboxes.first()).toBeVisible();
|
||||
await checkboxes.nth(0).check();
|
||||
await checkboxes.nth(1).check();
|
||||
|
||||
const bar = page.getByTestId('bulk-selection-bar');
|
||||
await expect(bar).toBeVisible();
|
||||
await expect(page.getByTestId('bulk-selection-count')).toContainText('2');
|
||||
});
|
||||
|
||||
test('Alles aufheben hides the bar', async ({ page }) => {
|
||||
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
|
||||
await checkboxes.nth(0).check();
|
||||
await expect(page.getByTestId('bulk-selection-bar')).toBeVisible();
|
||||
|
||||
await page.getByTestId('bulk-clear-all').click();
|
||||
await expect(page.getByTestId('bulk-selection-bar')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Massenbearbeitung navigates to bulk-edit with the selected documents', async ({ page }) => {
|
||||
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
|
||||
await checkboxes.nth(0).check();
|
||||
await checkboxes.nth(1).check();
|
||||
await page.getByTestId('bulk-edit-open').click();
|
||||
|
||||
await page.waitForURL('**/documents/bulk-edit');
|
||||
// Onboarding callout is the surest indicator the edit-mode layout rendered.
|
||||
await expect(page.getByTestId('bulk-edit-callout')).toBeVisible();
|
||||
});
|
||||
|
||||
test('navigating to /documents/bulk-edit with no selection redirects back to /documents', async ({
|
||||
page
|
||||
}) => {
|
||||
// Navigate directly without checking anything first.
|
||||
await page.goto('/documents/bulk-edit');
|
||||
await page.waitForURL('**/documents');
|
||||
expect(page.url()).toMatch(/\/documents(\?|$)/);
|
||||
});
|
||||
|
||||
test('the same selection bar drives the /enrich page', async ({ page }) => {
|
||||
await page.goto('/enrich');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// /enrich may legitimately be empty if every doc has metadata. In that
|
||||
// case there's nothing to bulk-select; skip.
|
||||
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
|
||||
const count = await checkboxes.count();
|
||||
test.skip(count === 0, 'No incomplete documents available on /enrich');
|
||||
|
||||
await checkboxes.first().check();
|
||||
await expect(page.getByTestId('bulk-selection-bar')).toBeVisible();
|
||||
await expect(page.getByTestId('bulk-selection-count')).toContainText('1');
|
||||
|
||||
await page.getByTestId('bulk-clear-all').click();
|
||||
await expect(page.getByTestId('bulk-selection-bar')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -8,21 +8,27 @@ test.describe('Help chip — Read/Edit panel header', () => {
|
||||
docId = await createEmptyDocument(request);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await request.delete(`/api/documents/${docId}`);
|
||||
});
|
||||
|
||||
test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => {
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
|
||||
// Find and click the (?) help chip
|
||||
const helpBtn = page.locator('button[aria-expanded]');
|
||||
// Use the accessible label of the HelpPopover trigger (transcription_mode_help_label)
|
||||
const helpBtn = page.getByRole('button', { name: 'Lese- und Bearbeitungsmodus' });
|
||||
await expect(helpBtn).toBeVisible({ timeout: 5000 });
|
||||
await helpBtn.click();
|
||||
|
||||
// Popover should open
|
||||
await expect(page.locator('[role="tooltip"]')).toBeVisible();
|
||||
// Popover should open (role="region", not tooltip — click-triggered panels are regions)
|
||||
await expect(page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })).toBeVisible();
|
||||
|
||||
// Press Esc
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('[role="tooltip"]')).not.toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })
|
||||
).not.toBeVisible();
|
||||
|
||||
// Focus should have returned to the chip
|
||||
await expect(helpBtn).toBeFocused();
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
import type { APIRequestContext } 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');
|
||||
|
||||
export async function createEmptyDocument(request: APIRequestContext): Promise<string> {
|
||||
const res = await request.post('/api/documents', {
|
||||
const createRes = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E Transcribe Coach Test' }
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Create document failed: ${res.status()}`);
|
||||
const doc = await res.json();
|
||||
return doc.id as string;
|
||||
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||
const doc = await createRes.json();
|
||||
const docId = doc.id as string;
|
||||
|
||||
const uploadRes = await request.put(`/api/documents/${docId}`, {
|
||||
multipart: {
|
||||
title: doc.title,
|
||||
file: {
|
||||
name: 'minimal.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||
|
||||
return docId;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,12 @@ test.describe('Richtlinien page — print media', () => {
|
||||
await expect(nav).toBeHidden();
|
||||
}
|
||||
|
||||
// .new-tab annotation spans must be hidden in print so "(öffnet in neuem Tab)"
|
||||
// text does not clutter the printed output (the print CSS declares display:none)
|
||||
for (const span of await page.locator('.new-tab').all()) {
|
||||
await expect(span).toBeHidden();
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,24 @@ import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { createEmptyDocument } from './helpers/upload-empty-document.js';
|
||||
|
||||
async function createBlock(
|
||||
request: Parameters<typeof createEmptyDocument>[0],
|
||||
docId: string
|
||||
): Promise<void> {
|
||||
const res = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||
data: {
|
||||
pageNumber: 1,
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.3,
|
||||
height: 0.1,
|
||||
text: 'Liebe Mutter,',
|
||||
label: null
|
||||
}
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Create block failed: ${res.status()}`);
|
||||
}
|
||||
|
||||
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||
}
|
||||
@@ -13,10 +31,13 @@ test.describe('Transcribe coach — empty state', () => {
|
||||
docId = await createEmptyDocument(request);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await request.delete(`/api/documents/${docId}`);
|
||||
});
|
||||
|
||||
test('shows coach card (title, preamble, three step bodies, animation region)', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
|
||||
@@ -31,14 +52,12 @@ test.describe('Transcribe coach — empty state', () => {
|
||||
});
|
||||
|
||||
test('training footer is NOT visible on empty doc', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
await expect(page.getByText('Für Training vormerken')).not.toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('axe: panel empty state — light theme — no WCAG 2.1 AA violations', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||
@@ -50,10 +69,9 @@ test.describe('Transcribe coach — empty state', () => {
|
||||
});
|
||||
|
||||
test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`/documents/${docId}`);
|
||||
// Toggle dark theme
|
||||
await page.getByRole('button', { name: /Farbmodus|theme/i }).click();
|
||||
await page.getByRole('button', { name: /dark mode/i }).click();
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||
timeout: 5000
|
||||
@@ -63,3 +81,25 @@ test.describe('Transcribe coach — empty state', () => {
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Transcribe coach — with blocks', () => {
|
||||
let docId: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
docId = await createEmptyDocument(request);
|
||||
await createBlock(request, docId);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await request.delete(`/api/documents/${docId}`);
|
||||
});
|
||||
|
||||
test('training footer IS visible when at least one block exists', async ({ page }) => {
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
// Wait for blocks to finish loading — block count confirms mode settled to 'read'
|
||||
await expect(page.getByText(/1 Abschnitt/)).toBeVisible({ timeout: 5000 });
|
||||
await page.locator('[data-testid="mode-edit"]').click();
|
||||
await expect(page.getByText('Für Training vormerken')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
"btn_back_to_overview": "Zurück zur Übersicht",
|
||||
"btn_back": "Zurück",
|
||||
"btn_back_to_document": "Zurück zum Dokument",
|
||||
"form_label_person_type": "Typ",
|
||||
"form_label_name": "Name",
|
||||
"form_label_first_name": "Vorname",
|
||||
"form_label_last_name": "Nachname",
|
||||
"form_label_alias": "Rufname / Alias",
|
||||
@@ -515,7 +517,6 @@
|
||||
"scan_collapse": "Scan verkleinern",
|
||||
"transcription_empty_title": "Noch keine Transkription",
|
||||
"transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.",
|
||||
"transcription_empty_draw_hint": "Zeichnen Sie Bereiche auf dem Dokument, um mit der Transkription zu beginnen.",
|
||||
"transcription_panel_close": "Panel schließen",
|
||||
"person_alias_heading": "Namensverlauf",
|
||||
"person_alias_empty": "Noch keine Namensaenderungen erfasst.",
|
||||
@@ -528,6 +529,7 @@
|
||||
"person_type_INSTITUTION": "Institution",
|
||||
"person_type_GROUP": "Gruppe",
|
||||
"person_type_UNKNOWN": "Unbekannt",
|
||||
"a11y_type_changed": "Typ geändert zu {type}",
|
||||
"person_alias_add_heading": "Name hinzufuegen",
|
||||
"person_alias_label_type": "Art",
|
||||
"person_alias_label_last_name": "Nachname",
|
||||
@@ -537,6 +539,9 @@
|
||||
"person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.",
|
||||
"person_alias_btn_delete": "Entfernen",
|
||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
||||
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
|
||||
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
||||
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||
"error_ocr_job_not_found": "Der OCR-Auftrag wurde nicht gefunden.",
|
||||
"error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.",
|
||||
@@ -828,9 +833,9 @@
|
||||
"transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.",
|
||||
|
||||
"richtlinien_title": "Transkriptions-Richtlinien",
|
||||
"richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal ob Tante Hedwig oder Cousin Paul tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
|
||||
"richtlinien_wiki_text": "Das vollständige Kurrent- und Sütterlin-Alphabet brauchen Sie für diese Seite nicht — das erledigt Wikipedia. Hier sind unsere eigenen Regeln für das, was Wikipedia nicht beantwortet.",
|
||||
"richtlinien_wiki_link": "Wikipedia →",
|
||||
"richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal wer tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
|
||||
"richtlinien_wiki_text": "Kurrent- und Sütterlin-Alphabete sind bei Wikipedia gut erklärt. Hier stehen nur unsere eigenen Vereinbarungen für dieses Archiv.",
|
||||
"richtlinien_wiki_link": "Wikipedia",
|
||||
"richtlinien_rules_label": "Regeln für die Transkription",
|
||||
"richtlinien_rule_unleserlich_title": "Nicht lesbare Wörter",
|
||||
"richtlinien_rule_unleserlich_body": "Wenn Sie ein Wort beim besten Willen nicht entziffern können, schreiben Sie [unleserlich]. Jemand anderes schaut später nochmal drauf.",
|
||||
@@ -850,5 +855,54 @@
|
||||
"richtlinien_klaer_umbrueche": "Originale Zeilenumbrüche",
|
||||
"richtlinien_klaer_caps": "Alte Groß-/Kleinschreibung",
|
||||
"richtlinien_closing_title": "Fehlt eine Regel?",
|
||||
"richtlinien_closing_body": "Stolpern Sie beim Transkribieren über eine Situation, die hier nicht steht — schreiben Sie einen Kommentar beim betreffenden Block. Wir sammeln sie und besprechen sie beim nächsten Familientreffen."
|
||||
"richtlinien_closing_body": "Stolpern Sie beim Transkribieren über eine Situation, die hier nicht steht — schreiben Sie einen Kommentar beim betreffenden Block. Wir sammeln sie und besprechen sie beim nächsten Familientreffen.",
|
||||
"error_batch_too_large": "Zu viele Dateien auf einmal — bitte in Blöcken hochladen.",
|
||||
"bulk_drop_hint": "Eine oder mehrere Dateien ablegen",
|
||||
"bulk_drop_sub": "PDF · bis zu 50 MB pro Datei",
|
||||
"bulk_count_pill": "{count} werden erstellt",
|
||||
"bulk_save_cta_one": "Speichern →",
|
||||
"bulk_save_cta": "{count} speichern →",
|
||||
"bulk_discard_all": "Alle verwerfen",
|
||||
"bulk_discard_confirm": "Alle Dateien und eingegebenen Daten verwerfen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"bulk_add_more": "Weitere hinzufügen",
|
||||
"bulk_scope_per_file_label": "Nur diese Datei",
|
||||
"bulk_scope_shared_label": "Gilt für alle {count}",
|
||||
"bulk_title_suggested_hint": "Vorschlag aus Dateiname — zum Bearbeiten anklicken",
|
||||
"bulk_switcher_prev": "Vorherige Datei",
|
||||
"bulk_switcher_next": "Nächste Datei",
|
||||
"bulk_file_error_chip_label": "Fehler beim Hochladen",
|
||||
"bulk_upload_progress": "{done} von {total} hochgeladen",
|
||||
"bulk_partial_success": "{created} erstellt, {failed} fehlgeschlagen",
|
||||
"bulk_all_failed": "Alle Uploads fehlgeschlagen",
|
||||
"bulk_drop_desc": "Für jede Datei wird ein eigenes Dokument erstellt. Der Titel wird aus dem Dateinamen vorausgefüllt — alle anderen Felder gelten für alle gemeinsam.",
|
||||
"bulk_select_files": "Dateien auswählen",
|
||||
"bulk_drop_zone_label": "Dateien ablegen",
|
||||
"bulk_remove_file": "Entfernen",
|
||||
"bulk_title_single": "Neues Dokument",
|
||||
"bulk_title_multi": "Neue Dokumente",
|
||||
"bulk_edit_button": "Massenbearbeitung",
|
||||
"bulk_edit_n_selected_one": "1 Dokument ausgewählt",
|
||||
"bulk_edit_n_selected_other": "{count} Dokumente ausgewählt",
|
||||
"bulk_edit_clear_all": "Alles aufheben",
|
||||
"bulk_edit_all_x": "Alle {count} editieren",
|
||||
"bulk_edit_select_document": "Dokument {title} auswählen",
|
||||
"bulk_edit_hint": "Nur ausgefüllte Felder werden angewendet. Tags und Empfänger werden hinzugefügt, nicht ersetzt.",
|
||||
"bulk_edit_badge_additive": "+ wird hinzugefügt",
|
||||
"bulk_edit_badge_replace": "wird ersetzt",
|
||||
"bulk_edit_save_progress": "Batch {done} von {total} verarbeitet",
|
||||
"bulk_edit_save_partial": "{done} von {total} gespeichert",
|
||||
"bulk_edit_retry": "Erneut versuchen",
|
||||
"bulk_edit_title": "Massenbearbeitung",
|
||||
"bulk_edit_save_button": "Anwenden",
|
||||
"error_bulk_edit_too_many_ids": "Maximal 500 Dokumente pro Anfrage.",
|
||||
"form_label_archive_box": "Karton",
|
||||
"form_helper_archive_box": "Welcher Karton im Archiv?",
|
||||
"form_label_archive_folder": "Mappe",
|
||||
"form_helper_archive_folder": "Welche Mappe innerhalb des Kartons?",
|
||||
"bulk_edit_clear_selection": "Auswahl aufheben",
|
||||
"bulk_edit_clear_hint_keyboard": "Esc: Auswahl aufheben",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
"btn_back_to_overview": "Back to overview",
|
||||
"btn_back": "Back",
|
||||
"btn_back_to_document": "Back to document",
|
||||
"form_label_person_type": "Type",
|
||||
"form_label_name": "Name",
|
||||
"form_label_first_name": "First name",
|
||||
"form_label_last_name": "Last name",
|
||||
"form_label_alias": "Nickname / Alias",
|
||||
@@ -515,7 +517,6 @@
|
||||
"scan_collapse": "Collapse scan",
|
||||
"transcription_empty_title": "No transcription yet",
|
||||
"transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.",
|
||||
"transcription_empty_draw_hint": "Draw regions on the document to start transcribing.",
|
||||
"transcription_panel_close": "Close panel",
|
||||
"person_alias_heading": "Name history",
|
||||
"person_alias_empty": "No name changes recorded yet.",
|
||||
@@ -528,6 +529,7 @@
|
||||
"person_type_INSTITUTION": "Institution",
|
||||
"person_type_GROUP": "Group",
|
||||
"person_type_UNKNOWN": "Unknown",
|
||||
"a11y_type_changed": "Type changed to {type}",
|
||||
"person_alias_add_heading": "Add name",
|
||||
"person_alias_label_type": "Type",
|
||||
"person_alias_label_last_name": "Last name",
|
||||
@@ -537,6 +539,9 @@
|
||||
"person_alias_delete_body": "This name will be removed from search results.",
|
||||
"person_alias_btn_delete": "Remove",
|
||||
"error_alias_not_found": "The name alias was not found.",
|
||||
"error_invalid_person_type": "The specified person type is not valid.",
|
||||
"validation_last_name_required": "Last name is required.",
|
||||
"validation_first_name_required": "First name is required.",
|
||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||
"error_ocr_job_not_found": "The OCR job was not found.",
|
||||
"error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.",
|
||||
@@ -828,9 +833,9 @@
|
||||
"transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.",
|
||||
|
||||
"richtlinien_title": "Transcription Guidelines",
|
||||
"richtlinien_intro": "So every letter is transcribed consistently — whether Tante Hedwig or Cousin Paul is typing — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
|
||||
"richtlinien_wiki_text": "You don't need the full Kurrent and Sütterlin alphabet on this page — that's what Wikipedia is for. Here are our own rules for everything Wikipedia can't answer.",
|
||||
"richtlinien_wiki_link": "Wikipedia →",
|
||||
"richtlinien_intro": "So every letter is transcribed consistently — no matter who types — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
|
||||
"richtlinien_wiki_text": "The Kurrent and Sütterlin alphabets are well explained on Wikipedia. Here you'll only find our own conventions for this archive.",
|
||||
"richtlinien_wiki_link": "Wikipedia",
|
||||
"richtlinien_rules_label": "Transcription rules",
|
||||
"richtlinien_rule_unleserlich_title": "Illegible words",
|
||||
"richtlinien_rule_unleserlich_body": "If you can't decipher a word even after trying, write [unleserlich]. Someone else will take another look later.",
|
||||
@@ -850,5 +855,54 @@
|
||||
"richtlinien_klaer_umbrueche": "Original line breaks",
|
||||
"richtlinien_klaer_caps": "Old capitalisation",
|
||||
"richtlinien_closing_title": "Missing a rule?",
|
||||
"richtlinien_closing_body": "If you hit a situation while transcribing that isn't listed here — leave a comment on the relevant block. We collect them and discuss them at the next family gathering."
|
||||
"richtlinien_closing_body": "If you hit a situation while transcribing that isn't listed here — leave a comment on the relevant block. We collect them and discuss them at the next family gathering.",
|
||||
"error_batch_too_large": "Too many files at once — please upload in smaller batches.",
|
||||
"bulk_drop_hint": "Drop one or more files here",
|
||||
"bulk_drop_sub": "PDF · up to 50 MB per file",
|
||||
"bulk_count_pill": "{count} will be created",
|
||||
"bulk_save_cta_one": "Save →",
|
||||
"bulk_save_cta": "Save {count} →",
|
||||
"bulk_discard_all": "Discard all",
|
||||
"bulk_discard_confirm": "Discard all files and entered data? This action cannot be undone.",
|
||||
"bulk_add_more": "Add more",
|
||||
"bulk_scope_per_file_label": "This file only",
|
||||
"bulk_scope_shared_label": "Applies to all {count}",
|
||||
"bulk_title_suggested_hint": "Suggested from filename — click to edit",
|
||||
"bulk_switcher_prev": "Previous file",
|
||||
"bulk_switcher_next": "Next file",
|
||||
"bulk_file_error_chip_label": "Upload failed",
|
||||
"bulk_upload_progress": "{done} of {total} uploaded",
|
||||
"bulk_partial_success": "{created} created, {failed} failed",
|
||||
"bulk_all_failed": "All uploads failed",
|
||||
"bulk_drop_desc": "A separate document is created for each file. The title is pre-filled from the filename — all other fields apply to all documents.",
|
||||
"bulk_select_files": "Select files",
|
||||
"bulk_drop_zone_label": "Drop files here",
|
||||
"bulk_remove_file": "Remove",
|
||||
"bulk_title_single": "New Document",
|
||||
"bulk_title_multi": "New Documents",
|
||||
"bulk_edit_button": "Bulk edit",
|
||||
"bulk_edit_n_selected_one": "1 document selected",
|
||||
"bulk_edit_n_selected_other": "{count} documents selected",
|
||||
"bulk_edit_clear_all": "Clear all",
|
||||
"bulk_edit_all_x": "Edit all {count}",
|
||||
"bulk_edit_select_document": "Select document {title}",
|
||||
"bulk_edit_hint": "Only filled fields are applied. Tags and receivers are added, not replaced.",
|
||||
"bulk_edit_badge_additive": "+ will be added",
|
||||
"bulk_edit_badge_replace": "will replace",
|
||||
"bulk_edit_save_progress": "Batch {done} of {total} processed",
|
||||
"bulk_edit_save_partial": "{done} of {total} saved",
|
||||
"bulk_edit_retry": "Retry",
|
||||
"bulk_edit_title": "Bulk edit",
|
||||
"bulk_edit_save_button": "Apply",
|
||||
"error_bulk_edit_too_many_ids": "Maximum 500 documents per request.",
|
||||
"form_label_archive_box": "Box",
|
||||
"form_helper_archive_box": "Which box in the archive?",
|
||||
"form_label_archive_folder": "Folder",
|
||||
"form_helper_archive_folder": "Which folder inside the box?",
|
||||
"bulk_edit_clear_selection": "Clear selection",
|
||||
"bulk_edit_clear_hint_keyboard": "Esc: clear selection",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
"btn_back_to_overview": "Volver al resumen",
|
||||
"btn_back": "Volver",
|
||||
"btn_back_to_document": "Volver al documento",
|
||||
"form_label_person_type": "Tipo",
|
||||
"form_label_name": "Nombre",
|
||||
"form_label_first_name": "Nombre",
|
||||
"form_label_last_name": "Apellido",
|
||||
"form_label_alias": "Apodo / Alias",
|
||||
@@ -515,7 +517,6 @@
|
||||
"scan_collapse": "Reducir escaneo",
|
||||
"transcription_empty_title": "Sin transcripcion",
|
||||
"transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.",
|
||||
"transcription_empty_draw_hint": "Dibuje regiones en el documento para comenzar a transcribir.",
|
||||
"transcription_panel_close": "Cerrar panel",
|
||||
"person_alias_heading": "Historial de nombres",
|
||||
"person_alias_empty": "Aun no se han registrado cambios de nombre.",
|
||||
@@ -528,6 +529,7 @@
|
||||
"person_type_INSTITUTION": "Institución",
|
||||
"person_type_GROUP": "Grupo",
|
||||
"person_type_UNKNOWN": "Desconocido",
|
||||
"a11y_type_changed": "Tipo cambiado a {type}",
|
||||
"person_alias_add_heading": "Agregar nombre",
|
||||
"person_alias_label_type": "Tipo",
|
||||
"person_alias_label_last_name": "Apellido",
|
||||
@@ -537,6 +539,9 @@
|
||||
"person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.",
|
||||
"person_alias_btn_delete": "Eliminar",
|
||||
"error_alias_not_found": "No se encontro el alias de nombre.",
|
||||
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
|
||||
"validation_last_name_required": "El apellido es obligatorio.",
|
||||
"validation_first_name_required": "El nombre es obligatorio.",
|
||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
||||
"error_ocr_job_not_found": "No se encontró el trabajo OCR.",
|
||||
"error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.",
|
||||
@@ -828,9 +833,9 @@
|
||||
"transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.",
|
||||
|
||||
"richtlinien_title": "Normas de transcripción",
|
||||
"richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — ya sea la tía Hedwig o el primo Paul quien escriba — aquí están nuestras reglas. La página crece con nosotros.",
|
||||
"richtlinien_wiki_text": "No necesitas el alfabeto Kurrent completo aquí — eso lo hace Wikipedia. Aquí están nuestras propias reglas para lo que Wikipedia no responde.",
|
||||
"richtlinien_wiki_link": "Wikipedia →",
|
||||
"richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — sin importar quién transcriba — aquí están nuestras reglas. La página crece con nosotros.",
|
||||
"richtlinien_wiki_text": "Los alfabetos Kurrent y Sütterlin están bien explicados en Wikipedia. Aquí solo se recogen nuestros propios acuerdos para este archivo.",
|
||||
"richtlinien_wiki_link": "Wikipedia",
|
||||
"richtlinien_rules_label": "Reglas de transcripción",
|
||||
"richtlinien_rule_unleserlich_title": "Palabras ilegibles",
|
||||
"richtlinien_rule_unleserlich_body": "Si no puedes descifrar una palabra, escribe [unleserlich]. Otra persona lo revisará después.",
|
||||
@@ -850,5 +855,54 @@
|
||||
"richtlinien_klaer_umbrueche": "Saltos de línea originales",
|
||||
"richtlinien_klaer_caps": "Mayúsculas antiguas",
|
||||
"richtlinien_closing_title": "¿Falta una regla?",
|
||||
"richtlinien_closing_body": "Si al transcribir encuentras una situación que no está aquí — deja un comentario en el bloque. Las recogemos y las discutimos en la próxima reunión familiar."
|
||||
"richtlinien_closing_body": "Si al transcribir encuentras una situación que no está aquí — deja un comentario en el bloque. Las recogemos y las discutimos en la próxima reunión familiar.",
|
||||
"error_batch_too_large": "Demasiados archivos a la vez — sube en lotes más pequeños.",
|
||||
"bulk_drop_hint": "Suelta uno o varios archivos aquí",
|
||||
"bulk_drop_sub": "PDF · hasta 50 MB por archivo",
|
||||
"bulk_count_pill": "Se crearán {count}",
|
||||
"bulk_save_cta_one": "Guardar →",
|
||||
"bulk_save_cta": "Guardar {count} →",
|
||||
"bulk_discard_all": "Descartar todo",
|
||||
"bulk_discard_confirm": "¿Descartar todos los archivos y datos introducidos? Esta acción no se puede deshacer.",
|
||||
"bulk_add_more": "Añadir más",
|
||||
"bulk_scope_per_file_label": "Solo este archivo",
|
||||
"bulk_scope_shared_label": "Para todos los {count}",
|
||||
"bulk_title_suggested_hint": "Sugerencia del nombre de archivo — haz clic para editar",
|
||||
"bulk_switcher_prev": "Archivo anterior",
|
||||
"bulk_switcher_next": "Archivo siguiente",
|
||||
"bulk_file_error_chip_label": "Error al subir",
|
||||
"bulk_upload_progress": "{done} de {total} subidos",
|
||||
"bulk_partial_success": "{created} creados, {failed} fallidos",
|
||||
"bulk_all_failed": "Todos los uploads fallaron",
|
||||
"bulk_drop_desc": "Se crea un documento separado por archivo. El título se rellena desde el nombre del archivo — el resto de campos se aplican a todos.",
|
||||
"bulk_select_files": "Seleccionar archivos",
|
||||
"bulk_drop_zone_label": "Soltar archivos aquí",
|
||||
"bulk_remove_file": "Eliminar",
|
||||
"bulk_title_single": "Nuevo Documento",
|
||||
"bulk_title_multi": "Nuevos Documentos",
|
||||
"bulk_edit_button": "Edición masiva",
|
||||
"bulk_edit_n_selected_one": "1 documento seleccionado",
|
||||
"bulk_edit_n_selected_other": "{count} documentos seleccionados",
|
||||
"bulk_edit_clear_all": "Limpiar todo",
|
||||
"bulk_edit_all_x": "Editar los {count}",
|
||||
"bulk_edit_select_document": "Seleccionar documento {title}",
|
||||
"bulk_edit_hint": "Solo se aplican los campos rellenados. Las etiquetas y los destinatarios se añaden, no se reemplazan.",
|
||||
"bulk_edit_badge_additive": "+ se añade",
|
||||
"bulk_edit_badge_replace": "se reemplaza",
|
||||
"bulk_edit_save_progress": "Lote {done} de {total} procesado",
|
||||
"bulk_edit_save_partial": "{done} de {total} guardado",
|
||||
"bulk_edit_retry": "Reintentar",
|
||||
"bulk_edit_title": "Edición masiva",
|
||||
"bulk_edit_save_button": "Aplicar",
|
||||
"error_bulk_edit_too_many_ids": "Máximo 500 documentos por solicitud.",
|
||||
"form_label_archive_box": "Caja",
|
||||
"form_helper_archive_box": "¿Qué caja del archivo?",
|
||||
"form_label_archive_folder": "Carpeta",
|
||||
"form_helper_archive_folder": "¿Qué carpeta dentro de la caja?",
|
||||
"bulk_edit_clear_selection": "Limpiar selección",
|
||||
"bulk_edit_clear_hint_keyboard": "Esc: limpiar selección",
|
||||
"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}"
|
||||
}
|
||||
|
||||
87
frontend/src/lib/actions/radioGroupNav.svelte.spec.ts
Normal file
87
frontend/src/lib/actions/radioGroupNav.svelte.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
const { radioGroupNav } = await import('./radioGroupNav');
|
||||
|
||||
describe('radioGroupNav action', () => {
|
||||
const nodes: HTMLElement[] = [];
|
||||
|
||||
function makeGroup(count: number): { container: HTMLElement; buttons: HTMLElement[] } {
|
||||
const container = document.createElement('div');
|
||||
container.setAttribute('role', 'radiogroup');
|
||||
const buttons: HTMLElement[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const btn = document.createElement('button');
|
||||
btn.setAttribute('role', 'radio');
|
||||
btn.setAttribute('aria-checked', i === 0 ? 'true' : 'false');
|
||||
btn.setAttribute('tabindex', i === 0 ? '0' : '-1');
|
||||
container.appendChild(btn);
|
||||
buttons.push(btn);
|
||||
}
|
||||
document.body.appendChild(container);
|
||||
nodes.push(container);
|
||||
return { container, buttons };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
nodes.forEach((n) => n.remove());
|
||||
nodes.length = 0;
|
||||
});
|
||||
|
||||
it('ArrowRight moves focus to next button', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[0].focus();
|
||||
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[1]);
|
||||
});
|
||||
|
||||
it('ArrowRight wraps from last to first', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[3].focus();
|
||||
buttons[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[0]);
|
||||
});
|
||||
|
||||
it('ArrowLeft moves focus to previous button', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[2].focus();
|
||||
buttons[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[1]);
|
||||
});
|
||||
|
||||
it('ArrowLeft wraps from first to last', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[0].focus();
|
||||
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[3]);
|
||||
});
|
||||
|
||||
it('ArrowRight updates aria-checked on new button and removes it from old', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[0].focus();
|
||||
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(buttons[1].getAttribute('aria-checked')).toBe('true');
|
||||
expect(buttons[0].getAttribute('aria-checked')).toBe('false');
|
||||
});
|
||||
|
||||
it('destroy removes keydown listener', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
const { destroy } = radioGroupNav(container);
|
||||
destroy();
|
||||
buttons[0].focus();
|
||||
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[0]);
|
||||
});
|
||||
|
||||
it('ignores non-arrow keys', () => {
|
||||
const { container, buttons } = makeGroup(4);
|
||||
radioGroupNav(container);
|
||||
buttons[0].focus();
|
||||
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[0]);
|
||||
});
|
||||
});
|
||||
37
frontend/src/lib/actions/radioGroupNav.ts
Normal file
37
frontend/src/lib/actions/radioGroupNav.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export function radioGroupNav(
|
||||
node: HTMLElement,
|
||||
onChange?: (value: string) => void
|
||||
): { destroy: () => void; update: (onChange?: (value: string) => void) => void } {
|
||||
let onChangeFn = onChange;
|
||||
|
||||
function getRadios(): HTMLElement[] {
|
||||
return Array.from(node.querySelectorAll<HTMLElement>('[role="radio"]'));
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
|
||||
|
||||
const radios = getRadios();
|
||||
const current = radios.indexOf(document.activeElement as HTMLElement);
|
||||
if (current === -1) return;
|
||||
|
||||
const delta = event.key === 'ArrowRight' ? 1 : -1;
|
||||
const next = (current + delta + radios.length) % radios.length;
|
||||
|
||||
radios[current].setAttribute('aria-checked', 'false');
|
||||
radios[next].setAttribute('aria-checked', 'true');
|
||||
radios[next].focus();
|
||||
onChangeFn?.(radios[next].getAttribute('value') ?? '');
|
||||
}
|
||||
|
||||
node.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return {
|
||||
update(newOnChange) {
|
||||
onChangeFn = newOnChange;
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener('keydown', handleKeydown);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -4,13 +4,14 @@ import type { components } from '$lib/generated/api';
|
||||
import { applyOffsets } from '$lib/search';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
import ProgressRing from './ProgressRing.svelte';
|
||||
import ContributorStack from './ContributorStack.svelte';
|
||||
import DocumentThumbnail from './DocumentThumbnail.svelte';
|
||||
|
||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||
|
||||
let { item }: { item: DocumentSearchItem } = $props();
|
||||
let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $props();
|
||||
|
||||
const doc = $derived(item.document);
|
||||
const titleText = $derived(doc.title || doc.originalFilename);
|
||||
@@ -55,6 +56,21 @@ function safeTagColor(color: string | null | undefined): string {
|
||||
<a href="/documents/{doc.id}" aria-label={titleText} class="absolute inset-0 z-0 block"></a>
|
||||
<div class="pointer-events-none relative z-10 px-4 py-4 sm:py-5">
|
||||
<div class="flex gap-3 sm:gap-5">
|
||||
<!-- Bulk-selection checkbox -->
|
||||
{#if canWrite}
|
||||
<label
|
||||
class="pointer-events-auto flex min-h-[44px] min-w-[44px] flex-shrink-0 cursor-pointer items-start pt-1"
|
||||
data-testid="bulk-select-checkbox"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-5 w-5 cursor-pointer accent-brand-navy"
|
||||
checked={bulkSelectionStore.has(doc.id)}
|
||||
onchange={() => bulkSelectionStore.toggle(doc.id)}
|
||||
aria-label={m.bulk_edit_select_document({ title: titleText })}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
<!-- Thumbnail tile -->
|
||||
<DocumentThumbnail doc={doc} size="lg" />
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { goto } from '$app/navigation';
|
||||
import DocumentRow from './DocumentRow.svelte';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
@@ -10,6 +11,7 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.mocked(goto).mockClear();
|
||||
bulkSelectionStore.clear();
|
||||
});
|
||||
|
||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||
@@ -265,6 +267,45 @@ describe('DocumentRow – tags', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bulk-selection checkbox ─────────────────────────────────────────────────
|
||||
|
||||
describe('DocumentRow – bulk selection checkbox', () => {
|
||||
it('does not render the checkbox when canWrite is false', async () => {
|
||||
render(DocumentRow, { item: makeItem(), canWrite: false });
|
||||
await expect.element(page.getByTestId('bulk-select-checkbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the checkbox when canWrite is true', async () => {
|
||||
render(DocumentRow, { item: makeItem(), canWrite: true });
|
||||
await expect.element(page.getByTestId('bulk-select-checkbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checkbox aria-label includes the document title', async () => {
|
||||
const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } });
|
||||
render(DocumentRow, { item, canWrite: true });
|
||||
await expect
|
||||
.element(page.getByRole('checkbox', { name: /Brief an Anna/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggling the checkbox calls bulkSelectionStore.toggle', async () => {
|
||||
const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } });
|
||||
render(DocumentRow, { item, canWrite: true });
|
||||
expect(bulkSelectionStore.has('doc-42')).toBe(false);
|
||||
|
||||
document.querySelector<HTMLInputElement>('input[type="checkbox"]')?.click();
|
||||
|
||||
await expect.poll(() => bulkSelectionStore.has('doc-42')).toBe(true);
|
||||
});
|
||||
|
||||
it('checked state mirrors the store', async () => {
|
||||
bulkSelectionStore.add('doc-99');
|
||||
const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } });
|
||||
render(DocumentRow, { item, canWrite: true });
|
||||
await expect.element(page.getByRole('checkbox')).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ProgressRing & ContributorStack ─────────────────────────────────────────
|
||||
|
||||
describe('DocumentRow – progress ring and contributors', () => {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
<script module>
|
||||
// Module-level counter produces stable, predictable IDs across SSR + hydration.
|
||||
// Math.random() would generate different values server-side vs client-side,
|
||||
// causing a hydration mismatch on first render.
|
||||
let _counter = 0;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
@@ -11,8 +18,9 @@ type Props = {
|
||||
|
||||
let { label, placement = 'bottom', children }: Props = $props();
|
||||
|
||||
const popoverId = `help-popover-${_counter++}`;
|
||||
|
||||
let open = $state(false);
|
||||
const popoverId = `help-popover-${Math.random().toString(36).slice(2)}`;
|
||||
let triggerEl: HTMLButtonElement | null = $state(null);
|
||||
|
||||
function toggle() {
|
||||
@@ -58,6 +66,10 @@ const placementClass: Record<Placement, string> = {
|
||||
</script>
|
||||
|
||||
<div class="relative inline-block">
|
||||
<!--
|
||||
Outer button is 44×44px for WCAG 2.5.8 touch-target compliance (our transcriber
|
||||
audience is 60+). The inner <span> carries the visual 20×20px circle.
|
||||
-->
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
type="button"
|
||||
@@ -65,15 +77,20 @@ const placementClass: Record<Placement, string> = {
|
||||
aria-expanded={open}
|
||||
aria-controls={popoverId}
|
||||
onclick={toggle}
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors hover:border-brand-navy hover:text-brand-navy"
|
||||
class="group flex h-[44px] w-[44px] items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
?
|
||||
<span
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors group-hover:border-brand-navy group-hover:text-brand-navy"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
id={popoverId}
|
||||
role="tooltip"
|
||||
role="region"
|
||||
aria-label={label}
|
||||
class="absolute z-50 w-64 rounded-sm border border-line bg-white p-3 font-sans text-sm text-ink shadow-md {placementClass[placement]}"
|
||||
>
|
||||
{#if children}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import HelpPopover from './HelpPopover.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -20,7 +20,7 @@ describe('HelpPopover — initial state', () => {
|
||||
renderPopover();
|
||||
const btn = page.getByRole('button', { name: /Help/ });
|
||||
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(document.querySelector('[role="tooltip"]')).toBeNull();
|
||||
expect(document.querySelector('[role="region"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,37 +30,61 @@ describe('HelpPopover — open / close interactions', () => {
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
const btn = page.getByRole('button', { name: /Help/ });
|
||||
await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||
expect(document.querySelector('[role="region"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('closes on Esc key', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||
expect(document.querySelector('[role="region"]')).not.toBeNull();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
|
||||
});
|
||||
|
||||
it('closes on outside click', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||
expect(document.querySelector('[role="region"]')).not.toBeNull();
|
||||
|
||||
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
|
||||
});
|
||||
|
||||
it('opens on Enter key (button is keyboard-reachable, Enter fires click)', async () => {
|
||||
it('opens on Enter key', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||
(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
|
||||
});
|
||||
|
||||
it('opens on Space key (button is keyboard-reachable, Space fires click)', async () => {
|
||||
it('opens on Space key', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
|
||||
(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
|
||||
await userEvent.keyboard('{Space}');
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe('HelpPopover — hover-target', () => {
|
||||
it('hover styles propagate from 44px button group to inner span, not from span itself', () => {
|
||||
const { container } = renderPopover();
|
||||
const btn = container.querySelector('button[aria-expanded]')!;
|
||||
const span = btn.querySelector('span')!;
|
||||
const btnClasses = btn.className.split(/\s+/);
|
||||
const spanClasses = span.className.split(/\s+/);
|
||||
expect(btnClasses).toContain('group');
|
||||
expect(spanClasses).not.toContain('hover:border-brand-navy');
|
||||
expect(spanClasses).toContain('group-hover:border-brand-navy');
|
||||
expect(spanClasses).not.toContain('hover:text-brand-navy');
|
||||
expect(spanClasses).toContain('group-hover:text-brand-navy');
|
||||
});
|
||||
|
||||
it('outer button has focus-visible ring for keyboard users', () => {
|
||||
const { container } = renderPopover();
|
||||
const btn = container.querySelector('button[aria-expanded]')!;
|
||||
expect(btn.className).toContain('focus-visible:ring-2');
|
||||
expect(btn.className).toContain('focus-visible:ring-brand-navy');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,4 +98,17 @@ describe('HelpPopover — aria wiring', () => {
|
||||
const popover = document.getElementById(controls!);
|
||||
expect(popover).not.toBeNull();
|
||||
});
|
||||
|
||||
it('two renders produce different, predictable IDs (no Math.random — SSR safe)', async () => {
|
||||
const { container: c1 } = render(HelpPopover, { props: { label: 'A' } });
|
||||
const { container: c2 } = render(HelpPopover, { props: { label: 'B' } });
|
||||
const id1 = c1.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
|
||||
const id2 = c2.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
|
||||
expect(id1).toBeTruthy();
|
||||
expect(id2).toBeTruthy();
|
||||
expect(id1).not.toBe(id2);
|
||||
// IDs must be deterministic (counter-based), not random hex
|
||||
expect(id1).toMatch(/^help-popover-\d+$/);
|
||||
expect(id2).toMatch(/^help-popover-\d+$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ function removePerson(id: string | undefined) {
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||
<div
|
||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
||||
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 selectedPersons as person (person.id)}
|
||||
<span
|
||||
|
||||
58
frontend/src/lib/components/PersonTypeSelector.svelte
Normal file
58
frontend/src/lib/components/PersonTypeSelector.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { radioGroupNav } from '$lib/actions/radioGroupNav';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person-validation';
|
||||
|
||||
let {
|
||||
value = 'PERSON',
|
||||
name = 'personType',
|
||||
onchange
|
||||
}: { value?: string; name?: string; onchange?: (type: PersonType) => void } = $props();
|
||||
|
||||
let selected = $state<PersonType>(
|
||||
untrack(() => (TYPES.includes(value as PersonType) ? (value as PersonType) : 'PERSON'))
|
||||
);
|
||||
|
||||
let announcement = $state('');
|
||||
|
||||
const labels: Record<PersonType, () => string> = {
|
||||
PERSON: m.person_type_PERSON,
|
||||
INSTITUTION: m.person_type_INSTITUTION,
|
||||
GROUP: m.person_type_GROUP,
|
||||
UNKNOWN: m.person_type_UNKNOWN
|
||||
};
|
||||
|
||||
function select(type: PersonType) {
|
||||
selected = type;
|
||||
announcement = m.a11y_type_changed({ type: labels[type]() });
|
||||
onchange?.(type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={m.form_label_person_type()}
|
||||
class="grid grid-cols-2 gap-2 sm:grid-cols-4"
|
||||
use:radioGroupNav={(v) => { if (TYPES.includes(v as PersonType)) select(v as PersonType); }}
|
||||
>
|
||||
{#each TYPES as type (type)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
value={type}
|
||||
aria-checked={selected === type}
|
||||
tabindex={selected === type ? 0 : -1}
|
||||
onclick={() => select(type)}
|
||||
class="min-h-[48px] cursor-pointer rounded-sm border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none {selected === type
|
||||
? 'border-primary bg-primary text-primary-fg'
|
||||
: 'border-line bg-surface text-ink hover:border-primary/50'}"
|
||||
>
|
||||
{labels[type]()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<input type="hidden" name={name} value={selected} />
|
||||
|
||||
<div class="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
|
||||
import PersonTypeSelector from './PersonTypeSelector.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('PersonTypeSelector', () => {
|
||||
it('radiogroup has an accessible name via aria-label', () => {
|
||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||
const radiogroup = container.querySelector('[role="radiogroup"]');
|
||||
expect(radiogroup).not.toBeNull();
|
||||
expect(radiogroup!.getAttribute('aria-label')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hidden input value updates when user navigates with ArrowRight', async () => {
|
||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
|
||||
expect(hiddenInput.value).toBe('PERSON');
|
||||
|
||||
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
|
||||
personButton.focus();
|
||||
await userEvent.keyboard('{ArrowRight}');
|
||||
|
||||
expect(hiddenInput.value).toBe('INSTITUTION');
|
||||
});
|
||||
|
||||
it('hidden input value updates when user navigates with ArrowLeft (wraps around)', async () => {
|
||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
|
||||
expect(hiddenInput.value).toBe('PERSON');
|
||||
|
||||
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
|
||||
personButton.focus();
|
||||
await userEvent.keyboard('{ArrowLeft}');
|
||||
|
||||
expect(hiddenInput.value).toBe('UNKNOWN');
|
||||
});
|
||||
it('exactly one button is aria-checked=true for the initial value', () => {
|
||||
const { container } = render(PersonTypeSelector, { value: 'INSTITUTION' });
|
||||
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||
const checked = buttons.filter((b) => b.getAttribute('aria-checked') === 'true');
|
||||
const unchecked = buttons.filter((b) => b.getAttribute('aria-checked') === 'false');
|
||||
expect(checked).toHaveLength(1);
|
||||
expect(unchecked).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('aria-checked=true moves to clicked button on click', async () => {
|
||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||
const groupButton = buttons.find((b) => b.getAttribute('value') === 'GROUP') as HTMLElement;
|
||||
await userEvent.click(groupButton);
|
||||
expect(groupButton.getAttribute('aria-checked')).toBe('true');
|
||||
const others = buttons.filter((b) => b !== groupButton);
|
||||
for (const btn of others) {
|
||||
expect(btn.getAttribute('aria-checked')).toBe('false');
|
||||
}
|
||||
});
|
||||
|
||||
it('selected button has tabindex=0, unselected buttons have tabindex=-1', () => {
|
||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||
const selected = buttons.find((b) => b.getAttribute('aria-checked') === 'true');
|
||||
const unselected = buttons.filter((b) => b.getAttribute('aria-checked') !== 'true');
|
||||
expect(selected!.getAttribute('tabindex')).toBe('0');
|
||||
for (const btn of unselected) {
|
||||
expect(btn.getAttribute('tabindex')).toBe('-1');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
|
||||
import FieldLabelBadge from './document/FieldLabelBadge.svelte';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
@@ -18,6 +19,7 @@ interface Props {
|
||||
autofocus?: boolean;
|
||||
required?: boolean;
|
||||
restrictToCorrespondentsOf?: string;
|
||||
badge?: 'additive' | 'replace';
|
||||
onchange?: (value: string) => void;
|
||||
onfocused?: () => void;
|
||||
}
|
||||
@@ -34,6 +36,7 @@ let {
|
||||
autofocus = false,
|
||||
required = false,
|
||||
restrictToCorrespondentsOf,
|
||||
badge,
|
||||
onchange,
|
||||
onfocused
|
||||
}: Props = $props();
|
||||
@@ -116,7 +119,7 @@ function selectPerson(person: Person) {
|
||||
class={compact
|
||||
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||
: 'block text-sm font-medium text-ink-2'}
|
||||
>{label}{#if required}*{/if}</label
|
||||
>{label}{#if required}*{/if}{#if badge}<FieldLabelBadge variant={badge} />{/if}</label
|
||||
>
|
||||
|
||||
<input type="hidden" name={name} bind:value={value} />
|
||||
@@ -134,7 +137,7 @@ function selectPerson(person: Person) {
|
||||
? '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'
|
||||
: compact
|
||||
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||
: 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||
/>
|
||||
|
||||
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
||||
|
||||
@@ -3,11 +3,21 @@ type Props = {
|
||||
icon: string;
|
||||
title: string;
|
||||
body: string;
|
||||
beispielInput?: string;
|
||||
beispielInputStrike?: boolean;
|
||||
beispielOutput?: string;
|
||||
beispielLabel?: string;
|
||||
};
|
||||
|
||||
let { icon, title, body, beispielOutput, beispielLabel = 'Beispiel' }: Props = $props();
|
||||
let {
|
||||
icon,
|
||||
title,
|
||||
body,
|
||||
beispielInput,
|
||||
beispielInputStrike = false,
|
||||
beispielOutput,
|
||||
beispielLabel = 'Beispiel'
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="border-brand-sand break-inside-avoid rounded-sm border bg-white p-5 shadow-sm">
|
||||
@@ -18,12 +28,18 @@ let { icon, title, body, beispielOutput, beispielLabel = 'Beispiel' }: Props = $
|
||||
<p class="font-serif text-sm leading-relaxed text-ink-2">{body}</p>
|
||||
|
||||
{#if beispielOutput !== undefined}
|
||||
<div class="border-brand-sand mt-4 rounded-sm border bg-[#FAF8F1] px-4 py-3">
|
||||
<div class="border-brand-sand mt-4 rounded-sm border bg-parchment px-4 py-3">
|
||||
<p class="font-sans text-xs font-semibold tracking-wider text-ink-3 uppercase">
|
||||
{beispielLabel}
|
||||
</p>
|
||||
<p class="mt-1 font-sans text-sm text-ink">
|
||||
→ <code class="font-mono">{beispielOutput}</code>
|
||||
{#if beispielInput !== undefined}
|
||||
<code
|
||||
class={['font-mono', beispielInputStrike && 'line-through'].filter(Boolean).join(' ')}
|
||||
>{beispielInput}</code
|
||||
> →
|
||||
{/if}
|
||||
<code class="font-mono">{beispielOutput}</code>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -13,7 +13,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
|
||||
|
||||
<ol class="m-0 flex list-none flex-col gap-[18px] p-0">
|
||||
<!-- Step 1 -->
|
||||
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
|
||||
<li aria-label="Schritt 1 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
|
||||
@@ -27,7 +27,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
|
||||
</li>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
|
||||
<li aria-label="Schritt 2 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
|
||||
@@ -40,7 +40,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
|
||||
</li>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
|
||||
<li aria-label="Schritt 3 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
<script lang="ts">
|
||||
const prefersReducedMotion = $derived(
|
||||
// $derived from .matches is a one-shot snapshot — it doesn't react when the
|
||||
// user toggles the OS setting at runtime. Use $state + addEventListener instead.
|
||||
let prefersReducedMotion = $state(
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
prefersReducedMotion = e.matches;
|
||||
};
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if prefersReducedMotion}
|
||||
@@ -10,7 +22,7 @@ const prefersReducedMotion = $derived(
|
||||
role="img"
|
||||
aria-label="Eine gestrichelte Umrandung markiert eine Zeile Kurrentschrift auf dem Dokument."
|
||||
viewBox="0 0 600 180"
|
||||
class="border-brand-sand block w-full rounded-sm border bg-[#FAF8F1]"
|
||||
class="border-brand-sand block w-full rounded-sm border bg-parchment"
|
||||
>
|
||||
<g
|
||||
stroke="#2a2a2a"
|
||||
@@ -61,7 +73,7 @@ const prefersReducedMotion = $derived(
|
||||
role="img"
|
||||
aria-label="Animation: Ein Cursor zieht einen gestrichelten Rahmen um eine Zeile Kurrentschrift. Beim Loslassen wird der Rahmen durchgehend und ein Häkchen erscheint."
|
||||
viewBox="0 0 600 180"
|
||||
class="border-brand-sand block w-full rounded-sm border bg-[#FAF8F1]"
|
||||
class="border-brand-sand block w-full rounded-sm border bg-parchment"
|
||||
>
|
||||
<!-- Kurrent writing (static) -->
|
||||
<g
|
||||
|
||||
@@ -177,6 +177,6 @@ describe('TranscriptionPanelHeader', () => {
|
||||
|
||||
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).not.toBeNull());
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
<script lang="ts">
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
import type { ConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
import BulkDropZone from './BulkDropZone.svelte';
|
||||
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
||||
import type { FileEntry } from './FileSwitcherStrip.svelte';
|
||||
import ScopeCard from './ScopeCard.svelte';
|
||||
import UploadSaveBar from './UploadSaveBar.svelte';
|
||||
import WhoWhenSection from './WhoWhenSection.svelte';
|
||||
import DescriptionSection from './DescriptionSection.svelte';
|
||||
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
||||
import { bulkTitleFromFilename } from '$lib/utils/filename';
|
||||
import type { Tag } from '$lib/components/TagInput.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
// Mirrors the backend `DocumentBatchSummary` JSON shape one-to-one — the route
|
||||
// passes the parsed `/api/documents/batch-metadata` response straight in, so
|
||||
// the field names must match what the backend actually serializes (id, not
|
||||
// documentId). The FileEntry built from each summary still uses both `id` and
|
||||
// `documentId` so the save handler can drive the PATCH payload by UUID.
|
||||
export type BulkEditEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
pdfUrl: string;
|
||||
};
|
||||
|
||||
// Optional — not available in unit tests that don't provide CONFIRM_KEY context.
|
||||
let _confirmService: ConfirmService | null;
|
||||
try {
|
||||
_confirmService = getConfirmService();
|
||||
} catch {
|
||||
_confirmService = null;
|
||||
}
|
||||
|
||||
let {
|
||||
mode = 'upload',
|
||||
initialSenderId = '',
|
||||
initialSenderName = '',
|
||||
initialReceivers = [],
|
||||
initialEditEntries = []
|
||||
}: {
|
||||
mode?: 'upload' | 'edit';
|
||||
initialSenderId?: string;
|
||||
initialSenderName?: string;
|
||||
initialReceivers?: Person[];
|
||||
initialEditEntries?: BulkEditEntry[];
|
||||
} = $props();
|
||||
|
||||
// --- File state ---
|
||||
let files = new SvelteMap<string, FileEntry>();
|
||||
let activeId = $state<string | null>(null);
|
||||
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
|
||||
let saving = $state(false);
|
||||
// Partial-failure surface: when set, the last save aborted at chunk N of M.
|
||||
let partialSaved = $state<{ done: number; total: number } | null>(null);
|
||||
|
||||
// --- Shared metadata ---
|
||||
let senderId = $state(untrack(() => initialSenderId));
|
||||
let selectedReceivers = $state<Person[]>(untrack(() => initialReceivers));
|
||||
let dateIso = $state('');
|
||||
let tags = $state<Tag[]>([]);
|
||||
// Bulk-edit only — replace-on-non-blank semantics.
|
||||
let archiveBox = $state('');
|
||||
let archiveFolder = $state('');
|
||||
|
||||
// Hydrate edit-mode entries on mount. The IDs in bulkSelectionStore drive the
|
||||
// fetch upstream in the route — by the time this layout mounts, the metadata
|
||||
// has already been resolved into `initialEditEntries`. Wrapped in onMount so
|
||||
// the SvelteMap mutation is unambiguously tied to instance lifecycle, not to
|
||||
// the script body's first execution (Felix C4 cycle 3).
|
||||
onMount(() => {
|
||||
if (mode !== 'edit') return;
|
||||
for (const entry of untrack(() => initialEditEntries)) {
|
||||
files.set(entry.id, {
|
||||
id: entry.id,
|
||||
documentId: entry.id,
|
||||
title: entry.title,
|
||||
status: 'idle',
|
||||
previewUrl: entry.pdfUrl
|
||||
});
|
||||
if (!activeId) activeId = entry.id;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Derived ---
|
||||
const isMulti = $derived(files.size >= 2);
|
||||
const activeFile = $derived(activeId ? files.get(activeId) : null);
|
||||
|
||||
// --- File management ---
|
||||
function addFiles(newFiles: File[]) {
|
||||
for (const file of newFiles) {
|
||||
const id = crypto.randomUUID();
|
||||
const title = bulkTitleFromFilename(file.name);
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
files.set(id, { id, file, title, status: 'idle', previewUrl });
|
||||
if (!activeId) activeId = id;
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(id: string) {
|
||||
const entry = files.get(id);
|
||||
if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||
files.delete(id);
|
||||
if (activeId === id) {
|
||||
activeId = files.keys().next().value ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function setTitle(id: string, title: string) {
|
||||
const entry = files.get(id);
|
||||
if (entry) files.set(id, { ...entry, title });
|
||||
}
|
||||
|
||||
function discardAll() {
|
||||
for (const entry of files.values()) {
|
||||
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||
}
|
||||
files.clear();
|
||||
activeId = null;
|
||||
chunkProgress = undefined;
|
||||
}
|
||||
|
||||
async function handleDiscard() {
|
||||
if (_confirmService) {
|
||||
const ok = await _confirmService.confirm({
|
||||
title: m.bulk_discard_all(),
|
||||
body: m.bulk_discard_confirm(),
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
if (mode === 'edit') {
|
||||
// In edit mode the file map IS the user's bulk selection — discarding
|
||||
// must clear the upstream store and bounce back to the list, otherwise
|
||||
// the user is left on /documents/bulk-edit with an empty form and a
|
||||
// stale count in the bottom bar (issue #225 Bulk-Edit Panel table).
|
||||
bulkSelectionStore.clear();
|
||||
discardAll();
|
||||
await goto('/documents');
|
||||
return;
|
||||
}
|
||||
discardAll();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
for (const entry of files.values()) {
|
||||
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Save (upload mode) ---
|
||||
async function saveUpload() {
|
||||
const entries = Array.from(files.values());
|
||||
// 10 files per request keeps multipart bodies well under typical reverse-proxy limits (e.g. nginx default 1 MB client_max_body_size per PDF).
|
||||
const chunkSize = 10;
|
||||
const chunks: FileEntry[][] = [];
|
||||
for (let i = 0; i < entries.length; i += chunkSize) {
|
||||
chunks.push(entries.slice(i, i + chunkSize));
|
||||
}
|
||||
chunkProgress = { done: 0, total: chunks.length };
|
||||
|
||||
let hadErrors = false;
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
const formData = new FormData();
|
||||
chunk.forEach((entry) => entry.file && formData.append('files', entry.file));
|
||||
const metadata = {
|
||||
titles: chunk.map((e) => e.title),
|
||||
senderId: senderId || null,
|
||||
receiverIds: selectedReceivers.map((r) => r.id),
|
||||
documentDate: dateIso || null,
|
||||
tagNames: tags.map((t) => t.name)
|
||||
};
|
||||
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
|
||||
// Raw fetch is intentional: SvelteKit form actions can't stream chunked
|
||||
// FormData with per-chunk progress. Session cookie is sent automatically
|
||||
// by the browser for same-origin requests.
|
||||
try {
|
||||
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
|
||||
const body = await res.json().catch(() => ({ errors: [] }));
|
||||
const errorFilenames = new Set<string>(
|
||||
(body.errors ?? []).map((err: { filename: string }) => err.filename)
|
||||
);
|
||||
if (!res.ok || errorFilenames.size > 0) {
|
||||
hadErrors = true;
|
||||
for (const entry of chunk) {
|
||||
const filename = entry.file?.name;
|
||||
const isError = errorFilenames.size > 0 && filename ? errorFilenames.has(filename) : true;
|
||||
if (isError) {
|
||||
const e = files.get(entry.id);
|
||||
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
hadErrors = true;
|
||||
for (const entry of chunk) {
|
||||
const e = files.get(entry.id);
|
||||
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||
}
|
||||
}
|
||||
chunkProgress = { done: i + 1, total: chunks.length };
|
||||
}
|
||||
if (!hadErrors) goto('/documents');
|
||||
}
|
||||
|
||||
// --- Save (edit mode) ---
|
||||
async function saveBulkEdit() {
|
||||
const entries = Array.from(files.values());
|
||||
const ids = entries.map((e) => e.documentId).filter((x): x is string => !!x);
|
||||
|
||||
// PATCH cap matches backend: 500 IDs per request. Sequential, stop on chunk
|
||||
// failure so the user sees a deterministic "X of N saved" outcome.
|
||||
const chunkSize = 500;
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < ids.length; i += chunkSize) {
|
||||
chunks.push(ids.slice(i, i + chunkSize));
|
||||
}
|
||||
chunkProgress = { done: 0, total: chunks.length };
|
||||
partialSaved = null;
|
||||
|
||||
const dto = {
|
||||
tagNames: tags.map((t) => t.name),
|
||||
senderId: senderId || null,
|
||||
receiverIds: selectedReceivers.map((r) => r.id),
|
||||
archiveBox: archiveBox || null,
|
||||
archiveFolder: archiveFolder || null
|
||||
};
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
try {
|
||||
const res = await fetch('/api/documents/bulk', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...dto, documentIds: chunk })
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Network/server failure: the chunk did not apply. Mark its entries
|
||||
// as errored, surface partial-save state, and stop.
|
||||
for (const id of chunk) {
|
||||
const e = files.get(id);
|
||||
if (e) files.set(id, { ...e, status: 'error' });
|
||||
}
|
||||
partialSaved = { done: i, total: chunks.length };
|
||||
return;
|
||||
}
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
updated: number;
|
||||
errors: { id: string; message: string }[];
|
||||
} | null;
|
||||
if (body && body.errors && body.errors.length > 0) {
|
||||
for (const err of body.errors) {
|
||||
const e = files.get(err.id);
|
||||
if (e) files.set(err.id, { ...e, status: 'error' });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
for (const id of chunk) {
|
||||
const e = files.get(id);
|
||||
if (e) files.set(id, { ...e, status: 'error' });
|
||||
}
|
||||
partialSaved = { done: i, total: chunks.length };
|
||||
return;
|
||||
}
|
||||
chunkProgress = { done: i + 1, total: chunks.length };
|
||||
}
|
||||
|
||||
const stillErrored = Array.from(files.values()).some((e) => e.status === 'error');
|
||||
if (!stillErrored) {
|
||||
bulkSelectionStore.clear();
|
||||
goto('/documents');
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
try {
|
||||
if (mode === 'edit') {
|
||||
await saveBulkEdit();
|
||||
} else {
|
||||
await saveUpload();
|
||||
}
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function retrySave() {
|
||||
partialSaved = null;
|
||||
await save();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: var(--header-height)">
|
||||
<!-- Topbar -->
|
||||
<div class="flex shrink-0 items-center gap-3 border-b border-line bg-surface px-6 py-3">
|
||||
<a
|
||||
href="/documents"
|
||||
class="flex items-center gap-1.5 text-xs font-bold tracking-widest text-ink-3 uppercase hover:text-ink"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
{m.btn_back_to_overview()}
|
||||
</a>
|
||||
<span class="text-ink-3" aria-hidden="true">·</span>
|
||||
<span class="font-serif text-sm font-bold text-ink">
|
||||
{#if mode === 'edit'}
|
||||
{m.bulk_edit_topbar_title()}
|
||||
{:else}
|
||||
{isMulti ? m.bulk_title_multi() : m.bulk_title_single()}
|
||||
{/if}
|
||||
</span>
|
||||
{#if isMulti}
|
||||
<span class="ml-auto flex items-center gap-3">
|
||||
<span class="rounded-[2px] bg-accent px-2 py-0.5 text-xs font-bold text-primary">
|
||||
{#if mode === 'edit'}
|
||||
{m.bulk_edit_count_pill({ count: files.size })}
|
||||
{:else}
|
||||
{m.bulk_count_pill({ count: files.size })}
|
||||
{/if}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="discard-all-btn"
|
||||
onclick={handleDiscard}
|
||||
class="text-xs font-medium text-red-600/70 hover:text-red-700"
|
||||
>
|
||||
{m.bulk_discard_all()}
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Split panel -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left: PDF preview / drop zone (55%) -->
|
||||
<div class="relative flex flex-[55] flex-col overflow-hidden border-r border-line bg-pdf-bg">
|
||||
{#if mode === 'upload' && files.size === 0}
|
||||
<!-- N=0: centred drop-zone box fills the panel (upload only) -->
|
||||
<BulkDropZone onFilesAdded={addFiles} />
|
||||
{:else if files.size > 0}
|
||||
<!-- PDF preview: blob URL in upload mode, server URL in edit mode -->
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
{#if activeFile}
|
||||
<PdfViewer url={activeFile.previewUrl} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if isMulti}
|
||||
<!-- File switcher strip pinned to bottom of left panel -->
|
||||
<FileSwitcherStrip
|
||||
files={Array.from(files.values())}
|
||||
activeId={activeId ?? ''}
|
||||
onSelect={(id) => (activeId = id)}
|
||||
onRemove={removeFile}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: metadata form (45%) -->
|
||||
<div class="flex flex-[45] flex-col overflow-hidden">
|
||||
<!-- Scrollable form area — greyed out and non-interactive when no files selected -->
|
||||
<div
|
||||
class="flex-1 space-y-4 overflow-y-auto p-4 transition-opacity"
|
||||
class:opacity-60={files.size === 0}
|
||||
class:pointer-events-none={files.size === 0}
|
||||
>
|
||||
{#if mode === 'edit'}
|
||||
<!-- Onboarding callout: tells the user that empty fields are skipped
|
||||
and that tags/receivers are added rather than replaced.
|
||||
No aria-label — role=note + the visible text content is
|
||||
self-describing; an aria-label would override that text for
|
||||
AT users on non-DE locales. -->
|
||||
<div
|
||||
role="note"
|
||||
data-testid="bulk-edit-callout"
|
||||
class="rounded-sm border border-accent/40 bg-accent/15 px-4 py-3 text-sm text-ink-2"
|
||||
>
|
||||
{m.bulk_edit_hint()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isMulti}
|
||||
<!-- N≥2: per-file card (title) + shared card (metadata) -->
|
||||
<ScopeCard variant="per-file">
|
||||
{#if activeFile}
|
||||
{#if mode === 'edit'}
|
||||
<div data-testid="readonly-title">
|
||||
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
|
||||
{m.form_label_title()}
|
||||
</span>
|
||||
<p class="font-serif text-base text-ink">{activeFile.title}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
|
||||
{m.form_label_title()} <span class="text-danger">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={activeFile.title}
|
||||
oninput={(e) =>
|
||||
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
||||
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
{/if}
|
||||
</ScopeCard>
|
||||
|
||||
<ScopeCard variant="shared" count={files.size}>
|
||||
<WhoWhenSection
|
||||
bind:senderId={senderId}
|
||||
bind:selectedReceivers={selectedReceivers}
|
||||
bind:dateIso={dateIso}
|
||||
initialSenderName={initialSenderName}
|
||||
hideDate={mode === 'edit'}
|
||||
editMode={mode === 'edit'}
|
||||
/>
|
||||
<DescriptionSection
|
||||
bind:tags={tags}
|
||||
bind:archiveBox={archiveBox}
|
||||
bind:archiveFolder={archiveFolder}
|
||||
hideTitle
|
||||
editMode={mode === 'edit'}
|
||||
/>
|
||||
</ScopeCard>
|
||||
{:else}
|
||||
<!-- N=0 (disabled placeholder) or N=1 (active): title + shared form -->
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
{#if mode === 'edit' && activeFile}
|
||||
<div data-testid="readonly-title">
|
||||
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.form_label_title()}
|
||||
</span>
|
||||
<p class="font-serif text-base text-ink">{activeFile.title}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.form_label_title()} <span class="text-danger">*</span>
|
||||
</span>
|
||||
{#if activeFile}
|
||||
<input
|
||||
type="text"
|
||||
value={activeFile.title}
|
||||
oninput={(e) =>
|
||||
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
placeholder="—"
|
||||
class="block w-full rounded border border-line p-2 text-sm text-ink-3 shadow-sm"
|
||||
/>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<WhoWhenSection
|
||||
bind:senderId={senderId}
|
||||
bind:selectedReceivers={selectedReceivers}
|
||||
bind:dateIso={dateIso}
|
||||
initialSenderName={initialSenderName}
|
||||
hideDate={mode === 'edit'}
|
||||
editMode={mode === 'edit'}
|
||||
/>
|
||||
<DescriptionSection
|
||||
bind:tags={tags}
|
||||
bind:archiveBox={archiveBox}
|
||||
bind:archiveFolder={archiveFolder}
|
||||
hideTitle
|
||||
editMode={mode === 'edit'}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if partialSaved}
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="bulk-edit-partial-failure"
|
||||
class="rounded-sm border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-danger"
|
||||
>
|
||||
<p class="font-medium">
|
||||
{m.bulk_edit_save_partial({
|
||||
done: partialSaved.done,
|
||||
total: partialSaved.total
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={retrySave}
|
||||
class="mt-2 inline-flex items-center bg-primary px-4 py-2 text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{m.bulk_edit_retry()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action bar: always visible at bottom of right panel -->
|
||||
<UploadSaveBar
|
||||
fileCount={files.size}
|
||||
chunkProgress={chunkProgress}
|
||||
onSave={save}
|
||||
onDiscard={handleDiscard}
|
||||
disabled={saving}
|
||||
editMode={mode === 'edit'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,543 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { goto } from '$app/navigation';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeFile(name: string): File {
|
||||
return new File(['content'], name, { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
async function addFilesViaInput(container: HTMLElement, files: File[]): Promise<void> {
|
||||
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
if (!input) throw new Error('No file input found — is BulkDropZone visible?');
|
||||
await userEvent.upload(input, files);
|
||||
}
|
||||
|
||||
describe('BulkDocumentEditLayout', () => {
|
||||
it('N=0: shows BulkDropZone', async () => {
|
||||
render(BulkDocumentEditLayout, {});
|
||||
await expect.element(page.getByTestId('bulk-drop-zone')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('N=1: file-switcher-strip and per-file scope card are absent', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('doc.pdf')]);
|
||||
expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull();
|
||||
expect(container.querySelector('[data-variant="per-file"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('N=5: file-switcher-strip and per-file scope card are both present', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [
|
||||
makeFile('a.pdf'),
|
||||
makeFile('b.pdf'),
|
||||
makeFile('c.pdf'),
|
||||
makeFile('d.pdf'),
|
||||
makeFile('e.pdf')
|
||||
]);
|
||||
expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-variant="per-file"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('removing middle file preserves order of remaining files', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [
|
||||
makeFile('file0.pdf'),
|
||||
makeFile('file1.pdf'),
|
||||
makeFile('file2.pdf')
|
||||
]);
|
||||
|
||||
// Remove the chip for file1 via its remove button (identified by data-remove-id)
|
||||
const removeButtons = container.querySelectorAll<HTMLButtonElement>(
|
||||
'[data-testid="file-switcher-strip"] button[data-remove-id]'
|
||||
);
|
||||
expect(removeButtons.length).toBe(3);
|
||||
removeButtons[1].click(); // remove file1
|
||||
|
||||
// Wait for Svelte to flush the DOM update
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const chips = container.querySelectorAll(
|
||||
'[data-testid="file-switcher-strip"] [data-chip-id]'
|
||||
);
|
||||
expect(chips.length).toBe(2);
|
||||
expect(chips[0].textContent?.trim()).toContain('file0');
|
||||
expect(chips[1].textContent?.trim()).toContain('file2');
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('save calls fetch twice for 12 files (2 chunks of 10)', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ created: [], updated: [], errors: [] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
const files = Array.from({ length: 12 }, (_, i) => makeFile(`f${i}.pdf`));
|
||||
await addFilesViaInput(container, files);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(saveBtn).not.toBeNull();
|
||||
saveBtn.click();
|
||||
|
||||
// Wait for async save to complete
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2), { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('save marks file as error when server returns non-ok response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ errors: [{ filename: 'f0.pdf', code: 'FILE_UPLOAD_FAILED' }] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('f0.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('save() includes tagNames in metadata payload', async () => {
|
||||
let capturedFormData: FormData | undefined;
|
||||
const mockFetch = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
|
||||
capturedFormData = init?.body as FormData;
|
||||
return { ok: true, json: async () => ({ created: [], updated: [], errors: [] }) };
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('doc.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
|
||||
expect(capturedFormData).toBeDefined();
|
||||
const metadataBlob = capturedFormData!.get('metadata') as Blob;
|
||||
const metadataJson = JSON.parse(await metadataBlob.text());
|
||||
expect(metadataJson).toHaveProperty('tagNames');
|
||||
});
|
||||
|
||||
it('save() navigates to /documents when all chunks succeed', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ created: [], updated: [], errors: [] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('doc.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('save() does not navigate when chunk returns non-ok response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ errors: [{ filename: 'f0.pdf', code: 'FILE_UPLOAD_FAILED' }] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('f0.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save marks only the file whose filename matches the backend error, not adjacent files', async () => {
|
||||
// backend returns error keyed to b.pdf — only b.pdf chip should get data-status="error"
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||
expect(errorChips.length).toBe(1);
|
||||
expect(errorChips[0].textContent).toContain('b');
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('save() marks only the failed file when server returns HTTP 200 with a partial errors array', async () => {
|
||||
// Backend can return 200 OK while reporting individual file failures
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
created: [{ id: '1' }],
|
||||
updated: [],
|
||||
errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }]
|
||||
})
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||
expect(errorChips.length).toBe(1);
|
||||
expect(errorChips[0].textContent).toContain('b');
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
// Navigation should be suppressed because hadErrors is true
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save() marks all chunk files as errored when fetch throws a network error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')));
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||
expect(errorChips.length).toBe(2);
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save() does not call fetch a second time when already saving', async () => {
|
||||
let resolveFirst: (() => void) | undefined;
|
||||
const mockFetch = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFirst = () =>
|
||||
resolve({
|
||||
ok: true,
|
||||
json: async () => ({ created: [], updated: [], errors: [] })
|
||||
} as Response);
|
||||
})
|
||||
);
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click(); // first click — fetch is in-flight
|
||||
saveBtn.click(); // second click — should be a no-op
|
||||
|
||||
resolveFirst?.();
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('discard-all resets to N=0 state and shows drop zone', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||
|
||||
// Confirm N=2 state — switcher is visible
|
||||
expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull();
|
||||
|
||||
// Click the topbar discard-all button (only visible in isMulti state)
|
||||
const discardBtn = container.querySelector(
|
||||
'button[data-testid="discard-all-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(discardBtn).not.toBeNull();
|
||||
discardBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(container.querySelector('[data-testid="bulk-drop-zone"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull();
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mode="edit" ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('BulkDocumentEditLayout — mode="edit" discard', () => {
|
||||
it('discard in edit mode clears the selection store and navigates back to /documents', async () => {
|
||||
const { bulkSelectionStore } = await import('$lib/stores/bulkSelection.svelte');
|
||||
bulkSelectionStore.setAll(['doc-1']);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [
|
||||
{ id: 'doc-1', title: 'Brief 1', pdfUrl: '/api/documents/doc-1/file' },
|
||||
{ id: 'doc-2', title: 'Brief 2', pdfUrl: '/api/documents/doc-2/file' }
|
||||
]
|
||||
});
|
||||
|
||||
const discardBtn = container.querySelector(
|
||||
'button[data-testid="discard-all-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(discardBtn).not.toBeNull();
|
||||
discardBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 1000 });
|
||||
expect(bulkSelectionStore.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BulkDocumentEditLayout — mode="edit"', () => {
|
||||
const editEntry = (i: number) => ({
|
||||
id: `doc-${i}`,
|
||||
title: `Brief ${i}`,
|
||||
pdfUrl: `/api/documents/doc-${i}/file`
|
||||
});
|
||||
|
||||
it('does not render the BulkDropZone in edit mode', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
expect(container.querySelector('[data-testid="bulk-drop-zone"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the onboarding callout with role=note in edit mode', async () => {
|
||||
render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
const callout = page.getByTestId('bulk-edit-callout');
|
||||
await expect.element(callout).toBeInTheDocument();
|
||||
await expect.element(callout).toHaveAttribute('role', 'note');
|
||||
});
|
||||
|
||||
it('renders read-only title display (no input) in edit mode', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
expect(container.querySelector('[data-testid="readonly-title"]')).not.toBeNull();
|
||||
// Per-file ScopeCard absent at N=1 — title rendered in the single card
|
||||
const titleInput = container.querySelector('input[type="text"][value="Brief 1"]');
|
||||
expect(titleInput).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the date field via WhoWhenSection hideDate prop', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
expect(container.querySelector('[data-testid="who-when-date"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows additive badge next to tags label', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
expect(container.querySelector('[data-testid="field-label-badge-additive"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows replace badges next to sender and archive fields', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
const replaceBadges = container.querySelectorAll('[data-testid="field-label-badge-replace"]');
|
||||
// sender + archiveBox + archiveFolder = 3
|
||||
expect(replaceBadges.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('topbar reads "Massenbearbeitung" + "{count} werden bearbeitet" in edit mode', async () => {
|
||||
// Elicit C1 fix — upload-flavoured "Mehrere Dokumente hochladen" /
|
||||
// "werden erstellt" copy must not appear when mode === 'edit'.
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1), editEntry(2)]
|
||||
});
|
||||
// Topbar title slot
|
||||
const topbar = container.querySelector('span.font-bold.text-ink');
|
||||
expect(topbar?.textContent).toContain('Massenbearbeitung');
|
||||
// Count pill
|
||||
const pill = container.querySelector('span.bg-accent');
|
||||
expect(pill?.textContent).toContain('werden bearbeitet');
|
||||
// Negative: must NOT show upload-flavoured copy
|
||||
expect(topbar?.textContent ?? '').not.toContain('hochladen');
|
||||
expect(pill?.textContent ?? '').not.toContain('werden erstellt');
|
||||
});
|
||||
|
||||
it('shows the archiveBox and archiveFolder bulk-only inputs', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
expect(container.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('save calls PATCH /api/documents/bulk in edit mode', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ updated: 2, errors: [] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1), editEntry(2)]
|
||||
});
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(saveBtn).not.toBeNull();
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/documents/bulk');
|
||||
expect(init.method).toBe('PATCH');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.documentIds).toEqual(['doc-1', 'doc-2']);
|
||||
});
|
||||
|
||||
it('chunks IDs into 500-sized PATCH requests', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ updated: 500, errors: [] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: entries
|
||||
});
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(3), { timeout: 5000 });
|
||||
expect(JSON.parse(mockFetch.mock.calls[0][1].body).documentIds.length).toBe(500);
|
||||
expect(JSON.parse(mockFetch.mock.calls[1][1].body).documentIds.length).toBe(500);
|
||||
expect(JSON.parse(mockFetch.mock.calls[2][1].body).documentIds.length).toBe(100);
|
||||
});
|
||||
|
||||
it('stops on chunk failure and shows the partial-failure alert with retry', async () => {
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ updated: 500, errors: [] }) })
|
||||
.mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'INTERNAL_ERROR' }) });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: entries
|
||||
});
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const alert = container.querySelector('[data-testid="bulk-edit-partial-failure"]');
|
||||
expect(alert).not.toBeNull();
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
// Should have called twice — chunks 0 and 1 — but not the third.
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(goto)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks per-document error chips when service returns errors[]', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
updated: 1,
|
||||
errors: [{ id: 'doc-2', message: 'Sender not found' }]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1), editEntry(2)]
|
||||
});
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const errorChip = container.querySelector(
|
||||
'[data-testid="file-switcher-strip"] [data-chip-id="doc-2"][data-status="error"]'
|
||||
);
|
||||
expect(errorChip).not.toBeNull();
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
80
frontend/src/lib/components/document/BulkDropZone.svelte
Normal file
80
frontend/src/lib/components/document/BulkDropZone.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
onFilesAdded
|
||||
}: {
|
||||
onFilesAdded: (files: File[]) => void;
|
||||
} = $props();
|
||||
|
||||
let isDragging = $state(false);
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="region"
|
||||
aria-label={m.bulk_drop_zone_label()}
|
||||
aria-describedby="bulk-drop-desc"
|
||||
data-testid="bulk-drop-zone"
|
||||
class="flex flex-1 flex-col items-center justify-center p-6"
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
}}
|
||||
ondragleave={() => (isDragging = false)}
|
||||
ondrop={(e) => {
|
||||
e.preventDefault();
|
||||
isDragging = false;
|
||||
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
||||
onFilesAdded(Array.from(e.dataTransfer.files));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class={[
|
||||
'flex w-full max-w-xl flex-col items-center gap-5 rounded-md border-2 border-dashed px-12 py-16 text-center transition-colors',
|
||||
isDragging ? 'border-accent bg-accent/10' : 'border-accent/50 bg-white/[0.04]'
|
||||
].join(' ')}
|
||||
>
|
||||
<!-- Circular mint icon -->
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-accent text-primary">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon
|
||||
fill="currentColor"
|
||||
points="6 12.5 16 2 26 12.5 24.5714286 14 16.999 6.049 17 30 15 30 14.999 6.051 7.42857143 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Serif title -->
|
||||
<p class="font-serif text-base font-bold text-ink">{m.bulk_drop_hint()}</p>
|
||||
|
||||
<!-- Sub description -->
|
||||
<p id="bulk-drop-desc" class="text-sm leading-relaxed text-ink-2">{m.bulk_drop_desc()}</p>
|
||||
|
||||
<!-- CTA button -->
|
||||
<label
|
||||
class="flex min-h-[44px] cursor-pointer items-center rounded-sm bg-primary px-6 py-2 text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90"
|
||||
>
|
||||
{m.bulk_select_files()}
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="application/pdf"
|
||||
class="sr-only"
|
||||
onchange={(e) => {
|
||||
const files = Array.from(e.currentTarget.files ?? []);
|
||||
if (files.length > 0) onFilesAdded(files);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Format hint -->
|
||||
<p class="text-xs text-ink-3">{m.bulk_drop_sub()}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import BulkDropZone from './BulkDropZone.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('BulkDropZone', () => {
|
||||
it('file input has multiple attribute', async () => {
|
||||
const { container } = render(BulkDropZone, { onFilesAdded: vi.fn() });
|
||||
const input = container.querySelector('input[type="file"]');
|
||||
expect(input).not.toBeNull();
|
||||
expect(input?.hasAttribute('multiple')).toBe(true);
|
||||
});
|
||||
|
||||
it('fires onFilesAdded with selected files when 3 files are picked via input', async () => {
|
||||
const onFilesAdded = vi.fn();
|
||||
render(BulkDropZone, { onFilesAdded });
|
||||
|
||||
const files = [
|
||||
new File(['a'], 'a.pdf', { type: 'application/pdf' }),
|
||||
new File(['b'], 'b.pdf', { type: 'application/pdf' }),
|
||||
new File(['c'], 'c.pdf', { type: 'application/pdf' })
|
||||
];
|
||||
|
||||
const input = page.getByRole('button', { name: /Dateien auswählen/i });
|
||||
await userEvent.upload(input, files);
|
||||
|
||||
expect(onFilesAdded).toHaveBeenCalledOnce();
|
||||
const received: File[] = onFilesAdded.mock.calls[0][0];
|
||||
expect(received).toHaveLength(3);
|
||||
expect(received.map((f) => f.name)).toEqual(['a.pdf', 'b.pdf', 'c.pdf']);
|
||||
});
|
||||
|
||||
it('shows drop hint text', async () => {
|
||||
render(BulkDropZone, { onFilesAdded: vi.fn() });
|
||||
await expect.element(page.getByText(/hier ablegen/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
74
frontend/src/lib/components/document/BulkSelectionBar.svelte
Normal file
74
frontend/src/lib/components/document/BulkSelectionBar.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
|
||||
let { canWrite }: { canWrite: boolean } = $props();
|
||||
|
||||
const count = $derived(bulkSelectionStore.size);
|
||||
const visible = $derived(canWrite && count > 0);
|
||||
|
||||
function openBulkEdit() {
|
||||
goto('/documents/bulk-edit');
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
bulkSelectionStore.clear();
|
||||
}
|
||||
|
||||
// Escape clears the selection — keyboard escape hatch when the user has
|
||||
// drilled into a 50-row selection and wants to bail without Tab-ing through
|
||||
// the whole footer (WCAG 2.1.1). Bails when an open dialog, expanded menu,
|
||||
// or popover is in front so we don't steal Esc from NotificationBell,
|
||||
// ConfirmDialog, HelpPopover, etc.
|
||||
function onEscape(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape' || !visible) return;
|
||||
if (e.defaultPrevented) return;
|
||||
const overlay = document.querySelector(
|
||||
'dialog[open], [aria-expanded="true"], [role="menu"]:not([hidden]), [role="dialog"]:not([hidden])'
|
||||
);
|
||||
if (overlay) return;
|
||||
clearAll();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onEscape} />
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
data-testid="bulk-selection-bar"
|
||||
class="fixed right-0 bottom-0 left-0 z-30 flex items-center justify-between gap-3 border-t border-line bg-surface px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:px-6"
|
||||
>
|
||||
<div class="flex items-baseline gap-3">
|
||||
<span
|
||||
class="font-sans text-sm font-medium text-ink"
|
||||
data-testid="bulk-selection-count"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{count === 1 ? m.bulk_edit_n_selected_one() : m.bulk_edit_n_selected_other({ count })}
|
||||
</span>
|
||||
<span class="hidden font-sans text-xs text-ink-3 sm:inline">
|
||||
{m.bulk_edit_clear_hint_keyboard()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearAll}
|
||||
class="inline-flex min-h-[44px] items-center px-4 py-2 font-sans text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
data-testid="bulk-clear-all"
|
||||
>
|
||||
{m.bulk_edit_clear_selection()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openBulkEdit}
|
||||
class="inline-flex min-h-[44px] items-center bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
|
||||
data-testid="bulk-edit-open"
|
||||
>
|
||||
{m.bulk_edit_button()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { goto } from '$app/navigation';
|
||||
import BulkSelectionBar from './BulkSelectionBar.svelte';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.mocked(goto).mockClear();
|
||||
bulkSelectionStore.clear();
|
||||
});
|
||||
|
||||
describe('BulkSelectionBar', () => {
|
||||
it('does not render when canWrite is false', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
render(BulkSelectionBar, { canWrite: false });
|
||||
await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when selection is empty', async () => {
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with the current selection count', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await expect.element(page.getByTestId('bulk-selection-count')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('uses the singular plural form for count=1 (not "1 Dokumente")', async () => {
|
||||
bulkSelectionStore.add('only');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await expect
|
||||
.element(page.getByTestId('bulk-selection-count'))
|
||||
.toHaveTextContent('1 Dokument ausgewählt');
|
||||
});
|
||||
|
||||
it('uses the plural form for count=2', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await expect
|
||||
.element(page.getByTestId('bulk-selection-count'))
|
||||
.toHaveTextContent('2 Dokumente ausgewählt');
|
||||
});
|
||||
|
||||
it('clear button empties the store', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await page.getByTestId('bulk-clear-all').click();
|
||||
expect(bulkSelectionStore.size).toBe(0);
|
||||
});
|
||||
|
||||
it('Massenbearbeitung navigates to /documents/bulk-edit', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await page.getByTestId('bulk-edit-open').click();
|
||||
expect(vi.mocked(goto)).toHaveBeenCalledWith('/documents/bulk-edit');
|
||||
});
|
||||
|
||||
it('selection count region announces via aria-live=polite', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await expect
|
||||
.element(page.getByTestId('bulk-selection-count'))
|
||||
.toHaveAttribute('aria-live', 'polite');
|
||||
});
|
||||
|
||||
it('Escape clears the selection while the bar is visible', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
await expect.poll(() => bulkSelectionStore.size).toBe(0);
|
||||
});
|
||||
|
||||
it('Escape is a no-op when the bar is hidden (no selection)', async () => {
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
// Nothing to clear, no error.
|
||||
expect(bulkSelectionStore.size).toBe(0);
|
||||
});
|
||||
|
||||
it('Escape does not clear when an open <dialog> is present (Leonie B6 scope guard)', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
|
||||
// Simulate a ConfirmDialog being open in front of the bar.
|
||||
const overlay = document.createElement('dialog');
|
||||
overlay.setAttribute('open', '');
|
||||
document.body.appendChild(overlay);
|
||||
try {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
// Escape is captured by the dialog, not the bar — selection survives.
|
||||
expect(bulkSelectionStore.size).toBe(2);
|
||||
} finally {
|
||||
overlay.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('Escape does not clear when an aria-expanded popover is present', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
|
||||
const trigger = document.createElement('button');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
document.body.appendChild(trigger);
|
||||
try {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
expect(bulkSelectionStore.size).toBe(1);
|
||||
} finally {
|
||||
trigger.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import TagInput, { type Tag } from '$lib/components/TagInput.svelte';
|
||||
import FieldLabelBadge from './FieldLabelBadge.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
tags = $bindable<Tag[]>([]),
|
||||
currentTitle = $bindable(''),
|
||||
documentLocation = $bindable(''),
|
||||
archiveBox = $bindable(''),
|
||||
archiveFolder = $bindable(''),
|
||||
initialTitle = '',
|
||||
initialDocumentLocation = '',
|
||||
initialArchiveBox = '',
|
||||
initialArchiveFolder = '',
|
||||
initialSummary = '',
|
||||
titleRequired = false,
|
||||
suggestedTitle = '',
|
||||
hideTitle = false
|
||||
hideTitle = false,
|
||||
editMode = false
|
||||
}: {
|
||||
tags?: Tag[];
|
||||
currentTitle?: string;
|
||||
documentLocation?: string;
|
||||
archiveBox?: string;
|
||||
archiveFolder?: string;
|
||||
initialTitle?: string;
|
||||
initialDocumentLocation?: string;
|
||||
initialArchiveBox?: string;
|
||||
initialArchiveFolder?: string;
|
||||
initialSummary?: string;
|
||||
titleRequired?: boolean;
|
||||
suggestedTitle?: string;
|
||||
hideTitle?: boolean;
|
||||
editMode?: boolean;
|
||||
} = $props();
|
||||
|
||||
// Seed bindables from initial-* props once at mount and only when the parent
|
||||
// hasn't already supplied a non-empty value through the binding. onMount runs
|
||||
// exactly once per instance, so this never stomps a parent-driven update on a
|
||||
// later prop change. Required by the single-doc edit flow which seeds from
|
||||
// the document; bulk-edit consumers leave the initial-* unset and bind their
|
||||
// own state.
|
||||
let titleDirty = $state(false);
|
||||
currentTitle = untrack(() => initialTitle);
|
||||
onMount(() => {
|
||||
if (!currentTitle && initialTitle) currentTitle = initialTitle;
|
||||
if (!archiveBox && initialArchiveBox) archiveBox = initialArchiveBox;
|
||||
if (!archiveFolder && initialArchiveFolder) archiveFolder = initialArchiveFolder;
|
||||
});
|
||||
const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
|
||||
</script>
|
||||
|
||||
@@ -67,40 +88,61 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
|
||||
|
||||
<!-- Schlagworte (optional) -->
|
||||
<div>
|
||||
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_tags()}</p>
|
||||
<p class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_tags()}
|
||||
{#if editMode}<FieldLabelBadge variant="additive" />{/if}
|
||||
</p>
|
||||
<TagInput bind:tags={tags} />
|
||||
<input type="hidden" name="tags" value={tags.map((t) => t.name).join(',')} />
|
||||
</div>
|
||||
|
||||
<!-- Inhalt (optional) -->
|
||||
<div>
|
||||
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_content()}</label
|
||||
>
|
||||
<textarea
|
||||
id="summary"
|
||||
name="summary"
|
||||
rows="5"
|
||||
placeholder={m.form_placeholder_content()}
|
||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>{initialSummary}</textarea
|
||||
>
|
||||
</div>
|
||||
{#if !editMode}
|
||||
<!-- Inhalt (optional) — not bulk-editable. -->
|
||||
<div>
|
||||
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_content()}</label
|
||||
>
|
||||
<textarea
|
||||
id="summary"
|
||||
name="summary"
|
||||
rows="5"
|
||||
placeholder={m.form_placeholder_content()}
|
||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>{initialSummary}</textarea
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Aufbewahrungsort (optional) -->
|
||||
<div>
|
||||
<label for="documentLocation" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_archive_location()}</label
|
||||
>
|
||||
<!-- Karton -->
|
||||
<div data-testid="description-archive-box">
|
||||
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_archive_box()}
|
||||
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
|
||||
</label>
|
||||
<input
|
||||
id="documentLocation"
|
||||
id="archiveBox"
|
||||
type="text"
|
||||
name="documentLocation"
|
||||
value={initialDocumentLocation}
|
||||
placeholder={m.form_placeholder_archive_location()}
|
||||
name="archiveBox"
|
||||
bind:value={archiveBox}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
|
||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_box()}</p>
|
||||
</div>
|
||||
|
||||
<!-- Mappe -->
|
||||
<div data-testid="description-archive-folder">
|
||||
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_archive_folder()}
|
||||
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
|
||||
</label>
|
||||
<input
|
||||
id="archiveFolder"
|
||||
type="text"
|
||||
name="archiveFolder"
|
||||
bind:value={archiveFolder}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_folder()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import DescriptionSection from './DescriptionSection.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('DescriptionSection — onMount seeding (Felix B1/B2 fix regression fence)', () => {
|
||||
it('pre-fills the title input from initialTitle when currentTitle is empty', async () => {
|
||||
render(DescriptionSection, { initialTitle: 'Brief an Anna' });
|
||||
const titleInput = document.querySelector('input#title') as HTMLInputElement;
|
||||
expect(titleInput).not.toBeNull();
|
||||
expect(titleInput.value).toBe('Brief an Anna');
|
||||
});
|
||||
|
||||
it('does not stomp a parent-bound currentTitle that is already non-empty', async () => {
|
||||
render(DescriptionSection, {
|
||||
currentTitle: 'Parent Title',
|
||||
initialTitle: 'Should Not Win'
|
||||
});
|
||||
const titleInput = document.querySelector('input#title') as HTMLInputElement;
|
||||
expect(titleInput.value).toBe('Parent Title');
|
||||
});
|
||||
|
||||
it('always renders archiveBox + archiveFolder fields regardless of editMode', async () => {
|
||||
render(DescriptionSection, { editMode: false });
|
||||
expect(document.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the editMode-only archiveBox + archiveFolder fields when editMode=true', async () => {
|
||||
render(DescriptionSection, { editMode: true, hideTitle: true });
|
||||
expect(document.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('pre-fills archiveBox from initialArchiveBox when archiveBox is empty', async () => {
|
||||
render(DescriptionSection, { initialArchiveBox: 'K-03', hideTitle: true });
|
||||
const input = document.querySelector('input#archiveBox') as HTMLInputElement;
|
||||
expect(input.value).toBe('K-03');
|
||||
});
|
||||
|
||||
it('pre-fills archiveFolder from initialArchiveFolder when archiveFolder is empty', async () => {
|
||||
render(DescriptionSection, { initialArchiveFolder: 'Mappe B', hideTitle: true });
|
||||
const input = document.querySelector('input#archiveFolder') as HTMLInputElement;
|
||||
expect(input.value).toBe('Mappe B');
|
||||
});
|
||||
|
||||
it('does not stomp a parent-bound archiveBox that is already non-empty', async () => {
|
||||
render(DescriptionSection, {
|
||||
archiveBox: 'Parent Value',
|
||||
initialArchiveBox: 'Should Not Win',
|
||||
hideTitle: true
|
||||
});
|
||||
const input = document.querySelector('input#archiveBox') as HTMLInputElement;
|
||||
expect(input.value).toBe('Parent Value');
|
||||
});
|
||||
});
|
||||
@@ -207,7 +207,8 @@ async function handleReplaceFile(e: Event) {
|
||||
bind:tags={tags}
|
||||
bind:currentTitle={currentTitle}
|
||||
initialTitle={doc.title ?? ''}
|
||||
initialDocumentLocation={doc.documentLocation ?? ''}
|
||||
initialArchiveBox={doc.archiveBox ?? ''}
|
||||
initialArchiveFolder={doc.archiveFolder ?? ''}
|
||||
initialSummary={doc.summary ?? ''}
|
||||
titleRequired={true}
|
||||
/>
|
||||
|
||||
16
frontend/src/lib/components/document/FieldLabelBadge.svelte
Normal file
16
frontend/src/lib/components/document/FieldLabelBadge.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { variant }: { variant: 'additive' | 'replace' } = $props();
|
||||
|
||||
const text = $derived(
|
||||
variant === 'additive' ? m.bulk_edit_badge_additive() : m.bulk_edit_badge_replace()
|
||||
);
|
||||
</script>
|
||||
|
||||
<span
|
||||
data-testid="field-label-badge-{variant}"
|
||||
class="ml-2 inline-flex items-center rounded-sm bg-muted px-1.5 py-0.5 text-[11px] font-medium tracking-wide text-ink-2"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import FieldLabelBadge from './FieldLabelBadge.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('FieldLabelBadge', () => {
|
||||
it('renders the additive variant text', async () => {
|
||||
render(FieldLabelBadge, { variant: 'additive' });
|
||||
await expect.element(page.getByTestId('field-label-badge-additive')).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByTestId('field-label-badge-additive'))
|
||||
.toHaveTextContent('+ wird hinzugefügt');
|
||||
});
|
||||
|
||||
it('renders the replace variant text', async () => {
|
||||
render(FieldLabelBadge, { variant: 'replace' });
|
||||
await expect
|
||||
.element(page.getByTestId('field-label-badge-replace'))
|
||||
.toHaveTextContent('wird ersetzt');
|
||||
});
|
||||
|
||||
it('uses the design-system text-ink-2 token (not raw Tailwind palette)', async () => {
|
||||
render(FieldLabelBadge, { variant: 'replace' });
|
||||
await expect.element(page.getByTestId('field-label-badge-replace')).toHaveClass(/text-ink-2/);
|
||||
});
|
||||
});
|
||||
154
frontend/src/lib/components/document/FileSwitcherStrip.svelte
Normal file
154
frontend/src/lib/components/document/FileSwitcherStrip.svelte
Normal file
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
export interface FileEntry {
|
||||
id: string;
|
||||
/** Present in upload mode only. Edit mode entries reference an existing
|
||||
* document by `documentId` and have no local file blob. */
|
||||
file?: File;
|
||||
/** Present in edit mode only — the server-side document UUID being edited. */
|
||||
documentId?: string;
|
||||
title: string;
|
||||
status: 'idle' | 'error';
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
let {
|
||||
files,
|
||||
activeId,
|
||||
onSelect,
|
||||
onRemove
|
||||
}: {
|
||||
files: FileEntry[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
let trackEl = $state<HTMLDivElement | null>(null);
|
||||
let listEl = $state<HTMLUListElement | null>(null);
|
||||
|
||||
const activeAnnouncement = $derived(files.find((f) => f.id === activeId)?.title ?? '');
|
||||
|
||||
function scrollPrev() {
|
||||
trackEl?.scrollBy({ left: -120, behavior: 'smooth' });
|
||||
}
|
||||
function scrollNext() {
|
||||
trackEl?.scrollBy({ left: 120, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
async function handleRemove(entry: FileEntry, index: number) {
|
||||
const targetId = index > 0 ? files[index - 1].id : (files[index + 1]?.id ?? null);
|
||||
onRemove(entry.id);
|
||||
if (targetId) {
|
||||
await tick();
|
||||
(listEl?.querySelector<HTMLElement>(`[data-chip-id="${targetId}"]`) ?? null)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!listEl) return;
|
||||
const node = listEl;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const buttons = Array.from(node.querySelectorAll<HTMLElement>('[data-chip-id]'));
|
||||
if (buttons.length === 0) return;
|
||||
|
||||
const focusedIndex = buttons.indexOf(document.activeElement as HTMLElement);
|
||||
if (focusedIndex === -1) return;
|
||||
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
const nextIndex = (focusedIndex + 1) % buttons.length;
|
||||
buttons[nextIndex].focus();
|
||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const prevIndex = (focusedIndex - 1 + buttons.length) % buttons.length;
|
||||
buttons[prevIndex].focus();
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener('keydown', handleKeyDown);
|
||||
return () => node.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">{activeAnnouncement}</div>
|
||||
<div
|
||||
data-testid="file-switcher-strip"
|
||||
class="flex h-11 shrink-0 items-center gap-1 border-t border-line bg-pdf-ctrl px-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.bulk_switcher_prev()}
|
||||
onclick={scrollPrev}
|
||||
class="flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-sm text-sm text-ink-3 hover:bg-black/10 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>‹</button
|
||||
>
|
||||
|
||||
<!-- Gradient fade overlays signal hidden overflow to pointer-only users -->
|
||||
<div class="relative flex flex-1 overflow-hidden">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r from-pdf-ctrl to-transparent"
|
||||
></div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l from-pdf-ctrl to-transparent"
|
||||
></div>
|
||||
<div bind:this={trackEl} class="flex flex-1 gap-1 overflow-x-auto" style="scrollbar-width:none">
|
||||
<ul bind:this={listEl} role="list" class="flex flex-row gap-1 py-1">
|
||||
{#each files as entry, i (entry.id)}
|
||||
<li role="listitem" class="inline-flex shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
aria-current={entry.id === activeId ? 'true' : undefined}
|
||||
data-status={entry.status}
|
||||
data-chip-id={entry.id}
|
||||
onclick={() => onSelect(entry.id)}
|
||||
class={[
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-[2px] px-1.5 py-0.5 text-xs font-bold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent',
|
||||
entry.id === activeId
|
||||
? 'bg-accent text-primary'
|
||||
: 'bg-black/[0.06] text-ink-2 hover:bg-black/10',
|
||||
entry.status === 'error'
|
||||
? '!border !border-dashed !border-red-400 !bg-red-50/80 !text-red-700'
|
||||
: ''
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
class={[
|
||||
'rounded-[2px] px-0.5 text-[11px] font-extrabold opacity-85',
|
||||
entry.id === activeId ? 'bg-black/20' : 'bg-black/10'
|
||||
].join(' ')}
|
||||
>{i + 1}</span
|
||||
>
|
||||
<span class="max-w-[8rem] truncate" title={entry.title}>{entry.title}</span>
|
||||
{#if entry.status === 'error'}
|
||||
<span class="sr-only">{m.bulk_file_error_chip_label()}</span>
|
||||
<span aria-hidden="true" class="ml-0.5 font-extrabold text-red-600">!</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.bulk_remove_file()}
|
||||
data-remove-id={entry.id}
|
||||
onclick={() => handleRemove(entry, i)}
|
||||
class="ml-0.5 flex h-[44px] w-[44px] items-center justify-center text-base text-ink-3 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.bulk_switcher_next()}
|
||||
onclick={scrollNext}
|
||||
class="flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-sm text-sm text-ink-3 hover:bg-black/10 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>›</button
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
||||
import type { FileEntry } from './FileSwitcherStrip.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function makeFiles(n: number): FileEntry[] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: `id-${i}`,
|
||||
file: new File([''], `file${i}.pdf`),
|
||||
title: `File ${i}`,
|
||||
status: 'idle' as const,
|
||||
previewUrl: ''
|
||||
}));
|
||||
}
|
||||
|
||||
describe('FileSwitcherStrip', () => {
|
||||
it('renders N chips for N files', async () => {
|
||||
const files = makeFiles(4);
|
||||
render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: files[0].id,
|
||||
onSelect: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const chips = page.getByRole('listitem');
|
||||
await expect.element(chips.nth(0)).toBeInTheDocument();
|
||||
await expect.element(chips.nth(3)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('active chip has aria-current="true"', async () => {
|
||||
const files = makeFiles(3);
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: files[1].id,
|
||||
onSelect: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const activeBtn = container.querySelector('[aria-current="true"]');
|
||||
expect(activeBtn).not.toBeNull();
|
||||
expect(activeBtn?.textContent).toContain('File 1');
|
||||
});
|
||||
|
||||
it('clicking a chip fires onSelect with its id', async () => {
|
||||
const files = makeFiles(3);
|
||||
const onSelect = vi.fn();
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: files[0].id,
|
||||
onSelect,
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const chip = container.querySelector('[data-chip-id="id-2"]') as HTMLElement;
|
||||
expect(chip).not.toBeNull();
|
||||
chip.click();
|
||||
expect(onSelect).toHaveBeenCalledWith('id-2');
|
||||
});
|
||||
|
||||
it('error chip has aria-label containing warning indicator', async () => {
|
||||
const files: FileEntry[] = [
|
||||
{
|
||||
id: 'e1',
|
||||
file: new File([''], 'bad.pdf'),
|
||||
title: 'Bad file',
|
||||
status: 'error',
|
||||
previewUrl: ''
|
||||
}
|
||||
];
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: 'e1',
|
||||
onSelect: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const errBtn = container.querySelector('[data-status="error"]');
|
||||
expect(errBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
it('error chip contains a screen-reader-only error label', async () => {
|
||||
const files: FileEntry[] = [
|
||||
{
|
||||
id: 'e1',
|
||||
file: new File([''], 'bad.pdf'),
|
||||
title: 'Bad file',
|
||||
status: 'error',
|
||||
previewUrl: ''
|
||||
}
|
||||
];
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: 'e1',
|
||||
onSelect: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const errBtn = container.querySelector('[data-status="error"]');
|
||||
const srOnly = errBtn?.querySelector('.sr-only');
|
||||
expect(srOnly).not.toBeNull();
|
||||
});
|
||||
|
||||
it('focus moves to the previous chip after the middle chip is removed', async () => {
|
||||
const files = makeFiles(3); // id-0, id-1, id-2
|
||||
const onRemove = vi.fn();
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: files[1].id,
|
||||
onSelect: vi.fn(),
|
||||
onRemove
|
||||
});
|
||||
|
||||
const removeBtn = container.querySelector('[data-remove-id="id-1"]') as HTMLButtonElement;
|
||||
expect(removeBtn).not.toBeNull();
|
||||
removeBtn.click();
|
||||
expect(onRemove).toHaveBeenCalledWith('id-1');
|
||||
|
||||
// After removal, focus should be on the chip for id-0 (the previous chip)
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const prevChip = container.querySelector('[data-chip-id="id-0"]') as HTMLElement | null;
|
||||
expect(prevChip).not.toBeNull();
|
||||
expect(document.activeElement).toBe(prevChip);
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('ArrowRight moves focus to next chip without leaving strip', async () => {
|
||||
const files = makeFiles(3);
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: files[0].id,
|
||||
onSelect: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const firstBtn = container.querySelectorAll('[data-chip-id]')[0] as HTMLElement;
|
||||
firstBtn.focus();
|
||||
await userEvent.keyboard('{ArrowRight}');
|
||||
const focused = document.activeElement;
|
||||
expect(focused).not.toBe(firstBtn);
|
||||
// The new focused element should still be inside the strip
|
||||
const strip = container.querySelector('[data-testid="file-switcher-strip"]');
|
||||
expect(strip?.contains(focused)).toBe(true);
|
||||
});
|
||||
});
|
||||
40
frontend/src/lib/components/document/ScopeCard.svelte
Normal file
40
frontend/src/lib/components/document/ScopeCard.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
variant,
|
||||
count = 0,
|
||||
children
|
||||
}: {
|
||||
variant: 'per-file' | 'shared';
|
||||
count?: number;
|
||||
children?: import('svelte').Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="scope-card"
|
||||
data-variant={variant}
|
||||
class="mb-3 rounded-sm border p-4
|
||||
{variant === 'per-file'
|
||||
? 'border-accent bg-accent-bg'
|
||||
: 'border-line bg-surface'}"
|
||||
>
|
||||
{#if variant === 'shared'}
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.bulk_scope_shared_label({ count })}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1.5 text-xs font-bold text-primary"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-3 text-xs font-bold tracking-widest text-primary uppercase">
|
||||
{m.bulk_scope_per_file_label()}
|
||||
</p>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import ScopeCard from './ScopeCard.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('ScopeCard', () => {
|
||||
it('per-file variant has accent background class', async () => {
|
||||
const { container } = render(ScopeCard, { variant: 'per-file', count: 1 });
|
||||
const card = container.querySelector('[data-testid="scope-card"]');
|
||||
expect(card?.className).toMatch(/bg-accent-bg/);
|
||||
});
|
||||
|
||||
it('shared variant does not have accent background', async () => {
|
||||
const { container } = render(ScopeCard, { variant: 'shared', count: 3 });
|
||||
const card = container.querySelector('[data-testid="scope-card"]');
|
||||
expect(card?.className).not.toMatch(/bg-accent-bg/);
|
||||
});
|
||||
|
||||
it('shared variant renders count badge with file count', async () => {
|
||||
render(ScopeCard, { variant: 'shared', count: 5 });
|
||||
await expect.element(page.getByText('5', { exact: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('per-file variant renders slot content', async () => {
|
||||
// ScopeCard is a container — verify it renders children
|
||||
render(ScopeCard, { variant: 'per-file', count: 1 });
|
||||
const card = await page.getByTestId('scope-card');
|
||||
await expect.element(card).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
69
frontend/src/lib/components/document/UploadSaveBar.svelte
Normal file
69
frontend/src/lib/components/document/UploadSaveBar.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
fileCount,
|
||||
chunkProgress,
|
||||
onSave,
|
||||
onDiscard,
|
||||
disabled = false,
|
||||
editMode = false
|
||||
}: {
|
||||
fileCount: number;
|
||||
chunkProgress?: { done: number; total: number };
|
||||
onSave: () => void;
|
||||
onDiscard: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
editMode?: boolean;
|
||||
} = $props();
|
||||
|
||||
const saveCta = $derived.by(() => {
|
||||
if (editMode) return m.bulk_edit_save_button();
|
||||
return fileCount === 1 ? m.bulk_save_cta_one() : m.bulk_save_cta({ count: fileCount });
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="shrink-0 border-t border-line bg-surface px-4 py-3">
|
||||
{#if chunkProgress}
|
||||
<progress
|
||||
value={chunkProgress.done}
|
||||
max={chunkProgress.total}
|
||||
aria-valuenow={chunkProgress.done}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={chunkProgress.total}
|
||||
aria-label={editMode
|
||||
? m.bulk_edit_save_progress({ done: chunkProgress.done, total: chunkProgress.total })
|
||||
: m.bulk_upload_progress({ done: chunkProgress.done, total: chunkProgress.total })}
|
||||
class="[&::-webkit-progress-bar]:bg-brand-sand mb-2 h-1 w-full rounded-full [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-accent"
|
||||
></progress>
|
||||
{#if editMode && chunkProgress.total > 1}
|
||||
<!-- Visible progress text for sighted users on multi-chunk PATCH
|
||||
(Elicit S3 — the unitless bar isn't enough for a 30-second op). -->
|
||||
<p
|
||||
class="mb-2 font-sans text-xs text-ink-2"
|
||||
aria-live="polite"
|
||||
data-testid="bulk-edit-chunk-progress-text"
|
||||
>
|
||||
{m.bulk_edit_save_progress({ done: chunkProgress.done, total: chunkProgress.total })}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onDiscard}
|
||||
class="flex min-h-[44px] items-center px-2 text-sm text-red-600/70 hover:text-red-700"
|
||||
>
|
||||
{m.bulk_discard_all()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bulk-save-btn"
|
||||
disabled={fileCount === 0 || disabled}
|
||||
onclick={onSave}
|
||||
class="min-h-[44px] rounded-sm bg-primary px-6 text-sm font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{saveCta}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import UploadSaveBar from './UploadSaveBar.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('UploadSaveBar', () => {
|
||||
it('shows plural label for multiple files', async () => {
|
||||
render(UploadSaveBar, { fileCount: 5, onSave: vi.fn(), onDiscard: vi.fn() });
|
||||
// "5 speichern →" or similar plural form
|
||||
await expect.element(page.getByText(/5/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows singular label for one file', async () => {
|
||||
render(UploadSaveBar, { fileCount: 1, onSave: vi.fn(), onDiscard: vi.fn() });
|
||||
// "Speichern →" singular form
|
||||
await expect.element(page.getByText(/Speichern/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('progress bar is visible when chunkProgress is provided', async () => {
|
||||
const { container } = render(UploadSaveBar, {
|
||||
fileCount: 3,
|
||||
chunkProgress: { done: 1, total: 3 },
|
||||
onSave: vi.fn(),
|
||||
onDiscard: vi.fn()
|
||||
});
|
||||
const progress = container.querySelector('progress');
|
||||
expect(progress).not.toBeNull();
|
||||
expect(progress?.getAttribute('value')).toBe('1');
|
||||
expect(progress?.getAttribute('max')).toBe('3');
|
||||
});
|
||||
|
||||
it('progress bar is not rendered when no chunkProgress', async () => {
|
||||
const { container } = render(UploadSaveBar, {
|
||||
fileCount: 2,
|
||||
onSave: vi.fn(),
|
||||
onDiscard: vi.fn()
|
||||
});
|
||||
const progress = container.querySelector('progress');
|
||||
expect(progress).toBeNull();
|
||||
});
|
||||
|
||||
it('discard link is rendered', async () => {
|
||||
render(UploadSaveBar, { fileCount: 2, onSave: vi.fn(), onDiscard: vi.fn() });
|
||||
await expect.element(page.getByText(/verwerfen/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
|
||||
import FieldLabelBadge from './FieldLabelBadge.svelte';
|
||||
import { isoToGerman, handleGermanDateInput } from '$lib/utils/date';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
@@ -16,7 +17,9 @@ let {
|
||||
initialLocation = '',
|
||||
initialSenderName = '',
|
||||
suggestedDateIso = '',
|
||||
suggestedSenderName = ''
|
||||
suggestedSenderName = '',
|
||||
hideDate = false,
|
||||
editMode = false
|
||||
}: {
|
||||
senderId?: string;
|
||||
selectedReceivers?: Person[];
|
||||
@@ -26,12 +29,24 @@ let {
|
||||
initialSenderName?: string;
|
||||
suggestedDateIso?: string;
|
||||
suggestedSenderName?: string;
|
||||
hideDate?: boolean;
|
||||
editMode?: boolean;
|
||||
} = $props();
|
||||
|
||||
let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso)));
|
||||
dateIso = untrack(() => initialDateIso);
|
||||
// dateDisplay seeds from the bindable's value or initialDateIso once at mount
|
||||
// and is then user-driven. onMount runs exactly once, so this never stomps
|
||||
// the parent's dateIso on a later prop change.
|
||||
let dateDisplay = $state('');
|
||||
let dateDirty = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
const seed = dateIso || initialDateIso;
|
||||
if (seed) {
|
||||
dateDisplay = isoToGerman(seed);
|
||||
if (!dateIso) dateIso = seed;
|
||||
}
|
||||
});
|
||||
|
||||
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
||||
|
||||
function handleDateInput(e: Event) {
|
||||
@@ -56,62 +71,72 @@ $effect(() => {
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<!-- Datum (required — row 1, col 1) -->
|
||||
<div>
|
||||
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_date()}*</label
|
||||
>
|
||||
<input
|
||||
id="documentDate"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={dateDisplay}
|
||||
oninput={handleDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
autofocus={!initialDateIso}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm
|
||||
{dateInvalid ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||
/>
|
||||
<input type="hidden" name="documentDate" value={dateIso} />
|
||||
{#if dateInvalid}
|
||||
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !hideDate}
|
||||
<!-- Datum (required — row 1, col 1) -->
|
||||
<div data-testid="who-when-date">
|
||||
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_date()}*</label
|
||||
>
|
||||
<input
|
||||
id="documentDate"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={dateDisplay}
|
||||
oninput={handleDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||
{dateInvalid
|
||||
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
|
||||
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||
/>
|
||||
<input type="hidden" name="documentDate" value={dateIso} />
|
||||
{#if dateInvalid}
|
||||
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Absender (required — row 1, col 2) -->
|
||||
<!-- Absender (required in upload mode — row 1, col 2) -->
|
||||
<div>
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
label={m.form_label_sender()}
|
||||
required={true}
|
||||
required={!editMode}
|
||||
bind:value={senderId}
|
||||
initialName={initialSenderName}
|
||||
suggestedName={suggestedSenderName}
|
||||
autofocus={!!initialDateIso}
|
||||
badge={editMode ? 'replace' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empfänger (optional — row 2, col 1) -->
|
||||
<div>
|
||||
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_receivers()}</p>
|
||||
<p class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_receivers()}
|
||||
{#if editMode}<FieldLabelBadge variant="additive" />{/if}
|
||||
</p>
|
||||
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
|
||||
</div>
|
||||
|
||||
<!-- Ort (optional — row 2, col 2) -->
|
||||
<div>
|
||||
<label for="location" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_location()}</label
|
||||
>
|
||||
<input
|
||||
id="location"
|
||||
type="text"
|
||||
name="location"
|
||||
value={initialLocation}
|
||||
placeholder={m.form_placeholder_location()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
{#if !editMode}
|
||||
<!-- Ort (optional — row 2, col 2). Hidden in editMode: meta_location is
|
||||
NOT bulk-editable per the issue spec; the three editable location
|
||||
fields live in DescriptionSection. -->
|
||||
<div>
|
||||
<label for="location" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_location()}</label
|
||||
>
|
||||
<input
|
||||
id="location"
|
||||
type="text"
|
||||
name="location"
|
||||
value={initialLocation}
|
||||
placeholder={m.form_placeholder_location()}
|
||||
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import WhoWhenSection from './WhoWhenSection.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('WhoWhenSection — onMount seeding (Felix B1 fix regression fence)', () => {
|
||||
it('pre-fills the date input from initialDateIso when the bindable is empty', async () => {
|
||||
render(WhoWhenSection, { initialDateIso: '2024-03-15' });
|
||||
// isoToGerman('2024-03-15') → '15.03.2024'
|
||||
const dateInput = document.querySelector('input#documentDate') as HTMLInputElement;
|
||||
expect(dateInput).not.toBeNull();
|
||||
expect(dateInput.value).toBe('15.03.2024');
|
||||
});
|
||||
|
||||
it('does not stomp a parent-bound dateIso that is already non-empty', async () => {
|
||||
// dateIso bindable is '2026-01-01' from the parent; initialDateIso is the
|
||||
// "fallback seed". onMount must not overwrite the already-bound value.
|
||||
render(WhoWhenSection, { dateIso: '2026-01-01', initialDateIso: '1900-01-01' });
|
||||
const dateInput = document.querySelector('input#documentDate') as HTMLInputElement;
|
||||
expect(dateInput.value).toBe('01.01.2026');
|
||||
});
|
||||
|
||||
it('hides the date field when hideDate=true (bulk-edit mode)', async () => {
|
||||
render(WhoWhenSection, { hideDate: true });
|
||||
await expect.element(page.getByTestId('who-when-date')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the meta_location input only outside editMode', async () => {
|
||||
const { rerender } = render(WhoWhenSection, { editMode: true });
|
||||
expect(document.querySelector('input#location')).toBeNull();
|
||||
await rerender({ editMode: false });
|
||||
expect(document.querySelector('input#location')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('pre-fills the location input from initialLocation', async () => {
|
||||
render(WhoWhenSection, { editMode: false, initialLocation: 'Berlin' });
|
||||
const locationInput = document.querySelector('input#location') as HTMLInputElement;
|
||||
expect(locationInput.value).toBe('Berlin');
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import * as m from '$lib/paraglide/messages.js';
|
||||
export type ErrorCode =
|
||||
| 'PERSON_NOT_FOUND'
|
||||
| 'ALIAS_NOT_FOUND'
|
||||
| 'INVALID_PERSON_TYPE'
|
||||
| 'DOCUMENT_NOT_FOUND'
|
||||
| 'DOCUMENT_NO_FILE'
|
||||
| 'FILE_NOT_FOUND'
|
||||
@@ -41,6 +42,8 @@ export type ErrorCode =
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'VALIDATION_ERROR'
|
||||
| 'BATCH_TOO_LARGE'
|
||||
| 'BULK_EDIT_TOO_MANY_IDS'
|
||||
| 'INTERNAL_ERROR';
|
||||
|
||||
export interface BackendError {
|
||||
@@ -71,6 +74,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_person_not_found();
|
||||
case 'ALIAS_NOT_FOUND':
|
||||
return m.error_alias_not_found();
|
||||
case 'INVALID_PERSON_TYPE':
|
||||
return m.error_invalid_person_type();
|
||||
case 'DOCUMENT_NOT_FOUND':
|
||||
return m.error_document_not_found();
|
||||
case 'DOCUMENT_NO_FILE':
|
||||
@@ -139,6 +144,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_forbidden();
|
||||
case 'VALIDATION_ERROR':
|
||||
return m.error_validation_error();
|
||||
case 'BATCH_TOO_LARGE':
|
||||
return m.error_batch_too_large();
|
||||
case 'BULK_EDIT_TOO_MANY_IDS':
|
||||
return m.error_bulk_edit_too_many_ids();
|
||||
default:
|
||||
return m.error_internal_error();
|
||||
}
|
||||
|
||||
@@ -484,6 +484,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/batch-metadata": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["batchMetadata"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/reset-password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -548,6 +564,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/generate-thumbnails": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["generateThumbnails"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/backfill-versions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -660,6 +692,22 @@ export interface paths {
|
||||
patch: operations["updateAnnotation"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/bulk": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch: operations["patchBulk"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/search": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1028,6 +1076,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{id}/thumbnail": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getDocumentThumbnail"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/transcription-blocks/{blockId}/history": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1124,6 +1188,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/ids": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getDocumentIds"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/conversation": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1204,6 +1284,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/thumbnail-status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["thumbnailStatus"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/import-status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1390,7 +1486,6 @@ export interface components {
|
||||
thumbnailAspect?: "PORTRAIT" | "LANDSCAPE";
|
||||
/** Format: int32 */
|
||||
pageCount?: number;
|
||||
thumbnailUrl?: string;
|
||||
originalFilename: string;
|
||||
/** @enum {string} */
|
||||
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||
@@ -1413,6 +1508,7 @@ export interface components {
|
||||
sender?: components["schemas"]["Person"];
|
||||
tags?: components["schemas"]["Tag"][];
|
||||
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
||||
thumbnailUrl?: string;
|
||||
};
|
||||
UpdateTranscriptionBlockDTO: {
|
||||
text?: string;
|
||||
@@ -1639,6 +1735,17 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
};
|
||||
DocumentBatchMetadataDTO: {
|
||||
titles?: string[];
|
||||
/** Format: uuid */
|
||||
senderId?: string;
|
||||
receiverIds?: string[];
|
||||
/** Format: date */
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
tagNames?: string[];
|
||||
metadataComplete?: boolean;
|
||||
};
|
||||
QuickUploadResult: {
|
||||
created?: components["schemas"]["Document"][];
|
||||
updated?: components["schemas"]["Document"][];
|
||||
@@ -1648,6 +1755,15 @@ export interface components {
|
||||
filename?: string;
|
||||
code?: string;
|
||||
};
|
||||
BatchMetadataRequest: {
|
||||
ids: string[];
|
||||
};
|
||||
DocumentBatchSummary: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
pdfUrl: string;
|
||||
};
|
||||
ResetPasswordRequest: {
|
||||
token?: string;
|
||||
newPassword?: string;
|
||||
@@ -1673,6 +1789,21 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
startedAt?: string;
|
||||
};
|
||||
BackfillStatus: {
|
||||
/** @enum {string} */
|
||||
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
|
||||
message?: string;
|
||||
/** Format: int32 */
|
||||
total?: number;
|
||||
/** Format: int32 */
|
||||
processed?: number;
|
||||
/** Format: int32 */
|
||||
skipped?: number;
|
||||
/** Format: int32 */
|
||||
failed?: number;
|
||||
/** Format: date-time */
|
||||
startedAt?: string;
|
||||
};
|
||||
BackfillResult: {
|
||||
/** Format: int32 */
|
||||
count: number;
|
||||
@@ -1708,6 +1839,26 @@ export interface components {
|
||||
/** Format: double */
|
||||
height?: number;
|
||||
};
|
||||
DocumentBulkEditDTO: {
|
||||
documentIds?: string[];
|
||||
tagNames?: string[];
|
||||
/** Format: uuid */
|
||||
senderId?: string;
|
||||
receiverIds?: string[];
|
||||
documentLocation?: string;
|
||||
archiveBox?: string;
|
||||
archiveFolder?: string;
|
||||
};
|
||||
BulkEditError: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
message: string;
|
||||
};
|
||||
BulkEditResult: {
|
||||
/** Format: int32 */
|
||||
updated: number;
|
||||
errors: components["schemas"]["BulkEditError"][];
|
||||
};
|
||||
TranscriptionWeeklyStatsDTO: {
|
||||
/** Format: int64 */
|
||||
segmentationCount: number;
|
||||
@@ -1759,7 +1910,6 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
personType?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** Format: int64 */
|
||||
@@ -1770,6 +1920,7 @@ export interface components {
|
||||
deathYear?: number;
|
||||
alias?: string;
|
||||
notes?: string;
|
||||
personType?: string;
|
||||
};
|
||||
SenderModel: {
|
||||
/** Format: uuid */
|
||||
@@ -1837,10 +1988,10 @@ export interface components {
|
||||
timeout?: number;
|
||||
};
|
||||
PageNotificationDTO: {
|
||||
/** Format: int32 */
|
||||
totalPages?: number;
|
||||
/** Format: int64 */
|
||||
totalElements?: number;
|
||||
/** Format: int32 */
|
||||
totalPages?: number;
|
||||
pageable?: components["schemas"]["PageableObject"];
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
@@ -3152,6 +3303,7 @@ export interface operations {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
files?: string[];
|
||||
metadata?: components["schemas"]["DocumentBatchMetadataDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -3167,6 +3319,30 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
batchMetadata: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["BatchMetadataRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentBatchSummary"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
resetPassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3255,6 +3431,26 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
generateThumbnails: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["BackfillStatus"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
backfillVersions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3483,6 +3679,30 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
patchBulk: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["DocumentBulkEditDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["BulkEditResult"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
search: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -3975,6 +4195,28 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getDocumentThumbnail: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getBlockHistory: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4038,15 +4280,9 @@ export interface operations {
|
||||
dir?: string;
|
||||
/** @description Tag operator: AND (default) or OR */
|
||||
tagOp?: string;
|
||||
/**
|
||||
* @description Page number (0-indexed)
|
||||
* @default 0
|
||||
*/
|
||||
/** @description Page number (0-indexed) */
|
||||
page?: number;
|
||||
/**
|
||||
* @description Page size (max 100)
|
||||
* @default 50
|
||||
*/
|
||||
/** @description Page size (max 100) */
|
||||
size?: number;
|
||||
};
|
||||
header?: never;
|
||||
@@ -4133,6 +4369,36 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getDocumentIds: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
senderId?: string;
|
||||
receiverId?: string;
|
||||
tag?: string[];
|
||||
tagQ?: string;
|
||||
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||
tagOp?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getConversation: {
|
||||
parameters: {
|
||||
query: {
|
||||
@@ -4245,6 +4511,26 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
thumbnailStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["BackfillStatus"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
importStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
40
frontend/src/lib/person-validation.test.ts
Normal file
40
frontend/src/lib/person-validation.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validatePersonFields } from './person-validation';
|
||||
|
||||
describe('validatePersonFields', () => {
|
||||
it('returns null when all required fields are present for PERSON', () => {
|
||||
expect(validatePersonFields('PERSON', 'Hans', 'Müller')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns lastName error key when lastName is missing', () => {
|
||||
expect(validatePersonFields('PERSON', 'Hans', '')).toBe('validation_last_name_required');
|
||||
});
|
||||
|
||||
it('returns lastName error key when lastName is undefined', () => {
|
||||
expect(validatePersonFields('INSTITUTION', undefined, undefined)).toBe(
|
||||
'validation_last_name_required'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns firstName error key when type is PERSON and firstName is missing', () => {
|
||||
expect(validatePersonFields('PERSON', '', 'Müller')).toBe('validation_first_name_required');
|
||||
});
|
||||
|
||||
it('returns firstName error key when type is PERSON and firstName is undefined', () => {
|
||||
expect(validatePersonFields('PERSON', undefined, 'Müller')).toBe(
|
||||
'validation_first_name_required'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for INSTITUTION without firstName', () => {
|
||||
expect(validatePersonFields('INSTITUTION', undefined, 'Verlag GmbH')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for GROUP without firstName', () => {
|
||||
expect(validatePersonFields('GROUP', undefined, 'Familie Raddatz')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for UNKNOWN without firstName', () => {
|
||||
expect(validatePersonFields('UNKNOWN', undefined, 'Unbekannt')).toBeNull();
|
||||
});
|
||||
});
|
||||
39
frontend/src/lib/person-validation.ts
Normal file
39
frontend/src/lib/person-validation.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
export const PERSON_TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const;
|
||||
export type PersonType = (typeof PERSON_TYPES)[number];
|
||||
|
||||
export type PersonFormData = {
|
||||
personType?: string | null;
|
||||
title?: string | null;
|
||||
firstName?: string | null;
|
||||
lastName: string;
|
||||
alias?: string | null;
|
||||
birthYear?: number | null;
|
||||
deathYear?: number | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
export function normalizePersonType(raw: string | undefined | null): PersonType {
|
||||
return raw === 'SKIP' ? 'UNKNOWN' : ((raw ?? 'PERSON') as PersonType);
|
||||
}
|
||||
|
||||
export type PersonValidationKey =
|
||||
| 'validation_last_name_required'
|
||||
| 'validation_first_name_required';
|
||||
|
||||
export function resolveValidationMessage(key: PersonValidationKey): string {
|
||||
return key === 'validation_last_name_required'
|
||||
? m.validation_last_name_required()
|
||||
: m.validation_first_name_required();
|
||||
}
|
||||
|
||||
export function validatePersonFields(
|
||||
personType: string,
|
||||
firstName: string | undefined | null,
|
||||
lastName: string | undefined | null
|
||||
): PersonValidationKey | null {
|
||||
if (!lastName) return 'validation_last_name_required';
|
||||
if (personType === 'PERSON' && !firstName) return 'validation_first_name_required';
|
||||
return null;
|
||||
}
|
||||
76
frontend/src/lib/stores/bulkSelection.svelte.spec.ts
Normal file
76
frontend/src/lib/stores/bulkSelection.svelte.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { bulkSelectionStore } from './bulkSelection.svelte';
|
||||
|
||||
describe('bulkSelectionStore', () => {
|
||||
afterEach(() => bulkSelectionStore.clear());
|
||||
|
||||
it('starts empty', () => {
|
||||
expect(bulkSelectionStore.size).toBe(0);
|
||||
});
|
||||
|
||||
it('toggle adds an id when absent', () => {
|
||||
bulkSelectionStore.toggle('a');
|
||||
expect(bulkSelectionStore.has('a')).toBe(true);
|
||||
expect(bulkSelectionStore.size).toBe(1);
|
||||
});
|
||||
|
||||
it('toggle removes an id when present', () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.toggle('a');
|
||||
expect(bulkSelectionStore.has('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('add and remove update size', () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
expect(bulkSelectionStore.size).toBe(2);
|
||||
bulkSelectionStore.remove('a');
|
||||
expect(bulkSelectionStore.size).toBe(1);
|
||||
expect(bulkSelectionStore.has('b')).toBe(true);
|
||||
});
|
||||
|
||||
it('add is idempotent', () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('a');
|
||||
expect(bulkSelectionStore.size).toBe(1);
|
||||
});
|
||||
|
||||
it('setAll replaces the selection', () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
bulkSelectionStore.setAll(['c', 'd', 'e']);
|
||||
expect(bulkSelectionStore.size).toBe(3);
|
||||
expect(bulkSelectionStore.has('a')).toBe(false);
|
||||
expect(bulkSelectionStore.has('c')).toBe(true);
|
||||
});
|
||||
|
||||
it('clear empties the selection', () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
bulkSelectionStore.clear();
|
||||
expect(bulkSelectionStore.size).toBe(0);
|
||||
});
|
||||
|
||||
it('setAll([]) leaves the store empty (no-op when filter returned zero matches)', () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.setAll([]);
|
||||
expect(bulkSelectionStore.size).toBe(0);
|
||||
});
|
||||
|
||||
it('setAll drops all previously selected ids, not just some', () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
bulkSelectionStore.setAll(['c', 'd']);
|
||||
expect(bulkSelectionStore.has('a')).toBe(false);
|
||||
expect(bulkSelectionStore.has('b')).toBe(false);
|
||||
expect(bulkSelectionStore.has('c')).toBe(true);
|
||||
expect(bulkSelectionStore.has('d')).toBe(true);
|
||||
});
|
||||
|
||||
it('ids getter exposes the current SvelteSet', () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
const ids = Array.from(bulkSelectionStore.ids);
|
||||
expect(ids.sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
36
frontend/src/lib/stores/bulkSelection.svelte.ts
Normal file
36
frontend/src/lib/stores/bulkSelection.svelte.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
// Live accumulator. Selection persists across pagination and route changes
|
||||
// within /documents and /enrich. Cleared on successful bulk save or via
|
||||
// "Alles aufheben". The store is module-singleton — there is only ever one
|
||||
// bulk-edit selection per browser session.
|
||||
const selectedIds = new SvelteSet<string>();
|
||||
|
||||
export const bulkSelectionStore = {
|
||||
get ids(): SvelteSet<string> {
|
||||
return selectedIds;
|
||||
},
|
||||
get size(): number {
|
||||
return selectedIds.size;
|
||||
},
|
||||
has(id: string): boolean {
|
||||
return selectedIds.has(id);
|
||||
},
|
||||
toggle(id: string): void {
|
||||
if (selectedIds.has(id)) selectedIds.delete(id);
|
||||
else selectedIds.add(id);
|
||||
},
|
||||
add(id: string): void {
|
||||
selectedIds.add(id);
|
||||
},
|
||||
remove(id: string): void {
|
||||
selectedIds.delete(id);
|
||||
},
|
||||
setAll(ids: Iterable<string>): void {
|
||||
selectedIds.clear();
|
||||
for (const id of ids) selectedIds.add(id);
|
||||
},
|
||||
clear(): void {
|
||||
selectedIds.clear();
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFilename, stripExtension } from './filename';
|
||||
import { parseFilename, stripExtension, bulkTitleFromFilename } from './filename';
|
||||
|
||||
describe('parseFilename', () => {
|
||||
describe('date-first patterns', () => {
|
||||
@@ -86,6 +86,24 @@ describe('parseFilename', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkTitleFromFilename', () => {
|
||||
it('replaces underscores with spaces', () => {
|
||||
expect(bulkTitleFromFilename('hello_world.pdf')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('replaces hyphens with spaces', () => {
|
||||
expect(bulkTitleFromFilename('2024-01-01_Max.pdf')).toBe('2024 01 01 Max');
|
||||
});
|
||||
|
||||
it('collapses multiple separators', () => {
|
||||
expect(bulkTitleFromFilename('foo__bar--baz.pdf')).toBe('foo bar baz');
|
||||
});
|
||||
|
||||
it('strips extension', () => {
|
||||
expect(bulkTitleFromFilename('document.pdf')).toBe('document');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripExtension', () => {
|
||||
it('removes the extension', () => {
|
||||
expect(stripExtension('document.pdf')).toBe('document');
|
||||
|
||||
@@ -81,3 +81,7 @@ export function parseFilename(filename: string): FilenameParseResult {
|
||||
export function stripExtension(filename: string): string {
|
||||
return filename.replace(/\.[^/.]+$/, '');
|
||||
}
|
||||
|
||||
export function bulkTitleFromFilename(filename: string): string {
|
||||
return stripExtension(filename).replace(/[_-]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
@@ -10,6 +10,7 @@ import AppNav from './AppNav.svelte';
|
||||
import UserMenu from './UserMenu.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import { provideConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
@@ -17,6 +18,27 @@ let { children, data } = $props();
|
||||
// ConfirmDialog below reads it via getConfirmService() and renders the <dialog>.
|
||||
provideConfirmService();
|
||||
|
||||
// Auto-clear the bulk-selection store when the user leaves the routes that
|
||||
// surface the BulkSelectionBar. Without this the selection silently follows
|
||||
// the user to /persons / /admin etc. and reappears as a stale 3-doc count
|
||||
// when they wander back to /documents — Felix C4 on PR #331.
|
||||
//
|
||||
// `bulkSelectionStore.size` is read inside `untrack` so the effect only
|
||||
// re-fires on route change, not on every checkbox toggle (Felix C3 cycle 3).
|
||||
$effect(() => {
|
||||
const path = page.url.pathname;
|
||||
const inBulkContext =
|
||||
path === '/documents' ||
|
||||
path.startsWith('/documents/') ||
|
||||
path === '/enrich' ||
|
||||
path.startsWith('/enrich/');
|
||||
if (!inBulkContext) {
|
||||
untrack(() => {
|
||||
if (bulkSelectionStore.size > 0) bulkSelectionStore.clear();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const isAdmin = $derived(
|
||||
data?.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'))
|
||||
);
|
||||
|
||||
@@ -13,14 +13,12 @@ let {
|
||||
items,
|
||||
canWrite,
|
||||
error,
|
||||
total = 0,
|
||||
q = '',
|
||||
sort = 'DATE'
|
||||
}: {
|
||||
items: DocumentSearchItem[];
|
||||
canWrite: boolean;
|
||||
error?: string | null;
|
||||
total?: number;
|
||||
q?: string;
|
||||
sort?: SortMode;
|
||||
} = $props();
|
||||
@@ -71,29 +69,6 @@ function groupByReceiver(docItems: DocumentSearchItem[]) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- DOCUMENT LIST HEADER -->
|
||||
<div class="mb-2 flex justify-end">
|
||||
{#if canWrite}
|
||||
<a
|
||||
href="/documents/new"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.docs_btn_new()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- RESULT COUNT -->
|
||||
{#if total > 0}
|
||||
<p class="mb-3 font-sans text-base text-ink-2">{m.docs_result_count({ count: total })}</p>
|
||||
{/if}
|
||||
|
||||
<!-- ERROR -->
|
||||
{#if error}
|
||||
<div class="border border-line bg-surface shadow-sm">
|
||||
@@ -119,7 +94,7 @@ function groupByReceiver(docItems: DocumentSearchItem[]) {
|
||||
</div>
|
||||
<ul class="divide-y divide-line">
|
||||
{#each group.items as item (group.label + '-' + item.document.id)}
|
||||
<DocumentRow item={item} />
|
||||
<DocumentRow item={item} canWrite={canWrite} />
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,9 @@ import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import SearchFilterBar from '../SearchFilterBar.svelte';
|
||||
import DocumentList from '../DocumentList.svelte';
|
||||
import Pagination from '$lib/components/Pagination.svelte';
|
||||
import BulkSelectionBar from '$lib/components/document/BulkSelectionBar.svelte';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
import { getErrorMessage, parseBackendError } from '$lib/errors';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -138,6 +141,50 @@ $effect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
let editingAll = $state(false);
|
||||
let editAllError = $state<string | null>(null);
|
||||
|
||||
/**
|
||||
* Fast path: replace the current selection with every document matching the
|
||||
* active filter (across all pages) and jump to the bulk-edit screen. The
|
||||
* /api/documents/ids endpoint is hard-capped (5000 results); on cap overflow
|
||||
* the backend returns BULK_EDIT_TOO_MANY_IDS, which we surface inline.
|
||||
*/
|
||||
async function editAllMatching() {
|
||||
if (editingAll) return;
|
||||
editingAll = true;
|
||||
editAllError = null;
|
||||
try {
|
||||
const params = buildSearchParams({
|
||||
q: data.q || '',
|
||||
from: data.from || '',
|
||||
to: data.to || '',
|
||||
senderId: data.senderId || '',
|
||||
receiverId: data.receiverId || '',
|
||||
tags: data.tags || [],
|
||||
sort: '',
|
||||
dir: '',
|
||||
tagQ: data.tagQ || '',
|
||||
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND'
|
||||
});
|
||||
params.delete('sort');
|
||||
params.delete('dir');
|
||||
const res = await fetch(`/api/documents/ids?${params.toString()}`);
|
||||
if (!res.ok) {
|
||||
const backend = await parseBackendError(res);
|
||||
editAllError = getErrorMessage(backend?.code);
|
||||
return;
|
||||
}
|
||||
const ids: string[] = await res.json();
|
||||
bulkSelectionStore.setAll(ids);
|
||||
await goto('/documents/bulk-edit');
|
||||
} catch {
|
||||
editAllError = m.bulk_edit_all_x_failed();
|
||||
} finally {
|
||||
editingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep local filter state in sync with server data after navigation completes.
|
||||
// Guard q: skip overwrite while the user is actively typing.
|
||||
$effect(() => {
|
||||
@@ -159,7 +206,13 @@ $effect(() => {
|
||||
<title>{m.nav_documents()} – Familienarchiv</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
|
||||
<!-- Reserve bottom padding when the bulk-selection bar is visible so the
|
||||
sticky bar does not occlude the last document row or the pagination
|
||||
controls (WCAG 1.4.10 / 2.4.7). -->
|
||||
<main
|
||||
class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8"
|
||||
class:pb-32={bulkSelectionStore.size > 0 && data.canWrite}
|
||||
>
|
||||
<h1 class="sr-only">{m.nav_documents()}</h1>
|
||||
|
||||
<SearchFilterBar
|
||||
@@ -181,9 +234,54 @@ $effect(() => {
|
||||
onblur={() => (qFocused = false)}
|
||||
/>
|
||||
|
||||
<div class="mb-3 flex items-center justify-between gap-4">
|
||||
<p class="font-sans text-base text-ink-2">
|
||||
{#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if}
|
||||
</p>
|
||||
{#if data.canWrite}
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<div class="flex items-center gap-4">
|
||||
{#if data.totalElements > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={editAllMatching}
|
||||
disabled={editingAll}
|
||||
class="inline-flex cursor-pointer items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
|
||||
data-testid="bulk-edit-all-x"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.bulk_edit_all_x({ count: data.totalElements })}
|
||||
</button>
|
||||
{/if}
|
||||
<a
|
||||
href="/documents/new"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{m.docs_btn_new()}
|
||||
</a>
|
||||
</div>
|
||||
{#if editAllError}
|
||||
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
|
||||
{editAllError}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<DocumentList
|
||||
items={data.items}
|
||||
total={data.totalElements}
|
||||
q={data.q}
|
||||
canWrite={data.canWrite}
|
||||
error={data.error}
|
||||
@@ -192,3 +290,5 @@ $effect(() => {
|
||||
|
||||
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
|
||||
</main>
|
||||
|
||||
<BulkSelectionBar canWrite={data.canWrite} />
|
||||
|
||||
@@ -85,7 +85,8 @@ export const actions = {
|
||||
if (doc.title) formData.set('title', doc.title);
|
||||
if (doc.documentDate) formData.set('documentDate', doc.documentDate);
|
||||
if (doc.location) formData.set('location', doc.location);
|
||||
if (doc.documentLocation) formData.set('documentLocation', doc.documentLocation);
|
||||
if (doc.archiveBox) formData.set('archiveBox', doc.archiveBox);
|
||||
if (doc.archiveFolder) formData.set('archiveFolder', doc.archiveFolder);
|
||||
if (doc.transcription) formData.set('transcription', doc.transcription);
|
||||
if (doc.summary) formData.set('summary', doc.summary);
|
||||
if (doc.sender?.id) formData.set('senderId', doc.sender.id);
|
||||
|
||||
12
frontend/src/routes/documents/bulk-edit/+page.server.ts
Normal file
12
frontend/src/routes/documents/bulk-edit/+page.server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export async function load({ locals }: { locals: App.Locals }) {
|
||||
// Defensive: a UserGroup row with NULL permissions returns undefined here
|
||||
// rather than throwing on .includes() — treat that as "not WRITE_ALL".
|
||||
const canWrite =
|
||||
locals.user?.groups?.some(
|
||||
(g: { permissions?: string[] }) => g.permissions?.includes('WRITE_ALL') ?? false
|
||||
) ?? false;
|
||||
if (!canWrite) throw redirect(303, '/documents');
|
||||
return { canWrite };
|
||||
}
|
||||
84
frontend/src/routes/documents/bulk-edit/+page.svelte
Normal file
84
frontend/src/routes/documents/bulk-edit/+page.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
import BulkDocumentEditLayout, {
|
||||
type BulkEditEntry
|
||||
} from '$lib/components/document/BulkDocumentEditLayout.svelte';
|
||||
import { getErrorMessage, parseBackendError } from '$lib/errors';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let entries = $state<BulkEditEntry[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const ids = Array.from(bulkSelectionStore.ids);
|
||||
if (ids.length === 0) {
|
||||
// Skip the loading flash on the empty-store redirect path — the user
|
||||
// is bouncing back to /documents in the next tick.
|
||||
loading = false;
|
||||
await goto('/documents');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/documents/batch-metadata', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const backend = await parseBackendError(res);
|
||||
error = getErrorMessage(backend?.code);
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
const summaries = (await res.json()) as BulkEditEntry[];
|
||||
entries = summaries;
|
||||
} catch {
|
||||
error = getErrorMessage(undefined);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.bulk_edit_title()} – Familienarchiv</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<div
|
||||
class="flex h-full items-center justify-center gap-3 p-12 text-sm text-ink-2"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="bulk-edit-loading"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 animate-spin text-ink-3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>{m.bulk_edit_loading()}</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div
|
||||
role="alert"
|
||||
class="m-6 rounded-sm border border-danger/40 bg-danger/10 p-4 text-sm text-danger"
|
||||
data-testid="bulk-edit-page-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{:else if entries.length > 0}
|
||||
<BulkDocumentEditLayout mode="edit" initialEditEntries={entries} />
|
||||
{/if}
|
||||
61
frontend/src/routes/documents/bulk-edit/page.server.spec.ts
Normal file
61
frontend/src/routes/documents/bulk-edit/page.server.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { load } from './+page.server';
|
||||
|
||||
describe('/documents/bulk-edit +page.server.ts', () => {
|
||||
it('redirects to /documents when user lacks WRITE_ALL', async () => {
|
||||
const locals = { user: { groups: [{ permissions: ['READ_ALL'] }] } };
|
||||
try {
|
||||
// @ts-expect-error — partial event shape sufficient for this guard
|
||||
await load({ locals });
|
||||
throw new Error('expected redirect to be thrown');
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; location?: string };
|
||||
expect(err.status).toBe(303);
|
||||
expect(err.location).toBe('/documents');
|
||||
}
|
||||
});
|
||||
|
||||
it('redirects when user has no groups', async () => {
|
||||
const locals = { user: { groups: [] } };
|
||||
try {
|
||||
// @ts-expect-error — partial event shape sufficient for this guard
|
||||
await load({ locals });
|
||||
throw new Error('expected redirect');
|
||||
} catch (e) {
|
||||
expect((e as { status?: number }).status).toBe(303);
|
||||
}
|
||||
});
|
||||
|
||||
it('redirects when no user is logged in', async () => {
|
||||
const locals = {};
|
||||
try {
|
||||
// @ts-expect-error — partial event shape sufficient for this guard
|
||||
await load({ locals });
|
||||
throw new Error('expected redirect');
|
||||
} catch (e) {
|
||||
expect((e as { status?: number }).status).toBe(303);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns canWrite=true for a WRITE_ALL user', async () => {
|
||||
const locals = { user: { groups: [{ permissions: ['WRITE_ALL', 'READ_ALL'] }] } };
|
||||
// @ts-expect-error — partial event shape sufficient for this guard
|
||||
const result = await load({ locals });
|
||||
expect(result).toEqual({ canWrite: true });
|
||||
});
|
||||
|
||||
it('redirects when a group has no permissions array (defensive)', async () => {
|
||||
// Sara C7 — a UserGroup row with NULL permissions used to throw on
|
||||
// .includes(); the guard now treats that case as "not WRITE_ALL".
|
||||
const locals = {
|
||||
user: { groups: [{ permissions: undefined as unknown as string[] }] }
|
||||
};
|
||||
try {
|
||||
// @ts-expect-error — partial event shape sufficient for this guard
|
||||
await load({ locals });
|
||||
throw new Error('expected redirect');
|
||||
} catch (e) {
|
||||
expect((e as { status?: number }).status).toBe(303);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,154 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
|
||||
import DescriptionSection from '$lib/components/document/DescriptionSection.svelte';
|
||||
import TranscriptionSection from '$lib/components/document/TranscriptionSection.svelte';
|
||||
import FileSectionNew from './FileSectionNew.svelte';
|
||||
import { type FilenameParseResult } from '$lib/utils/filename';
|
||||
import BulkDocumentEditLayout from '$lib/components/document/BulkDocumentEditLayout.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let tags: { name: string; id?: string; color?: string; parentId?: string }[] = $state([]);
|
||||
let senderId = $state(untrack(() => data.initialSenderId));
|
||||
let selectedReceivers: { id: string; firstName?: string; lastName: string; displayName: string }[] =
|
||||
$state(untrack(() => data.initialReceivers));
|
||||
|
||||
let parsedSuggestion = $state<FilenameParseResult>({});
|
||||
|
||||
// Title is derived from the filename suggestion unless the user has typed something
|
||||
let titleDirty = $state(false);
|
||||
let titleOverride = $state('');
|
||||
let titleValue = $derived(
|
||||
titleDirty ? titleOverride : (parsedSuggestion.suggestedTitle ?? titleOverride)
|
||||
);
|
||||
|
||||
// Details panel: starts open when prefill data is present or a form error occurred.
|
||||
// Auto-opens when filename parsing finds a date/sender, but never force-closes — user
|
||||
// can always collapse the section manually.
|
||||
let detailsOpen = $state(
|
||||
!!(
|
||||
untrack(() => data.initialSenderId) ||
|
||||
untrack(() => data.initialReceivers).length > 0 ||
|
||||
untrack(() => form)?.error
|
||||
)
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (parsedSuggestion.dateIso || senderId || selectedReceivers.length > 0) {
|
||||
detailsOpen = true;
|
||||
}
|
||||
});
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<!-- Heading -->
|
||||
<div class="mb-6">
|
||||
<a
|
||||
href="/"
|
||||
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
{m.btn_back_to_overview()}
|
||||
</a>
|
||||
<h1 class="font-serif text-3xl text-ink">{m.doc_new_heading()}</h1>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-6 rounded border border-red-200 bg-red-50 p-4 text-red-700">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
|
||||
<!-- File upload — prominent, at the top -->
|
||||
<FileSectionNew onfileParsed={(r) => (parsedSuggestion = r)} />
|
||||
|
||||
<!-- Standalone title card -->
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
<label for="new-title" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_title()}</label
|
||||
>
|
||||
<input
|
||||
id="new-title"
|
||||
type="text"
|
||||
name="title"
|
||||
value={titleValue}
|
||||
oninput={(e) => {
|
||||
titleOverride = (e.target as HTMLInputElement).value;
|
||||
titleDirty = true;
|
||||
}}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
placeholder="Titel eingeben…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible further details -->
|
||||
<details
|
||||
bind:open={detailsOpen}
|
||||
class="group rounded-sm border border-line bg-surface shadow-sm"
|
||||
>
|
||||
<summary class="cursor-pointer list-none px-6 py-4">
|
||||
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.doc_more_details()}</span
|
||||
>
|
||||
</summary>
|
||||
<div class="space-y-6 px-0 pb-6">
|
||||
<WhoWhenSection
|
||||
bind:senderId={senderId}
|
||||
bind:selectedReceivers={selectedReceivers}
|
||||
initialSenderName={data.initialSenderName}
|
||||
suggestedDateIso={parsedSuggestion.dateIso ?? ''}
|
||||
suggestedSenderName={parsedSuggestion.personName ?? ''}
|
||||
/>
|
||||
<DescriptionSection bind:tags={tags} hideTitle={true} />
|
||||
<TranscriptionSection />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Sticky Save Bar -->
|
||||
<div
|
||||
class="sticky bottom-0 z-10 -mx-4 border-t border-line bg-surface px-4 py-3 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:px-6 sm:py-4"
|
||||
>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<a
|
||||
href="/"
|
||||
class="order-last text-center text-sm font-medium text-ink-2 transition-colors hover:text-ink sm:order-first sm:text-left"
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</a>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
name="metadataComplete"
|
||||
value="false"
|
||||
formaction="?/save"
|
||||
class="w-full rounded-sm border border-line px-5 py-2.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted sm:w-auto sm:py-2"
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
name="metadataComplete"
|
||||
value="true"
|
||||
formaction="?/saveReviewed"
|
||||
class="w-full rounded-sm bg-primary px-5 py-2.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90 sm:w-auto sm:py-2"
|
||||
>
|
||||
{m.btn_save_and_mark_reviewed()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<BulkDocumentEditLayout
|
||||
initialSenderId={data.initialSenderId}
|
||||
initialSenderName={data.initialSenderName}
|
||||
initialReceivers={data.initialReceivers}
|
||||
/>
|
||||
|
||||
@@ -21,15 +21,14 @@ const baseData = {
|
||||
|
||||
describe('New document page – sender prefill', () => {
|
||||
it('shows an empty sender input when no senderId is in the URL', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
render(Page, { data: baseData });
|
||||
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
||||
expect(input?.value).toBe('');
|
||||
});
|
||||
|
||||
it('shows the sender name in the typeahead input when initialSenderName is set', async () => {
|
||||
render(Page, {
|
||||
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' },
|
||||
form: null
|
||||
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }
|
||||
});
|
||||
const input = document.querySelector<HTMLInputElement>('#senderId-search');
|
||||
expect(input?.value).toBe('Hans Müller');
|
||||
@@ -37,8 +36,7 @@ describe('New document page – sender prefill', () => {
|
||||
|
||||
it('sets the hidden senderId input to the prefilled ID', async () => {
|
||||
render(Page, {
|
||||
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' },
|
||||
form: null
|
||||
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }
|
||||
});
|
||||
const hidden = document.querySelector<HTMLInputElement>(
|
||||
'input[type="hidden"][name="senderId"]'
|
||||
@@ -51,7 +49,7 @@ describe('New document page – sender prefill', () => {
|
||||
|
||||
describe('New document page – receiver prefill', () => {
|
||||
it('shows no receiver chips when initialReceivers is empty', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
render(Page, { data: baseData });
|
||||
await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -62,7 +60,7 @@ describe('New document page – receiver prefill', () => {
|
||||
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
||||
]
|
||||
};
|
||||
render(Page, { data, form: null });
|
||||
render(Page, { data });
|
||||
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -73,7 +71,7 @@ describe('New document page – receiver prefill', () => {
|
||||
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
|
||||
]
|
||||
};
|
||||
render(Page, { data, form: null });
|
||||
render(Page, { data });
|
||||
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');
|
||||
expect(hidden?.value).toBe('p2');
|
||||
});
|
||||
|
||||
@@ -19,5 +19,5 @@ export async function load({
|
||||
|
||||
const documents = result.response.ok ? (result.data ?? []) : [];
|
||||
|
||||
return { documents };
|
||||
return { documents, canWrite };
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import BackButton from '$lib/components/BackButton.svelte';
|
||||
import BulkSelectionBar from '$lib/components/document/BulkSelectionBar.svelte';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const documents = $derived(data.documents);
|
||||
const count = $derived(documents.length);
|
||||
const canWrite = $derived(data.canWrite);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-10">
|
||||
<!-- Reserve bottom padding when the bulk-selection bar is visible so the
|
||||
sticky bar does not occlude the last document row (WCAG 1.4.10). -->
|
||||
<div class="mx-auto max-w-4xl px-4 py-10" class:pb-32={bulkSelectionStore.size > 0 && canWrite}>
|
||||
<!-- Back Link -->
|
||||
<BackButton />
|
||||
|
||||
@@ -61,8 +66,24 @@ const count = $derived(documents.length);
|
||||
<div class="border border-line bg-surface shadow-sm">
|
||||
<ul class="divide-y divide-line-2">
|
||||
{#each documents as doc (doc.id)}
|
||||
<li class="group transition-colors duration-200 hover:bg-muted">
|
||||
<a href="/enrich/{doc.id}" class="flex items-center justify-between p-6">
|
||||
<li class="group relative transition-colors duration-200 hover:bg-muted">
|
||||
<a href="/enrich/{doc.id}" class="absolute inset-0 z-0 block" aria-label={doc.title}
|
||||
></a>
|
||||
<div class="pointer-events-none relative z-10 flex items-center justify-between p-6">
|
||||
{#if canWrite}
|
||||
<label
|
||||
class="pointer-events-auto mr-4 flex min-h-[44px] min-w-[44px] flex-shrink-0 cursor-pointer items-center"
|
||||
data-testid="bulk-select-checkbox"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-5 w-5 cursor-pointer accent-brand-navy"
|
||||
checked={bulkSelectionStore.has(doc.id)}
|
||||
onchange={() => bulkSelectionStore.toggle(doc.id)}
|
||||
aria-label={m.bulk_edit_select_document({ title: doc.title })}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-serif text-lg font-medium text-ink group-hover:underline">
|
||||
{doc.title}
|
||||
@@ -74,10 +95,12 @@ const count = $derived(documents.length);
|
||||
aria-hidden="true"
|
||||
class="ml-4 h-5 w-5 shrink-0 opacity-30 transition-opacity group-hover:opacity-70"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<BulkSelectionBar canWrite={canWrite} />
|
||||
|
||||
@@ -13,18 +13,22 @@ const rules = [
|
||||
icon: '✗',
|
||||
title: m.richtlinien_rule_durchgestrichen_title(),
|
||||
body: m.richtlinien_rule_durchgestrichen_body(),
|
||||
beispielInput: 'der Text',
|
||||
beispielInputStrike: true,
|
||||
beispielOutput: '[durchgestrichen: der Text]'
|
||||
},
|
||||
{
|
||||
icon: 'ſ',
|
||||
title: m.richtlinien_rule_langes_s_title(),
|
||||
body: m.richtlinien_rule_langes_s_body(),
|
||||
beispielOutput: 's'
|
||||
beispielInput: 'ſtraße',
|
||||
beispielOutput: 'straße'
|
||||
},
|
||||
{
|
||||
icon: '?',
|
||||
title: m.richtlinien_rule_name_title(),
|
||||
body: m.richtlinien_rule_name_body(),
|
||||
beispielInput: 'Müller',
|
||||
beispielOutput: '[Müller?]'
|
||||
},
|
||||
{
|
||||
@@ -46,7 +50,7 @@ const klaerungChips = [
|
||||
<title>{m.richtlinien_title()} — Familienarchiv</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-10 font-serif">
|
||||
<main class="mx-auto max-w-2xl px-4 py-10 font-serif">
|
||||
<!-- Title -->
|
||||
<h1 class="mb-4 text-3xl font-bold text-ink">{m.richtlinien_title()}</h1>
|
||||
|
||||
@@ -61,10 +65,23 @@ const klaerungChips = [
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
referrerpolicy="no-referrer"
|
||||
class="inline-flex items-center gap-1 font-sans text-sm font-medium text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
|
||||
aria-label="{m.richtlinien_wiki_link()} — {m.common_opens_new_tab()}"
|
||||
class="inline-flex items-center gap-1.5 font-sans text-sm font-medium text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
|
||||
>
|
||||
{m.richtlinien_wiki_link()}
|
||||
<span class="new-tab ml-1 text-[11px] text-ink-3">({m.common_opens_new_tab()})</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Zm6.75-3a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0V3.56l-4.22 4.22a.75.75 0 0 1-1.06-1.06l4.22-4.22H11a.75.75 0 0 1-.75-.75Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -78,6 +95,8 @@ const klaerungChips = [
|
||||
icon={rule.icon}
|
||||
title={rule.title}
|
||||
body={rule.body}
|
||||
beispielInput={rule.beispielInput}
|
||||
beispielInputStrike={rule.beispielInputStrike}
|
||||
beispielOutput={rule.beispielOutput}
|
||||
beispielLabel={m.richtlinien_beispiel_label()}
|
||||
/>
|
||||
@@ -102,10 +121,10 @@ const klaerungChips = [
|
||||
|
||||
<!-- Closing card -->
|
||||
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-2 font-serif text-lg font-bold text-ink">{m.richtlinien_closing_title()}</h2>
|
||||
<h3 class="mb-2 font-serif text-lg font-bold text-ink">{m.richtlinien_closing_title()}</h3>
|
||||
<p class="font-serif text-sm leading-relaxed text-ink-2">{m.richtlinien_closing_body()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
@@ -113,10 +132,6 @@ const klaerungChips = [
|
||||
display: none;
|
||||
}
|
||||
|
||||
.new-tab {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 1.5cm;
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
// Safe: handleAuth in hooks.server.ts redirects unauthenticated requests
|
||||
// before prerendered HTML is visible.
|
||||
export const prerender = true;
|
||||
|
||||
@@ -66,6 +66,9 @@
|
||||
/* Focus ring — keyboard focus indicator, mode-aware (navy in light, mint in dark) */
|
||||
--color-focus-ring: var(--c-focus-ring);
|
||||
|
||||
/* Parchment — warm background for code/example blocks inside cards */
|
||||
--color-parchment: var(--c-parchment);
|
||||
|
||||
/* Danger — destructive action color */
|
||||
--color-danger: var(--c-danger);
|
||||
--color-danger-fg: var(--c-danger-fg);
|
||||
@@ -122,6 +125,9 @@
|
||||
--c-danger: #c0392b;
|
||||
--c-danger-fg: #ffffff;
|
||||
|
||||
/* Parchment — warm near-white for example blocks (light mode: cream #FAF8F1) */
|
||||
--c-parchment: #faf8f1;
|
||||
|
||||
/* Tag color tokens — decorative dot colors on tag chips */
|
||||
--c-tag-sage: #5a8a6a;
|
||||
--c-tag-sienna: #a0522d;
|
||||
@@ -203,6 +209,9 @@
|
||||
--c-danger: #e55347;
|
||||
--c-danger-fg: #ffffff;
|
||||
|
||||
/* Parchment — subtle surface shift for example blocks on dark navy */
|
||||
--c-parchment: #041828;
|
||||
|
||||
/* Tag color tokens — lighter for visibility on dark backgrounds */
|
||||
--c-tag-sage: #7abf8a;
|
||||
--c-tag-sienna: #cc7050;
|
||||
@@ -267,6 +276,9 @@
|
||||
--c-danger: #e55347;
|
||||
--c-danger-fg: #ffffff;
|
||||
|
||||
/* Parchment — subtle surface shift for example blocks on dark navy */
|
||||
--c-parchment: #041828;
|
||||
|
||||
/* Tag color tokens — lighter for visibility on dark backgrounds */
|
||||
--c-tag-sage: #7abf8a;
|
||||
--c-tag-sienna: #cc7050;
|
||||
|
||||
@@ -13,6 +13,7 @@ let {
|
||||
lastName: string;
|
||||
displayName: string;
|
||||
personType?: string | null;
|
||||
title?: string | null;
|
||||
alias?: string | null;
|
||||
birthYear?: number | null;
|
||||
deathYear?: number | null;
|
||||
@@ -66,6 +67,14 @@ let {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if person.personType === 'PERSON' && person.title}
|
||||
<p
|
||||
class="mb-0.5 text-center font-sans text-xs tracking-widest text-ink-3 [font-variant:small-caps]"
|
||||
>
|
||||
{person.title}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Name — centered, serif -->
|
||||
<h1 class="mb-1 text-center font-serif text-xl font-bold text-ink">
|
||||
{person.displayName}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
import {
|
||||
normalizePersonType,
|
||||
validatePersonFields,
|
||||
resolveValidationMessage
|
||||
} from '$lib/person-validation';
|
||||
|
||||
export async function load({ params, fetch, locals }) {
|
||||
const canWrite =
|
||||
@@ -22,12 +27,16 @@ export async function load({ params, fetch, locals }) {
|
||||
throw error(result.response.status, getErrorMessage(code));
|
||||
}
|
||||
|
||||
return { person: result.data!, aliases: aliasesResult.data ?? [] };
|
||||
const person = result.data!;
|
||||
const personType = normalizePersonType(person.personType);
|
||||
return { person: { ...person, personType }, aliases: aliasesResult.data ?? [] };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
update: async ({ request, params, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const personType = normalizePersonType(formData.get('personType')?.toString());
|
||||
const title = formData.get('title')?.toString().trim() || undefined;
|
||||
const firstName = formData.get('firstName')?.toString().trim();
|
||||
const lastName = formData.get('lastName')?.toString().trim();
|
||||
const alias = formData.get('alias')?.toString().trim() || undefined;
|
||||
@@ -37,15 +46,18 @@ export const actions = {
|
||||
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
||||
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });
|
||||
const validationKey = validatePersonFields(personType, firstName, lastName);
|
||||
if (validationKey) {
|
||||
return fail(400, { updateError: resolveValidationMessage(validationKey) });
|
||||
}
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.PUT('/api/persons/{id}', {
|
||||
params: { path: { id: params.id } },
|
||||
body: {
|
||||
firstName,
|
||||
personType,
|
||||
...(title ? { title } : {}),
|
||||
...(firstName ? { firstName } : {}),
|
||||
lastName,
|
||||
...(alias ? { alias } : {}),
|
||||
...(notes ? { notes } : {}),
|
||||
|
||||
@@ -1,93 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import PersonTypeSelector from '$lib/components/PersonTypeSelector.svelte';
|
||||
import {
|
||||
PERSON_TYPES as TYPES,
|
||||
type PersonType,
|
||||
type PersonFormData
|
||||
} from '$lib/person-validation';
|
||||
|
||||
let {
|
||||
person
|
||||
}: {
|
||||
person: {
|
||||
firstName?: string | null;
|
||||
lastName: string;
|
||||
alias?: string | null;
|
||||
birthYear?: number | null;
|
||||
deathYear?: number | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
} = $props();
|
||||
let { person }: { person: PersonFormData } = $props();
|
||||
|
||||
let selectedType = $state<PersonType>(
|
||||
untrack(() =>
|
||||
TYPES.includes(person.personType as PersonType) ? (person.personType as PersonType) : 'PERSON'
|
||||
)
|
||||
);
|
||||
|
||||
const isPerson = $derived(selectedType === 'PERSON');
|
||||
const lastNameLabel = $derived(
|
||||
selectedType === 'INSTITUTION' || selectedType === 'GROUP'
|
||||
? m.form_label_name()
|
||||
: m.form_label_last_name()
|
||||
);
|
||||
|
||||
const labelCls = 'mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase';
|
||||
const inputCls =
|
||||
'block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="firstName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.form_label_first_name()} *</label
|
||||
>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
value={person.firstName}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
<div class="md:col-span-2">
|
||||
<p class={labelCls}>
|
||||
{m.form_label_person_type()}
|
||||
</p>
|
||||
<PersonTypeSelector
|
||||
value={selectedType}
|
||||
name="personType"
|
||||
onchange={(type: PersonType) => (selectedType = type)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="lastName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.form_label_last_name()} *</label
|
||||
>
|
||||
|
||||
{#if isPerson}
|
||||
<div>
|
||||
<label for="title" class={labelCls}>{m.form_label_title()}</label>
|
||||
<input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
maxlength="50"
|
||||
value={person.title ?? ''}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="firstName" class={labelCls}>{m.form_label_first_name()} *</label>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
value={person.firstName ?? ''}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class={!isPerson ? 'md:col-span-2' : ''}>
|
||||
<label for="lastName" class={labelCls}>{lastNameLabel} *</label>
|
||||
<input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
required
|
||||
value={person.lastName}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isPerson}
|
||||
<div class="md:col-span-2">
|
||||
<label for="alias" class={labelCls}>{m.form_label_alias()}</label>
|
||||
<input id="alias" name="alias" type="text" value={person.alias ?? ''} class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
|
||||
<input
|
||||
id="birthYear"
|
||||
name="birthYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
value={person.birthYear ?? ''}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
|
||||
<input
|
||||
id="deathYear"
|
||||
name="deathYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
value={person.deathYear ?? ''}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="alias" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.form_label_alias()}</label
|
||||
>
|
||||
<input
|
||||
id="alias"
|
||||
name="alias"
|
||||
type="text"
|
||||
value={person.alias ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="birthYear" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.person_label_birth_year()}</label
|
||||
>
|
||||
<input
|
||||
id="birthYear"
|
||||
name="birthYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
value={person.birthYear ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="deathYear" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.person_label_death_year()}</label
|
||||
>
|
||||
<input
|
||||
id="deathYear"
|
||||
name="deathYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
value={person.deathYear ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="notes" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>{m.person_label_notes()}</label
|
||||
>
|
||||
<label for="notes" class={labelCls}>{m.person_label_notes()}</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizePersonType } from '$lib/person-validation';
|
||||
|
||||
describe('edit load — SKIP → UNKNOWN normalization', () => {
|
||||
it('maps SKIP to UNKNOWN', () => {
|
||||
expect(normalizePersonType('SKIP')).toBe('UNKNOWN');
|
||||
});
|
||||
|
||||
it('passes PERSON through unchanged', () => {
|
||||
expect(normalizePersonType('PERSON')).toBe('PERSON');
|
||||
});
|
||||
|
||||
it('passes INSTITUTION through unchanged', () => {
|
||||
expect(normalizePersonType('INSTITUTION')).toBe('INSTITUTION');
|
||||
});
|
||||
|
||||
it('passes GROUP through unchanged', () => {
|
||||
expect(normalizePersonType('GROUP')).toBe('GROUP');
|
||||
});
|
||||
|
||||
it('passes UNKNOWN through unchanged', () => {
|
||||
expect(normalizePersonType('UNKNOWN')).toBe('UNKNOWN');
|
||||
});
|
||||
|
||||
it('defaults null to PERSON', () => {
|
||||
expect(normalizePersonType(null)).toBe('PERSON');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/api.server';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
import {
|
||||
normalizePersonType,
|
||||
validatePersonFields,
|
||||
resolveValidationMessage
|
||||
} from '$lib/person-validation';
|
||||
|
||||
export async function load({ locals }: { locals: App.Locals }) {
|
||||
const canWrite =
|
||||
@@ -12,6 +18,8 @@ export async function load({ locals }: { locals: App.Locals }) {
|
||||
export const actions = {
|
||||
default: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const personType = normalizePersonType(formData.get('personType')?.toString());
|
||||
const title = formData.get('title')?.toString().trim() || undefined;
|
||||
const firstName = formData.get('firstName')?.toString().trim();
|
||||
const lastName = formData.get('lastName')?.toString().trim();
|
||||
const alias = formData.get('alias')?.toString().trim() || undefined;
|
||||
@@ -19,8 +27,16 @@ export const actions = {
|
||||
const deathYearStr = formData.get('deathYear')?.toString().trim();
|
||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
return fail(400, { error: 'Vor- und Nachname sind Pflichtfelder.' });
|
||||
const validationKey = validatePersonFields(personType, firstName, lastName);
|
||||
if (validationKey) {
|
||||
return fail(400, {
|
||||
error: resolveValidationMessage(validationKey),
|
||||
personType,
|
||||
title,
|
||||
firstName: firstName ?? '',
|
||||
lastName: lastName ?? '',
|
||||
alias
|
||||
});
|
||||
}
|
||||
|
||||
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
||||
@@ -29,8 +45,10 @@ export const actions = {
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.POST('/api/persons', {
|
||||
body: {
|
||||
firstName,
|
||||
lastName,
|
||||
personType,
|
||||
...(title ? { title } : {}),
|
||||
...(firstName ? { firstName } : {}),
|
||||
lastName: lastName!,
|
||||
...(alias ? { alias } : {}),
|
||||
...(birthYear ? { birthYear } : {}),
|
||||
...(deathYear ? { deathYear } : {}),
|
||||
@@ -39,7 +57,15 @@ export const actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, { error: 'Person konnte nicht gespeichert werden.' });
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(code),
|
||||
personType,
|
||||
title,
|
||||
firstName,
|
||||
lastName: lastName!,
|
||||
alias
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect(303, `/persons/${result.data!.id}`);
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import BackButton from '$lib/components/BackButton.svelte';
|
||||
import PersonTypeSelector from '$lib/components/PersonTypeSelector.svelte';
|
||||
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person-validation';
|
||||
|
||||
let { form } = $props();
|
||||
|
||||
let selectedType = $state<PersonType>(
|
||||
untrack(() =>
|
||||
TYPES.includes((form?.personType as PersonType) ?? 'PERSON')
|
||||
? ((form?.personType as PersonType) ?? 'PERSON')
|
||||
: 'PERSON'
|
||||
)
|
||||
);
|
||||
|
||||
const isPerson = $derived(selectedType === 'PERSON');
|
||||
const lastNameLabel = $derived(
|
||||
selectedType === 'INSTITUTION' || selectedType === 'GROUP'
|
||||
? m.form_label_name()
|
||||
: m.form_label_last_name()
|
||||
);
|
||||
|
||||
const inputCls =
|
||||
'block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
|
||||
const labelCls = 'mb-1 block text-sm font-medium text-ink-2';
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||
<!-- Heading -->
|
||||
<div class="mb-6">
|
||||
<BackButton />
|
||||
<h1 class="font-serif text-3xl text-ink">{m.persons_new_heading()}</h1>
|
||||
@@ -22,79 +44,92 @@ let { form } = $props();
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="firstName" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_first_name()} *</label
|
||||
>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
<div class="md:col-span-2">
|
||||
<p class={labelCls}>{m.form_label_person_type()}</p>
|
||||
<PersonTypeSelector
|
||||
value={selectedType}
|
||||
name="personType"
|
||||
onchange={(type: PersonType) => (selectedType = type)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="lastName" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_last_name()} *</label
|
||||
>
|
||||
{#if isPerson}
|
||||
<div>
|
||||
<label for="title" class={labelCls}>{m.form_label_title()}</label>
|
||||
<input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
maxlength="50"
|
||||
value={form?.title ?? ''}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="firstName" class={labelCls}>{m.form_label_first_name()} *</label>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
value={form?.firstName ?? ''}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class={!isPerson ? 'md:col-span-2' : ''}>
|
||||
<label for="lastName" class={labelCls}>{lastNameLabel} *</label>
|
||||
<input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
required
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
value={form?.lastName ?? ''}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isPerson}
|
||||
<div class="md:col-span-2">
|
||||
<label for="alias" class={labelCls}>{m.form_label_alias()}</label>
|
||||
<input
|
||||
id="alias"
|
||||
name="alias"
|
||||
type="text"
|
||||
placeholder={m.form_placeholder_alias()}
|
||||
value={form?.alias ?? ''}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
|
||||
<input
|
||||
id="birthYear"
|
||||
name="birthYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
|
||||
<input
|
||||
id="deathYear"
|
||||
name="deathYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="alias" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_alias()}</label
|
||||
>
|
||||
<input
|
||||
id="alias"
|
||||
name="alias"
|
||||
type="text"
|
||||
placeholder={m.form_placeholder_alias()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="birthYear" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.person_label_birth_year()}</label
|
||||
>
|
||||
<input
|
||||
id="birthYear"
|
||||
name="birthYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="deathYear" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.person_label_death_year()}</label
|
||||
>
|
||||
<input
|
||||
id="deathYear"
|
||||
name="deathYear"
|
||||
type="number"
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="notes" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.person_label_notes()}</label
|
||||
>
|
||||
<label for="notes" class={labelCls}>{m.person_label_notes()}</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
@@ -106,7 +141,6 @@ let { form } = $props();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Bar -->
|
||||
<div
|
||||
class="mt-4 flex items-center justify-between rounded-sm border border-line bg-surface px-6 py-4 shadow-sm"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user