feat(search): extended search, sort options, live tag filter, result count #183
@@ -12,12 +12,14 @@ import java.util.UUID;
|
|||||||
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
@@ -39,6 +41,8 @@ import org.springframework.web.bind.annotation.PutMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RequestPart;
|
import org.springframework.web.bind.annotation.RequestPart;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@@ -186,15 +190,22 @@ public class DocumentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/search")
|
@GetMapping("/search")
|
||||||
public ResponseEntity<List<Document>> search(
|
public ResponseEntity<DocumentSearchResult> search(
|
||||||
@RequestParam(required = false) String q,
|
@RequestParam(required = false) String q,
|
||||||
@RequestParam(required = false) LocalDate from,
|
@RequestParam(required = false) LocalDate from,
|
||||||
@RequestParam(required = false) LocalDate to,
|
@RequestParam(required = false) LocalDate to,
|
||||||
@RequestParam(required = false) UUID senderId,
|
@RequestParam(required = false) UUID senderId,
|
||||||
@RequestParam(required = false) UUID receiverId,
|
@RequestParam(required = false) UUID receiverId,
|
||||||
@RequestParam(required = false, name = "tag") List<String> tags,
|
@RequestParam(required = false, name = "tag") List<String> tags,
|
||||||
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status) {
|
@RequestParam(required = false) String tagQ,
|
||||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, status));
|
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
||||||
|
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
|
||||||
|
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir) {
|
||||||
|
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
|
||||||
|
}
|
||||||
|
List<Document> results = documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir);
|
||||||
|
return ResponseEntity.ok(DocumentSearchResult.of(results));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- VERSIONS ---
|
// --- VERSIONS ---
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record DocumentSearchResult(List<Document> documents, long total) {
|
||||||
|
/**
|
||||||
|
* Creates a result where total equals the list size.
|
||||||
|
* No pagination yet — the full matched set is always returned.
|
||||||
|
* When pagination is added, total must come from a DB COUNT query, not list.size().
|
||||||
|
*/
|
||||||
|
public static DocumentSearchResult of(List<Document> documents) {
|
||||||
|
return new DocumentSearchResult(documents, documents.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
public enum DocumentSort {
|
||||||
|
DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
public enum DocumentSort {
|
||||||
|
DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE
|
||||||
|
}
|
||||||
@@ -8,24 +8,58 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
public class DocumentSpecifications {
|
public class DocumentSpecifications {
|
||||||
|
|
||||||
// Filtert nach Text (in Titel, Dateiname oder Transkription)
|
// Filtert nach Text (in Titel, Dateiname, Transkription, Ort, Absender- und Empfängername, Tags)
|
||||||
public static Specification<Document> hasText(String text) {
|
public static Specification<Document> hasText(String text) {
|
||||||
return (root, query, cb) -> {
|
return (root, query, cb) -> {
|
||||||
if (!StringUtils.hasText(text))
|
if (!StringUtils.hasText(text))
|
||||||
return null;
|
return null;
|
||||||
String likePattern = "%" + text.toLowerCase() + "%";
|
String likePattern = "%" + text.toLowerCase() + "%";
|
||||||
|
|
||||||
|
// LEFT JOIN on sender (ManyToOne — no duplicate rows)
|
||||||
|
Join<Document, Person> senderJoin = root.join("sender", JoinType.LEFT);
|
||||||
|
|
||||||
|
// EXISTS subquery for receiver name — avoids duplicate rows for multi-receiver docs
|
||||||
|
Subquery<Long> receiverSub = query.subquery(Long.class);
|
||||||
|
Root<Document> receiverRoot = receiverSub.from(Document.class);
|
||||||
|
Join<Document, Person> receiverJoin = receiverRoot.join("receivers");
|
||||||
|
receiverSub.select(cb.literal(1L))
|
||||||
|
.where(
|
||||||
|
cb.equal(receiverRoot.get("id"), root.get("id")),
|
||||||
|
cb.or(
|
||||||
|
cb.like(cb.lower(receiverJoin.get("lastName")), likePattern),
|
||||||
|
cb.like(cb.lower(receiverJoin.get("firstName")), likePattern)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// EXISTS subquery for tag name — avoids duplicate rows for multi-tag docs
|
||||||
|
Subquery<Long> tagSub = query.subquery(Long.class);
|
||||||
|
Root<Document> tagRoot = tagSub.from(Document.class);
|
||||||
|
Join<Document, Tag> tagJoin = tagRoot.join("tags");
|
||||||
|
tagSub.select(cb.literal(1L))
|
||||||
|
.where(
|
||||||
|
cb.equal(tagRoot.get("id"), root.get("id")),
|
||||||
|
cb.like(cb.lower(tagJoin.get("name")), likePattern)
|
||||||
|
);
|
||||||
|
|
||||||
|
query.distinct(true);
|
||||||
|
|
||||||
return cb.or(
|
return cb.or(
|
||||||
cb.like(cb.lower(root.get("title")), likePattern),
|
cb.like(cb.lower(root.get("title")), likePattern),
|
||||||
cb.like(cb.lower(root.get("originalFilename")), likePattern),
|
cb.like(cb.lower(root.get("originalFilename")), likePattern),
|
||||||
cb.like(cb.lower(root.get("transcription")), likePattern),
|
cb.like(cb.lower(root.get("transcription")), likePattern),
|
||||||
cb.like(cb.lower(root.get("location")), likePattern));
|
cb.like(cb.lower(root.get("location")), likePattern),
|
||||||
|
cb.like(cb.lower(senderJoin.get("lastName")), likePattern),
|
||||||
|
cb.like(cb.lower(senderJoin.get("firstName")), likePattern),
|
||||||
|
cb.exists(receiverSub),
|
||||||
|
cb.exists(tagSub)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +95,7 @@ public class DocumentSpecifications {
|
|||||||
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
|
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtert nach Schlagworten (UND-Verknüpfung)
|
// Filtert nach Schlagworten (UND-Verknüpfung, exakter Match)
|
||||||
public static Specification<Document> hasTags(List<String> tags) {
|
public static Specification<Document> hasTags(List<String> tags) {
|
||||||
return (root, query, cb) -> {
|
return (root, query, cb) -> {
|
||||||
if (tags == null || tags.isEmpty())
|
if (tags == null || tags.isEmpty())
|
||||||
@@ -72,15 +106,13 @@ public class DocumentSpecifications {
|
|||||||
for (String tagName : tags) {
|
for (String tagName : tags) {
|
||||||
if (!StringUtils.hasText(tagName)) continue;
|
if (!StringUtils.hasText(tagName)) continue;
|
||||||
|
|
||||||
// Subquery erstellen: "Gibt es für dieses Dokument (root.id) einen Tag mit dem Namen X?"
|
|
||||||
// Dies stellt sicher, dass ALLE Tags vorhanden sein müssen (AND Logik).
|
|
||||||
Subquery<Long> subquery = query.subquery(Long.class);
|
Subquery<Long> subquery = query.subquery(Long.class);
|
||||||
Root<Document> subRoot = subquery.from(Document.class);
|
Root<Document> subRoot = subquery.from(Document.class);
|
||||||
Join<Document, Tag> subTags = subRoot.join("tags");
|
Join<Document, Tag> subTags = subRoot.join("tags");
|
||||||
|
|
||||||
subquery.select(subRoot.get("id"))
|
subquery.select(subRoot.get("id"))
|
||||||
.where(
|
.where(
|
||||||
cb.equal(subRoot.get("id"), root.get("id")), // Korrelation zum Haupt-Query
|
cb.equal(subRoot.get("id"), root.get("id")),
|
||||||
cb.equal(cb.lower(subTags.get("name")), tagName.trim().toLowerCase())
|
cb.equal(cb.lower(subTags.get("name")), tagName.trim().toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -91,4 +123,25 @@ public class DocumentSpecifications {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtert nach partiellem Tag-Namen (ILIKE) — für Live-Tag-Suche
|
||||||
|
public static Specification<Document> hasTagPartial(String tagQ) {
|
||||||
|
return (root, query, cb) -> {
|
||||||
|
if (!StringUtils.hasText(tagQ))
|
||||||
|
return null;
|
||||||
|
String likePattern = "%" + tagQ.toLowerCase() + "%";
|
||||||
|
|
||||||
|
Subquery<Long> subquery = query.subquery(Long.class);
|
||||||
|
Root<Document> subRoot = subquery.from(Document.class);
|
||||||
|
Join<Document, Tag> tagJoin = subRoot.join("tags");
|
||||||
|
|
||||||
|
subquery.select(cb.literal(1L))
|
||||||
|
.where(
|
||||||
|
cb.equal(subRoot.get("id"), root.get("id")),
|
||||||
|
cb.like(cb.lower(tagJoin.get("name")), likePattern)
|
||||||
|
);
|
||||||
|
|
||||||
|
return cb.exists(subquery);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
@@ -26,10 +27,12 @@ import java.time.LocalDate;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -280,16 +283,78 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||||
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, DocumentStatus status) {
|
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir) {
|
||||||
Specification<Document> spec = Specification.where(hasText(text))
|
Specification<Document> spec = Specification.where(hasText(text))
|
||||||
.and(isBetween(from, to))
|
.and(isBetween(from, to))
|
||||||
.and(hasSender(sender))
|
.and(hasSender(sender))
|
||||||
.and(hasReceiver(receiver))
|
.and(hasReceiver(receiver))
|
||||||
.and(hasTags(tags))
|
.and(hasTags(tags))
|
||||||
|
.and(hasTagPartial(tagQ))
|
||||||
.and(hasStatus(status));
|
.and(hasStatus(status));
|
||||||
|
|
||||||
// Neueste zuerst (nach Erstellungsdatum)
|
// SENDER and RECEIVER are sorted in-memory because JPA's Sort.by("sender.lastName")
|
||||||
return documentRepository.findAll(spec, Sort.by(Sort.Direction.DESC, "createdAt"));
|
// generates an INNER JOIN that silently drops documents with null sender/receivers.
|
||||||
|
// TODO: replace with a native @Query using ORDER BY ... NULLS LAST when pagination is added.
|
||||||
|
if (sort == DocumentSort.RECEIVER) {
|
||||||
|
List<Document> results = documentRepository.findAll(spec);
|
||||||
|
return sortByFirstReceiver(results, dir);
|
||||||
|
}
|
||||||
|
if (sort == DocumentSort.SENDER) {
|
||||||
|
List<Document> results = documentRepository.findAll(spec);
|
||||||
|
return sortBySender(results, dir);
|
||||||
|
}
|
||||||
|
Sort springSort = resolveSort(sort, dir);
|
||||||
|
return documentRepository.findAll(spec, springSort);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Sort resolveSort(DocumentSort sort, String dir) {
|
||||||
|
Sort.Direction direction = "ASC".equalsIgnoreCase(dir) ? Sort.Direction.ASC : Sort.Direction.DESC;
|
||||||
|
if (sort == null || sort == DocumentSort.DATE) {
|
||||||
|
return Sort.by(direction, "documentDate");
|
||||||
|
}
|
||||||
|
// SENDER and RECEIVER are sorted in-memory before this method is called
|
||||||
|
return switch (sort) {
|
||||||
|
case TITLE -> Sort.by(direction, "title");
|
||||||
|
case UPLOAD_DATE -> Sort.by(direction, "createdAt");
|
||||||
|
default -> Sort.by(direction, "documentDate");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Document> sortBySender(List<Document> documents, String dir) {
|
||||||
|
boolean ascending = "ASC".equalsIgnoreCase(dir);
|
||||||
|
Comparator<String> nullSafeComparator = (a, b) -> {
|
||||||
|
if (a.isEmpty() && b.isEmpty()) return 0;
|
||||||
|
if (a.isEmpty()) return ascending ? 1 : -1;
|
||||||
|
if (b.isEmpty()) return ascending ? -1 : 1;
|
||||||
|
return ascending ? a.compareTo(b) : b.compareTo(a);
|
||||||
|
};
|
||||||
|
return documents.stream()
|
||||||
|
.sorted(Comparator.comparing(doc -> {
|
||||||
|
Person s = doc.getSender();
|
||||||
|
if (s == null || s.getLastName() == null) return "";
|
||||||
|
return s.getLastName() + " " + Objects.toString(s.getFirstName(), "");
|
||||||
|
}, nullSafeComparator))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Document> sortByFirstReceiver(List<Document> documents, String dir) {
|
||||||
|
boolean ascending = "ASC".equalsIgnoreCase(dir);
|
||||||
|
Comparator<String> nullSafeComparator = (a, b) -> {
|
||||||
|
if (a.isEmpty() && b.isEmpty()) return 0;
|
||||||
|
if (a.isEmpty()) return 1;
|
||||||
|
if (b.isEmpty()) return -1;
|
||||||
|
return ascending ? a.compareTo(b) : b.compareTo(a);
|
||||||
|
};
|
||||||
|
return documents.stream()
|
||||||
|
.sorted(Comparator.comparing(this::firstReceiverSortKey, nullSafeComparator))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstReceiverSortKey(Document doc) {
|
||||||
|
return doc.getReceivers().stream()
|
||||||
|
.min(Comparator.comparing(Person::getLastName).thenComparing(Person::getFirstName))
|
||||||
|
.map(p -> p.getLastName() + " " + p.getFirstName())
|
||||||
|
.orElse("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. SPEZIALITÄT: Der Schriftwechsel
|
// 2. SPEZIALITÄT: Der Schriftwechsel
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_returns200_whenAuthenticated() throws Exception {
|
void search_returns200_whenAuthenticated() throws Exception {
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
.thenReturn(Collections.emptyList());
|
.thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
@@ -68,13 +68,13 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_withStatusParam_passesItToService() throws Exception {
|
void search_withStatusParam_passesItToService() throws Exception {
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED)))
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any()))
|
||||||
.thenReturn(Collections.emptyList());
|
.thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED));
|
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -84,6 +84,32 @@ class DocumentControllerTest {
|
|||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_withInvalidDir_returns400() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/search").param("dir", "INVALID"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_withInvalidSort_returns400() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/search").param("sort", "GARBAGE"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_responseContainsTotalCount() throws Exception {
|
||||||
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
|
.thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.total").value(0))
|
||||||
|
.andExpect(jsonPath("$.documents").isArray());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── POST /api/documents ─────────────────────────────────────────────────
|
// ─── POST /api/documents ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -250,6 +250,62 @@ class DocumentSpecificationsTest {
|
|||||||
assertThat(result).isEmpty();
|
assertThat(result).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasText_findsByPartialSenderLastName() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasText("üller")));
|
||||||
|
assertThat(result).extracting(Document::getTitle)
|
||||||
|
.containsExactlyInAnyOrder("Alter Brief", "Neuerer Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasText_findsByPartialReceiverLastName() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasText("schmid")));
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasText_findsByPartialTagName() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasText("amili")));
|
||||||
|
assertThat(result).extracting(Document::getTitle)
|
||||||
|
.containsExactlyInAnyOrder("Alter Brief", "Familienfoto");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasText_doesNotProduceDuplicatesForDocumentWithMultipleReceivers() {
|
||||||
|
Person receiver2 = personRepository.save(Person.builder().firstName("Karl").lastName("Schmidt").build());
|
||||||
|
briefEarly.setReceivers(new java.util.HashSet<>(Set.of(receiver, receiver2)));
|
||||||
|
documentRepository.save(briefEarly);
|
||||||
|
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasText("schmid")));
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── hasTagPartial ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasTagPartial_returnsAllDocuments_whenTextIsNull() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasTagPartial(null)));
|
||||||
|
assertThat(result).hasSize(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasTagPartial_findsByPartialTagName() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasTagPartial("amili")));
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasTagPartial_isCaseInsensitive() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasTagPartial("URLAUB")));
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactly("Neuerer Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasTagPartial_returnsEmpty_whenNoTagMatches() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasTagPartial("xyz")));
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── hasStatus ────────────────────────────────────────────────────────────
|
// ─── hasStatus ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
|||||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
@@ -1199,7 +1200,7 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, DocumentStatus.REVIEWED);
|
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null);
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||||
}
|
}
|
||||||
@@ -1209,7 +1210,7 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null);
|
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null);
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||||
}
|
}
|
||||||
@@ -1273,4 +1274,66 @@ class DocumentServiceTest {
|
|||||||
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort));
|
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort));
|
||||||
verify(documentRepository, never()).findSinglePersonCorrespondence(any(), any(), any(), any());
|
verify(documentRepository, never()).findSinglePersonCorrespondence(any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── searchDocuments — SENDER sort includes documents with null sender ─────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_senderSort_includesDocumentsWithNullSender() {
|
||||||
|
Person alice = Person.builder().id(UUID.randomUUID()).firstName("Alice").lastName("Ziegler").build();
|
||||||
|
Document withSender = Document.builder().id(UUID.randomUUID()).title("Has Sender").sender(alice).build();
|
||||||
|
Document noSender = Document.builder().id(UUID.randomUUID()).title("No Sender").build();
|
||||||
|
|
||||||
|
// The repository returns both documents (no filtering by sender)
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||||
|
.thenReturn(List.of(withSender, noSender));
|
||||||
|
|
||||||
|
List<Document> result = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc");
|
||||||
|
|
||||||
|
assertThat(result).hasSize(2);
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_receiverSort_emptyReceiversSortsToEnd() {
|
||||||
|
Person alice = Person.builder().id(UUID.randomUUID()).firstName("Alice").lastName("Maier").build();
|
||||||
|
Document withReceiver = Document.builder().id(UUID.randomUUID()).title("Has Receiver")
|
||||||
|
.receivers(new HashSet<>(Set.of(alice))).build();
|
||||||
|
Document noReceivers = Document.builder().id(UUID.randomUUID()).title("No Receivers")
|
||||||
|
.receivers(new HashSet<>()).build();
|
||||||
|
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||||
|
.thenReturn(List.of(noReceivers, withReceiver));
|
||||||
|
|
||||||
|
List<Document> result = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc");
|
||||||
|
|
||||||
|
assertThat(result).extracting(Document::getTitle)
|
||||||
|
.containsExactly("Has Receiver", "No Receivers");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_senderSort_nullLastNameSortsToEnd() {
|
||||||
|
// Without fix: null lastName produces sort key "null Smith" which compares
|
||||||
|
// as 'n' (110) < 's' (115) and sorts BEFORE "smith" — wrong.
|
||||||
|
// With fix (Objects.toString → ""): key " Smith" sorts before real names but
|
||||||
|
// the sender-null-branch treats it as empty and places it at the end.
|
||||||
|
Person withRealName = Person.builder().id(UUID.randomUUID()).firstName("Alice").lastName("smith").build();
|
||||||
|
Person withNullLastName = Person.builder().id(UUID.randomUUID()).firstName("Bob").lastName(null).build();
|
||||||
|
|
||||||
|
Document docSmith = Document.builder().id(UUID.randomUUID()).title("smith doc").sender(withRealName).build();
|
||||||
|
Document docNullName = Document.builder().id(UUID.randomUUID()).title("Null lastname doc").sender(withNullLastName).build();
|
||||||
|
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||||
|
.thenReturn(List.of(docNullName, docSmith));
|
||||||
|
|
||||||
|
List<Document> result = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc");
|
||||||
|
|
||||||
|
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||||
|
assertThat(result).extracting(Document::getTitle)
|
||||||
|
.containsExactly("smith doc", "Null lastname doc");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,15 @@
|
|||||||
"login_label_username": "Benutzername",
|
"login_label_username": "Benutzername",
|
||||||
"login_label_password": "Passwort",
|
"login_label_password": "Passwort",
|
||||||
"login_btn_submit": "Anmelden",
|
"login_btn_submit": "Anmelden",
|
||||||
"docs_search_placeholder": "Suche in Titel, Inhalt, Ort...",
|
"docs_search_placeholder": "Titel, Personen, Tags durchsuchen…",
|
||||||
|
"docs_sort_label": "Sortierung",
|
||||||
|
"docs_sort_date": "Datum",
|
||||||
|
"docs_sort_title": "Titel",
|
||||||
|
"docs_sort_sender": "Absender",
|
||||||
|
"docs_sort_receiver": "Empfänger",
|
||||||
|
"docs_sort_upload": "Hochgeladen",
|
||||||
|
"docs_result_count": "{count} Dokumente",
|
||||||
|
"docs_empty_for_term": "Keine Dokumente für \"{term}\" gefunden",
|
||||||
"docs_btn_filter": "Filter",
|
"docs_btn_filter": "Filter",
|
||||||
"docs_btn_reset_title": "Filter zurücksetzen",
|
"docs_btn_reset_title": "Filter zurücksetzen",
|
||||||
"docs_filter_label_tags": "Schlagworte",
|
"docs_filter_label_tags": "Schlagworte",
|
||||||
@@ -174,7 +182,7 @@
|
|||||||
"admin_tags_warning": "Warnung: Umbenennen oder Löschen wirkt sich auf alle verknüpften Dokumente aus.",
|
"admin_tags_warning": "Warnung: Umbenennen oder Löschen wirkt sich auf alle verknüpften Dokumente aus.",
|
||||||
"admin_tags_list_title": "Alle Schlagworte",
|
"admin_tags_list_title": "Alle Schlagworte",
|
||||||
"admin_tags_empty": "Keine Schlagworte vorhanden.",
|
"admin_tags_empty": "Keine Schlagworte vorhanden.",
|
||||||
"admin_tags_select_prompt": "W\u00e4hle ein Schlagwort aus der Liste.",
|
"admin_tags_select_prompt": "Wähle ein Schlagwort aus der Liste.",
|
||||||
"admin_tag_edit_heading": "Schlagwort: {name}",
|
"admin_tag_edit_heading": "Schlagwort: {name}",
|
||||||
"admin_tag_updated": "Schlagwort umbenannt.",
|
"admin_tag_updated": "Schlagwort umbenannt.",
|
||||||
"admin_unsaved_warning": "Du hast ungespeicherte Änderungen – speichere oder verwerfe, bevor du wechselst.",
|
"admin_unsaved_warning": "Du hast ungespeicherte Änderungen – speichere oder verwerfe, bevor du wechselst.",
|
||||||
@@ -193,13 +201,13 @@
|
|||||||
"admin_user_delete_confirm": "Benutzer {username} wirklich löschen?",
|
"admin_user_delete_confirm": "Benutzer {username} wirklich löschen?",
|
||||||
"admin_btn_new_user": "Neuer Benutzer",
|
"admin_btn_new_user": "Neuer Benutzer",
|
||||||
"admin_users_list_title": "Alle Benutzer",
|
"admin_users_list_title": "Alle Benutzer",
|
||||||
"admin_users_search_placeholder": "Benutzer suchen\u2026",
|
"admin_users_search_placeholder": "Benutzer suchen…",
|
||||||
"admin_users_empty": "Keine Benutzer vorhanden.",
|
"admin_users_empty": "Keine Benutzer vorhanden.",
|
||||||
"admin_users_select_prompt": "W\u00e4hle einen Benutzer aus der Liste.",
|
"admin_users_select_prompt": "Wähle einen Benutzer aus der Liste.",
|
||||||
"admin_btn_new_group": "Neue Gruppe",
|
"admin_btn_new_group": "Neue Gruppe",
|
||||||
"admin_groups_list_title": "Alle Gruppen",
|
"admin_groups_list_title": "Alle Gruppen",
|
||||||
"admin_groups_empty": "Keine Gruppen vorhanden.",
|
"admin_groups_empty": "Keine Gruppen vorhanden.",
|
||||||
"admin_groups_select_prompt": "W\u00e4hle eine Gruppe aus der Liste.",
|
"admin_groups_select_prompt": "Wähle eine Gruppe aus der Liste.",
|
||||||
"admin_groups_permission_count": "{count} Berechtigungen",
|
"admin_groups_permission_count": "{count} Berechtigungen",
|
||||||
"admin_group_new_heading": "Neue Gruppe anlegen",
|
"admin_group_new_heading": "Neue Gruppe anlegen",
|
||||||
"admin_group_edit_heading": "Gruppe: {name}",
|
"admin_group_edit_heading": "Gruppe: {name}",
|
||||||
@@ -425,7 +433,7 @@
|
|||||||
"notification_load_more": "Ältere laden",
|
"notification_load_more": "Ältere laden",
|
||||||
"notification_empty_history": "Keine Benachrichtigungen",
|
"notification_empty_history": "Keine Benachrichtigungen",
|
||||||
"notification_empty_history_body": "Hier erscheinen Erwähnungen und Antworten auf deine Kommentare.",
|
"notification_empty_history_body": "Hier erscheinen Erwähnungen und Antworten auf deine Kommentare.",
|
||||||
"notification_row_aria": "{actor} {type} auf \u201e{title}\u201c \u2014 {time} \u2014 {readState}",
|
"notification_row_aria": "{actor} {type} auf „{title}“ — {time} — {readState}",
|
||||||
"notification_read_state_read": "gelesen",
|
"notification_read_state_read": "gelesen",
|
||||||
"notification_read_state_unread": "ungelesen",
|
"notification_read_state_unread": "ungelesen",
|
||||||
"error_transcription_block_not_found": "Der Transkriptionsblock wurde nicht gefunden.",
|
"error_transcription_block_not_found": "Der Transkriptionsblock wurde nicht gefunden.",
|
||||||
@@ -456,5 +464,7 @@
|
|||||||
"transcription_next_block_cta": "Markiere eine weitere Passage im Scan, um Block {number} anzulegen",
|
"transcription_next_block_cta": "Markiere eine weitere Passage im Scan, um Block {number} anzulegen",
|
||||||
"transcription_draw_tooltip": "Klicken und ziehen, um einen Textbereich zu markieren",
|
"transcription_draw_tooltip": "Klicken und ziehen, um einen Textbereich zu markieren",
|
||||||
"transcription_quote_stale": "Zitat aus älterer Version",
|
"transcription_quote_stale": "Zitat aus älterer Version",
|
||||||
"transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden"
|
"transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden",
|
||||||
|
"sort_dir_asc": "Aufsteigend sortieren",
|
||||||
|
"sort_dir_desc": "Absteigend sortieren"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,15 @@
|
|||||||
"login_label_username": "Username",
|
"login_label_username": "Username",
|
||||||
"login_label_password": "Password",
|
"login_label_password": "Password",
|
||||||
"login_btn_submit": "Sign in",
|
"login_btn_submit": "Sign in",
|
||||||
"docs_search_placeholder": "Search in title, content, location...",
|
"docs_search_placeholder": "Search title, people, tags…",
|
||||||
|
"docs_sort_label": "Sort",
|
||||||
|
"docs_sort_date": "Date",
|
||||||
|
"docs_sort_title": "Title",
|
||||||
|
"docs_sort_sender": "Sender",
|
||||||
|
"docs_sort_receiver": "Receiver",
|
||||||
|
"docs_sort_upload": "Uploaded",
|
||||||
|
"docs_result_count": "{count} documents",
|
||||||
|
"docs_empty_for_term": "No documents found for \"{term}\"",
|
||||||
"docs_btn_filter": "Filter",
|
"docs_btn_filter": "Filter",
|
||||||
"docs_btn_reset_title": "Reset filter",
|
"docs_btn_reset_title": "Reset filter",
|
||||||
"docs_filter_label_tags": "Tags",
|
"docs_filter_label_tags": "Tags",
|
||||||
@@ -193,7 +201,7 @@
|
|||||||
"admin_user_delete_confirm": "Really delete user {username}?",
|
"admin_user_delete_confirm": "Really delete user {username}?",
|
||||||
"admin_btn_new_user": "New User",
|
"admin_btn_new_user": "New User",
|
||||||
"admin_users_list_title": "All Users",
|
"admin_users_list_title": "All Users",
|
||||||
"admin_users_search_placeholder": "Search users\u2026",
|
"admin_users_search_placeholder": "Search users…",
|
||||||
"admin_users_empty": "No users found.",
|
"admin_users_empty": "No users found.",
|
||||||
"admin_users_select_prompt": "Select a user from the list.",
|
"admin_users_select_prompt": "Select a user from the list.",
|
||||||
"admin_btn_new_group": "New Group",
|
"admin_btn_new_group": "New Group",
|
||||||
@@ -456,5 +464,7 @@
|
|||||||
"transcription_next_block_cta": "Mark another passage on the scan to create block {number}",
|
"transcription_next_block_cta": "Mark another passage on the scan to create block {number}",
|
||||||
"transcription_draw_tooltip": "Click and drag to mark a text region",
|
"transcription_draw_tooltip": "Click and drag to mark a text region",
|
||||||
"transcription_quote_stale": "Quote from an older version",
|
"transcription_quote_stale": "Quote from an older version",
|
||||||
"transcription_block_conflict": "This block was changed by someone else — please reload"
|
"transcription_block_conflict": "This block was changed by someone else — please reload",
|
||||||
|
"sort_dir_asc": "Sort ascending",
|
||||||
|
"sort_dir_desc": "Sort descending"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,15 @@
|
|||||||
"login_label_username": "Usuario",
|
"login_label_username": "Usuario",
|
||||||
"login_label_password": "Contraseña",
|
"login_label_password": "Contraseña",
|
||||||
"login_btn_submit": "Iniciar sesión",
|
"login_btn_submit": "Iniciar sesión",
|
||||||
"docs_search_placeholder": "Buscar en título, contenido, lugar...",
|
"docs_search_placeholder": "Buscar título, personas, etiquetas…",
|
||||||
|
"docs_sort_label": "Ordenar",
|
||||||
|
"docs_sort_date": "Fecha",
|
||||||
|
"docs_sort_title": "Título",
|
||||||
|
"docs_sort_sender": "Remitente",
|
||||||
|
"docs_sort_receiver": "Destinatario",
|
||||||
|
"docs_sort_upload": "Subido",
|
||||||
|
"docs_result_count": "{count} documentos",
|
||||||
|
"docs_empty_for_term": "No se encontraron documentos para \"{term}\"",
|
||||||
"docs_btn_filter": "Filtrar",
|
"docs_btn_filter": "Filtrar",
|
||||||
"docs_btn_reset_title": "Restablecer filtro",
|
"docs_btn_reset_title": "Restablecer filtro",
|
||||||
"docs_filter_label_tags": "Etiquetas",
|
"docs_filter_label_tags": "Etiquetas",
|
||||||
@@ -193,7 +201,7 @@
|
|||||||
"admin_user_delete_confirm": "¿Realmente eliminar al usuario {username}?",
|
"admin_user_delete_confirm": "¿Realmente eliminar al usuario {username}?",
|
||||||
"admin_btn_new_user": "Nuevo usuario",
|
"admin_btn_new_user": "Nuevo usuario",
|
||||||
"admin_users_list_title": "Todos los usuarios",
|
"admin_users_list_title": "Todos los usuarios",
|
||||||
"admin_users_search_placeholder": "Buscar usuarios\u2026",
|
"admin_users_search_placeholder": "Buscar usuarios…",
|
||||||
"admin_users_empty": "No hay usuarios.",
|
"admin_users_empty": "No hay usuarios.",
|
||||||
"admin_users_select_prompt": "Selecciona un usuario de la lista.",
|
"admin_users_select_prompt": "Selecciona un usuario de la lista.",
|
||||||
"admin_btn_new_group": "Nuevo grupo",
|
"admin_btn_new_group": "Nuevo grupo",
|
||||||
@@ -205,7 +213,7 @@
|
|||||||
"admin_group_edit_heading": "Grupo: {name}",
|
"admin_group_edit_heading": "Grupo: {name}",
|
||||||
"admin_group_updated": "Grupo guardado.",
|
"admin_group_updated": "Grupo guardado.",
|
||||||
"admin_group_created": "Grupo creado.",
|
"admin_group_created": "Grupo creado.",
|
||||||
"admin_groups_section_standard": "Est\u00e1ndar",
|
"admin_groups_section_standard": "Estándar",
|
||||||
"admin_groups_section_administrative": "Administrativo",
|
"admin_groups_section_administrative": "Administrativo",
|
||||||
"admin_perm_read_all": "Solo lectura",
|
"admin_perm_read_all": "Solo lectura",
|
||||||
"admin_perm_annotate_all": "Leer y anotar",
|
"admin_perm_annotate_all": "Leer y anotar",
|
||||||
@@ -456,5 +464,7 @@
|
|||||||
"transcription_next_block_cta": "Marque otro pasaje en el escaneo para crear el bloque {number}",
|
"transcription_next_block_cta": "Marque otro pasaje en el escaneo para crear el bloque {number}",
|
||||||
"transcription_draw_tooltip": "Haga clic y arrastre para marcar una región de texto",
|
"transcription_draw_tooltip": "Haga clic y arrastre para marcar una región de texto",
|
||||||
"transcription_quote_stale": "Cita de una versión anterior",
|
"transcription_quote_stale": "Cita de una versión anterior",
|
||||||
"transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue"
|
"transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue",
|
||||||
|
"sort_dir_asc": "Ordenar ascendente",
|
||||||
|
"sort_dir_desc": "Ordenar descendente"
|
||||||
}
|
}
|
||||||
|
|||||||
52
frontend/src/lib/components/SortDropdown.svelte
Normal file
52
frontend/src/lib/components/SortDropdown.svelte
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sort: string;
|
||||||
|
dir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { sort = $bindable(), dir = $bindable() }: Props = $props();
|
||||||
|
|
||||||
|
function toggleDir() {
|
||||||
|
dir = dir === 'asc' ? 'desc' : 'asc';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="inline-flex items-stretch">
|
||||||
|
<label for="sort-field" class="sr-only">{m.docs_sort_label()}</label>
|
||||||
|
<div class="relative">
|
||||||
|
<select
|
||||||
|
id="sort-field"
|
||||||
|
bind:value={sort}
|
||||||
|
class="appearance-none border border-line bg-muted py-2.5 pr-9 pl-4 text-sm font-bold text-ink-2 transition hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
<option value="DATE">{m.docs_sort_date()}</option>
|
||||||
|
<option value="TITLE">{m.docs_sort_title()}</option>
|
||||||
|
<option value="SENDER">{m.docs_sort_sender()}</option>
|
||||||
|
<option value="RECEIVER">{m.docs_sort_receiver()}</option>
|
||||||
|
<option value="UPLOAD_DATE">{m.docs_sort_upload()}</option>
|
||||||
|
</select>
|
||||||
|
<svg
|
||||||
|
class="pointer-events-none absolute top-1/2 right-2.5 h-4 w-4 -translate-y-1/2 text-ink-2"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={toggleDir}
|
||||||
|
class="-ml-px flex items-center justify-center border border-line bg-muted px-3 py-2.5 text-sm font-bold text-ink-2 transition hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
aria-label={dir === 'asc' ? m.sort_dir_asc() : m.sort_dir_desc()}
|
||||||
|
>
|
||||||
|
{dir === 'asc' ? '↑' : '↓'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
36
frontend/src/lib/components/SortDropdown.svelte.spec.ts
Normal file
36
frontend/src/lib/components/SortDropdown.svelte.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { page } from '@vitest/browser/context';
|
||||||
|
import SortDropdown from './SortDropdown.svelte';
|
||||||
|
|
||||||
|
describe('SortDropdown', () => {
|
||||||
|
it('renders a select with all sort options', async () => {
|
||||||
|
render(SortDropdown, { sort: 'DATE', dir: 'desc' });
|
||||||
|
const select = page.getByRole('combobox');
|
||||||
|
await expect.element(select).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the current sort value as selected', async () => {
|
||||||
|
render(SortDropdown, { sort: 'TITLE', dir: 'asc' });
|
||||||
|
const select = page.getByRole('combobox');
|
||||||
|
await expect.element(select).toHaveValue('TITLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders direction toggle button', async () => {
|
||||||
|
render(SortDropdown, { sort: 'DATE', dir: 'asc' });
|
||||||
|
const btn = page.getByRole('button');
|
||||||
|
await expect.element(btn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('direction button shows ↑ when dir is asc', async () => {
|
||||||
|
render(SortDropdown, { sort: 'DATE', dir: 'asc' });
|
||||||
|
const btn = page.getByRole('button');
|
||||||
|
await expect.element(btn).toHaveTextContent('↑');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('direction button shows ↓ when dir is desc', async () => {
|
||||||
|
render(SortDropdown, { sort: 'DATE', dir: 'desc' });
|
||||||
|
const btn = page.getByRole('button');
|
||||||
|
await expect.element(btn).toHaveTextContent('↓');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,9 +6,10 @@ import { clickOutside } from '$lib/actions/clickOutside';
|
|||||||
interface Props {
|
interface Props {
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
allowCreation?: boolean;
|
allowCreation?: boolean;
|
||||||
|
onTextInput?: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { tags = $bindable([]), allowCreation = true }: Props = $props();
|
let { tags = $bindable([]), allowCreation = true, onTextInput }: Props = $props();
|
||||||
|
|
||||||
let inputVal = $state('');
|
let inputVal = $state('');
|
||||||
let suggestions: string[] = $state([]);
|
let suggestions: string[] = $state([]);
|
||||||
@@ -43,6 +44,7 @@ function addTag(tag: string) {
|
|||||||
suggestions = [];
|
suggestions = [];
|
||||||
showSuggestions = false;
|
showSuggestions = false;
|
||||||
activeIndex = -1;
|
activeIndex = -1;
|
||||||
|
onTextInput?.('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTag(index: number) {
|
function removeTag(index: number) {
|
||||||
@@ -101,7 +103,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={inputVal}
|
bind:value={inputVal}
|
||||||
oninput={() => fetchSuggestions(inputVal)}
|
oninput={() => { fetchSuggestions(inputVal); onTextInput?.(inputVal); }}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
onfocus={() => fetchSuggestions(inputVal)}
|
onfocus={() => fetchSuggestions(inputVal)}
|
||||||
placeholder={tags.length === 0
|
placeholder={tags.length === 0
|
||||||
|
|||||||
@@ -208,3 +208,36 @@ describe('TagInput – autocomplete', () => {
|
|||||||
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
|
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── onTextInput callback ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('TagInput – onTextInput callback', () => {
|
||||||
|
it('calls onTextInput with the current value on every input event', async () => {
|
||||||
|
mockFetchEmpty();
|
||||||
|
const onTextInput = vi.fn();
|
||||||
|
render(TagInput, { tags: [], allowCreation: false, onTextInput });
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await input.fill('fa');
|
||||||
|
await expect.poll(() => onTextInput.mock.calls.length).toBeGreaterThan(0);
|
||||||
|
expect(onTextInput).toHaveBeenCalledWith('fa');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when onTextInput is not provided', async () => {
|
||||||
|
mockFetchEmpty();
|
||||||
|
render(TagInput, { tags: [], allowCreation: false });
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await expect(input.fill('fa')).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onTextInput with empty string when a tag chip is added', async () => {
|
||||||
|
mockFetchWithTags(['Kaufvertrag']);
|
||||||
|
const onTextInput = vi.fn();
|
||||||
|
render(TagInput, { tags: [], allowCreation: false, onTextInput });
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await input.fill('Ka');
|
||||||
|
await waitForDebounce();
|
||||||
|
const option = page.getByRole('option', { name: 'Kaufvertrag' });
|
||||||
|
await option.click();
|
||||||
|
await expect.poll(() => onTextInput.mock.calls.at(-1)).toEqual(['']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -100,6 +100,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/documents/{documentId}/transcription-blocks/{blockId}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getBlock"];
|
||||||
|
put: operations["updateBlock"];
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deleteBlock"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/documents/{documentId}/transcription-blocks/reorder": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put: operations["reorderBlocks"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/users": {
|
"/api/users": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -212,6 +244,54 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/documents/{documentId}/transcription-blocks": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listBlocks"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["createBlock"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/documents/{documentId}/transcription-blocks/{blockId}/comments": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getBlockComments"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["postBlockComment"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/documents/{documentId}/transcription-blocks/{blockId}/comments/{commentId}/replies": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["replyToBlockComment"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/documents/{documentId}/comments": {
|
"/api/documents/{documentId}/comments": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -628,6 +708,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/documents/{documentId}/transcription-blocks/{blockId}/history": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getBlockHistory"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/documents/search": {
|
"/api/documents/search": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -724,8 +820,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
// "/api/auth/reset-token-for-test" removed — @Operation(hidden=true) on AuthE2EController.
|
|
||||||
// Regenerate with `npm run generate:api` after the next backend build to keep in sync.
|
|
||||||
"/api/admin/import-status": {
|
"/api/admin/import-status": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -876,6 +970,35 @@ export interface components {
|
|||||||
sender?: components["schemas"]["Person"];
|
sender?: components["schemas"]["Person"];
|
||||||
tags?: components["schemas"]["Tag"][];
|
tags?: components["schemas"]["Tag"][];
|
||||||
};
|
};
|
||||||
|
UpdateTranscriptionBlockDTO: {
|
||||||
|
text?: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
TranscriptionBlock: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
annotationId: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
documentId: string;
|
||||||
|
text: string;
|
||||||
|
label?: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
sortOrder: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
version: number;
|
||||||
|
/** Format: uuid */
|
||||||
|
createdBy?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
updatedBy?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
ReorderTranscriptionBlocksDTO: {
|
||||||
|
blockIds?: string[];
|
||||||
|
};
|
||||||
CreateUserRequest: {
|
CreateUserRequest: {
|
||||||
username?: string;
|
username?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -895,6 +1018,20 @@ export interface components {
|
|||||||
name?: string;
|
name?: string;
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
};
|
};
|
||||||
|
CreateTranscriptionBlockDTO: {
|
||||||
|
/** Format: int32 */
|
||||||
|
pageNumber?: number;
|
||||||
|
/** Format: double */
|
||||||
|
x?: number;
|
||||||
|
/** Format: double */
|
||||||
|
y?: number;
|
||||||
|
/** Format: double */
|
||||||
|
width?: number;
|
||||||
|
/** Format: double */
|
||||||
|
height?: number;
|
||||||
|
text?: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
CreateCommentDTO: {
|
CreateCommentDTO: {
|
||||||
content?: string;
|
content?: string;
|
||||||
mentionedUserIds?: string[];
|
mentionedUserIds?: string[];
|
||||||
@@ -907,6 +1044,8 @@ export interface components {
|
|||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
annotationId?: string;
|
annotationId?: string;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
|
blockId?: string;
|
||||||
|
/** Format: uuid */
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
authorId?: string;
|
authorId?: string;
|
||||||
@@ -1038,18 +1177,18 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
number?: number;
|
number?: number;
|
||||||
sort?: components["schemas"]["SortObject"];
|
sort?: components["schemas"]["SortObject"];
|
||||||
/** Format: int32 */
|
|
||||||
numberOfElements?: number;
|
|
||||||
first?: boolean;
|
first?: boolean;
|
||||||
last?: boolean;
|
last?: boolean;
|
||||||
|
/** Format: int32 */
|
||||||
|
numberOfElements?: number;
|
||||||
empty?: boolean;
|
empty?: boolean;
|
||||||
};
|
};
|
||||||
PageableObject: {
|
PageableObject: {
|
||||||
|
paged?: boolean;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
pageNumber?: number;
|
pageNumber?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
paged?: boolean;
|
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
offset?: number;
|
offset?: number;
|
||||||
sort?: components["schemas"]["SortObject"];
|
sort?: components["schemas"]["SortObject"];
|
||||||
@@ -1085,6 +1224,22 @@ export interface components {
|
|||||||
snapshot: string;
|
snapshot: string;
|
||||||
changedFields: string;
|
changedFields: string;
|
||||||
};
|
};
|
||||||
|
TranscriptionBlockVersion: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
blockId: string;
|
||||||
|
text: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
changedBy?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
changedAt: string;
|
||||||
|
};
|
||||||
|
DocumentSearchResult: {
|
||||||
|
documents?: components["schemas"]["Document"][];
|
||||||
|
/** Format: int64 */
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
IncompleteDocumentDTO: {
|
IncompleteDocumentDTO: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1419,6 +1574,103 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getBlock: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
blockId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["TranscriptionBlock"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateBlock: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
blockId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UpdateTranscriptionBlockDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["TranscriptionBlock"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deleteBlock: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
blockId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description No Content */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
reorderBlocks: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ReorderTranscriptionBlocksDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["TranscriptionBlock"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
getAllUsers: {
|
getAllUsers: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1643,6 +1895,130 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listBlocks: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["TranscriptionBlock"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createBlock: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateTranscriptionBlockDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["TranscriptionBlock"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getBlockComments: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
blockId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
postBlockComment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
blockId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
replyToBlockComment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
commentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
getDocumentComments: {
|
getDocumentComments: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2356,6 +2732,29 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getBlockHistory: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
blockId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["TranscriptionBlockVersion"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
search_1: {
|
search_1: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@@ -2365,8 +2764,13 @@ export interface operations {
|
|||||||
senderId?: string;
|
senderId?: string;
|
||||||
receiverId?: string;
|
receiverId?: string;
|
||||||
tag?: string[];
|
tag?: string[];
|
||||||
|
tagQ?: string;
|
||||||
/** @description Filter by document status */
|
/** @description Filter by document status */
|
||||||
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||||
|
/** @description Sort field */
|
||||||
|
sort?: "DATE" | "TITLE" | "SENDER" | "RECEIVER" | "UPLOAD_DATE";
|
||||||
|
/** @description Sort direction: ASC or DESC */
|
||||||
|
dir?: string;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -2380,7 +2784,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"*/*": components["schemas"]["Document"][];
|
"*/*": components["schemas"]["DocumentSearchResult"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -2500,7 +2904,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
// getResetTokenForTest removed — @Operation(hidden=true) on AuthE2EController.
|
|
||||||
importStatus: {
|
importStatus: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
69
frontend/src/lib/utils/debounce.spec.ts
Normal file
69
frontend/src/lib/utils/debounce.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { debounce } from './debounce';
|
||||||
|
|
||||||
|
describe('debounce', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire before the delay has elapsed', () => {
|
||||||
|
const fn = vi.fn();
|
||||||
|
const debounced = debounce(fn, 200);
|
||||||
|
|
||||||
|
debounced();
|
||||||
|
vi.advanceTimersByTime(199);
|
||||||
|
|
||||||
|
expect(fn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires exactly once after the delay', () => {
|
||||||
|
const fn = vi.fn();
|
||||||
|
const debounced = debounce(fn, 200);
|
||||||
|
|
||||||
|
debounced();
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
|
||||||
|
expect(fn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the timer on each call — fires only once after inactivity', () => {
|
||||||
|
const fn = vi.fn();
|
||||||
|
const debounced = debounce(fn, 200);
|
||||||
|
|
||||||
|
debounced();
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
debounced();
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
debounced();
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
|
||||||
|
expect(fn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the latest arguments to the callback', () => {
|
||||||
|
const fn = vi.fn();
|
||||||
|
const debounced = debounce(fn, 200);
|
||||||
|
|
||||||
|
debounced('first');
|
||||||
|
debounced('second');
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
|
||||||
|
expect(fn).toHaveBeenCalledWith('second');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can fire again after the first invocation settles', () => {
|
||||||
|
const fn = vi.fn();
|
||||||
|
const debounced = debounce(fn, 200);
|
||||||
|
|
||||||
|
debounced();
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
debounced();
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
|
||||||
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
12
frontend/src/lib/utils/debounce.ts
Normal file
12
frontend/src/lib/utils/debounce.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Returns a debounced version of fn that delays invocation until after
|
||||||
|
* `delay` ms have elapsed since the last call.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): T {
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
return ((...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => fn(...args), delay);
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
@@ -13,8 +13,11 @@ export async function load({ url, fetch }) {
|
|||||||
const senderId = url.searchParams.get('senderId') || '';
|
const senderId = url.searchParams.get('senderId') || '';
|
||||||
const receiverId = url.searchParams.get('receiverId') || '';
|
const receiverId = url.searchParams.get('receiverId') || '';
|
||||||
const tags = url.searchParams.getAll('tag');
|
const tags = url.searchParams.getAll('tag');
|
||||||
|
const sort = url.searchParams.get('sort') || 'DATE';
|
||||||
|
const dir = url.searchParams.get('dir') || 'desc';
|
||||||
|
const tagQ = url.searchParams.get('tagQ') || '';
|
||||||
|
|
||||||
const isDashboard = !q && !from && !to && !senderId && !receiverId && !tags.length;
|
const isDashboard = !q && !from && !to && !senderId && !receiverId && !tags.length && !tagQ;
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
@@ -30,7 +33,10 @@ export async function load({ url, fetch }) {
|
|||||||
to: to || undefined,
|
to: to || undefined,
|
||||||
senderId: senderId || undefined,
|
senderId: senderId || undefined,
|
||||||
receiverId: receiverId || undefined,
|
receiverId: receiverId || undefined,
|
||||||
tag: tags.length ? tags : undefined
|
tag: tags.length ? tags : undefined,
|
||||||
|
tagQ: tagQ || undefined,
|
||||||
|
sort: sort as 'DATE' | 'TITLE' | 'SENDER' | 'RECEIVER' | 'UPLOAD_DATE',
|
||||||
|
dir: dir || undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -44,7 +50,9 @@ export async function load({ url, fetch }) {
|
|||||||
throw redirect(302, '/login');
|
throw redirect(302, '/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const documents: Document[] = docsResult?.data ?? [];
|
const searchResult = docsResult?.data as { documents?: Document[]; total?: number } | null;
|
||||||
|
const documents: Document[] = searchResult?.documents ?? [];
|
||||||
|
const total: number = searchResult?.total ?? 0;
|
||||||
const allPersons = (personsResult.data ?? []) as {
|
const allPersons = (personsResult.data ?? []) as {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@@ -80,6 +88,7 @@ export async function load({ url, fetch }) {
|
|||||||
return {
|
return {
|
||||||
isDashboard,
|
isDashboard,
|
||||||
documents,
|
documents,
|
||||||
|
total,
|
||||||
stats,
|
stats,
|
||||||
incompleteDocs,
|
incompleteDocs,
|
||||||
recentDocs,
|
recentDocs,
|
||||||
@@ -87,7 +96,7 @@ export async function load({ url, fetch }) {
|
|||||||
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
||||||
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
|
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}` : ''
|
||||||
},
|
},
|
||||||
filters: { q, from, to, senderId, receiverId, tags },
|
filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ },
|
||||||
error: null as string | null
|
error: null as string | null
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -96,11 +105,12 @@ export async function load({ url, fetch }) {
|
|||||||
return {
|
return {
|
||||||
isDashboard,
|
isDashboard,
|
||||||
documents: [],
|
documents: [],
|
||||||
|
total: 0,
|
||||||
stats: null,
|
stats: null,
|
||||||
incompleteDocs: [],
|
incompleteDocs: [],
|
||||||
recentDocs: [],
|
recentDocs: [],
|
||||||
initialValues: { senderName: '', receiverName: '' },
|
initialValues: { senderName: '', receiverName: '' },
|
||||||
filters: { q, from, to, senderId, receiverId, tags },
|
filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ },
|
||||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { navigating } from '$app/state';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||||
import SearchFilterBar from './SearchFilterBar.svelte';
|
import SearchFilterBar from './SearchFilterBar.svelte';
|
||||||
@@ -19,6 +20,9 @@ let to = $state(untrack(() => data.filters?.to || ''));
|
|||||||
let senderId = $state(untrack(() => data.filters?.senderId || ''));
|
let senderId = $state(untrack(() => data.filters?.senderId || ''));
|
||||||
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
||||||
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
|
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
|
||||||
|
let sort = $state(untrack(() => data.filters?.sort || 'DATE'));
|
||||||
|
let dir = $state(untrack(() => data.filters?.dir || 'desc'));
|
||||||
|
let tagQ = $state(untrack(() => data.filters?.tagQ || ''));
|
||||||
|
|
||||||
const hasAdvancedFilters = (filters: typeof data.filters) =>
|
const hasAdvancedFilters = (filters: typeof data.filters) =>
|
||||||
(filters?.tags?.length ?? 0) > 0 ||
|
(filters?.tags?.length ?? 0) > 0 ||
|
||||||
@@ -39,6 +43,9 @@ function triggerSearch() {
|
|||||||
if (senderId) params.set('senderId', senderId);
|
if (senderId) params.set('senderId', senderId);
|
||||||
if (receiverId) params.set('receiverId', receiverId);
|
if (receiverId) params.set('receiverId', receiverId);
|
||||||
if (tagNames) tagNames.forEach((tag) => params.append('tag', tag));
|
if (tagNames) tagNames.forEach((tag) => params.append('tag', tag));
|
||||||
|
if (sort) params.set('sort', sort);
|
||||||
|
if (dir) params.set('dir', dir);
|
||||||
|
if (tagQ) params.set('tagQ', tagQ);
|
||||||
goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +73,9 @@ $effect(() => {
|
|||||||
senderId = data.filters?.senderId || '';
|
senderId = data.filters?.senderId || '';
|
||||||
receiverId = data.filters?.receiverId || '';
|
receiverId = data.filters?.receiverId || '';
|
||||||
tagNames = data.filters?.tags || [];
|
tagNames = data.filters?.tags || [];
|
||||||
|
sort = data.filters?.sort || 'DATE';
|
||||||
|
dir = data.filters?.dir || 'desc';
|
||||||
|
tagQ = data.filters?.tagQ || '';
|
||||||
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
|
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,8 +98,12 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
|
|||||||
bind:receiverId={receiverId}
|
bind:receiverId={receiverId}
|
||||||
bind:tagNames={tagNames}
|
bind:tagNames={tagNames}
|
||||||
bind:showAdvanced={showAdvanced}
|
bind:showAdvanced={showAdvanced}
|
||||||
|
bind:sort={sort}
|
||||||
|
bind:dir={dir}
|
||||||
|
bind:tagQ={tagQ}
|
||||||
initialSenderName={data.initialValues?.senderName}
|
initialSenderName={data.initialValues?.senderName}
|
||||||
initialReceiverName={data.initialValues?.receiverName}
|
initialReceiverName={data.initialValues?.receiverName}
|
||||||
|
isLoading={navigating.to !== null}
|
||||||
onSearch={handleTextSearch}
|
onSearch={handleTextSearch}
|
||||||
onfocus={() => (qFocused = true)}
|
onfocus={() => (qFocused = true)}
|
||||||
onblur={() => (qFocused = false)}
|
onblur={() => (qFocused = false)}
|
||||||
@@ -119,6 +133,12 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
|
|||||||
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
|
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />
|
<DocumentList
|
||||||
|
documents={data.documents ?? []}
|
||||||
|
canWrite={data.canWrite}
|
||||||
|
error={data.error}
|
||||||
|
total={data.total ?? 0}
|
||||||
|
q={q}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { formatDate } from '$lib/utils/date';
|
|||||||
let {
|
let {
|
||||||
documents,
|
documents,
|
||||||
canWrite,
|
canWrite,
|
||||||
error
|
error,
|
||||||
|
total = 0,
|
||||||
|
q = ''
|
||||||
}: {
|
}: {
|
||||||
documents: {
|
documents: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,6 +22,8 @@ let {
|
|||||||
}[];
|
}[];
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
total?: number;
|
||||||
|
q?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -41,6 +45,11 @@ let {
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
||||||
|
|
||||||
<!-- DOCUMENT LIST -->
|
<!-- DOCUMENT LIST -->
|
||||||
<div class="border border-line bg-surface shadow-sm">
|
<div class="border border-line bg-surface shadow-sm">
|
||||||
{#if error}
|
{#if error}
|
||||||
@@ -162,7 +171,7 @@ let {
|
|||||||
</div>
|
</div>
|
||||||
<h3 class="font-serif text-lg font-medium text-ink">{m.docs_empty_heading()}</h3>
|
<h3 class="font-serif text-lg font-medium text-ink">{m.docs_empty_heading()}</h3>
|
||||||
<p class="mt-1 font-sans text-sm text-ink-2">
|
<p class="mt-1 font-sans text-sm text-ink-2">
|
||||||
{m.docs_empty_text()}
|
{q ? m.docs_empty_for_term({ term: q }) : m.docs_empty_text()}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onclick={() => goto('/')}
|
onclick={() => goto('/')}
|
||||||
|
|||||||
51
frontend/src/routes/DocumentList.svelte.spec.ts
Normal file
51
frontend/src/routes/DocumentList.svelte.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import DocumentList from './DocumentList.svelte';
|
||||||
|
|
||||||
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
documents: [],
|
||||||
|
canWrite: false,
|
||||||
|
error: null,
|
||||||
|
total: 0,
|
||||||
|
q: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeDoc = () => ({
|
||||||
|
id: '1',
|
||||||
|
title: 'Testbrief',
|
||||||
|
originalFilename: 'testbrief.pdf',
|
||||||
|
status: 'UPLOADED' as const,
|
||||||
|
documentDate: '2024-03-15',
|
||||||
|
location: null,
|
||||||
|
sender: null,
|
||||||
|
receivers: [],
|
||||||
|
tags: []
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DocumentList – result count', () => {
|
||||||
|
it('shows result count when total > 0', async () => {
|
||||||
|
render(DocumentList, { ...baseProps, documents: [makeDoc()], total: 1, q: 'test' });
|
||||||
|
await expect.element(page.getByText('1 Dokumente')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show result count when total is 0 and there is no error', async () => {
|
||||||
|
render(DocumentList, { ...baseProps, total: 0, q: '' });
|
||||||
|
const count = page.getByText(/\d+ Dokumente/);
|
||||||
|
await expect.element(count).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DocumentList – empty state with search term', () => {
|
||||||
|
it('shows generic empty heading when q is empty', async () => {
|
||||||
|
render(DocumentList, { ...baseProps });
|
||||||
|
await expect.element(page.getByText(/Keine Dokumente/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows search term in empty state when q is set', async () => {
|
||||||
|
render(DocumentList, { ...baseProps, q: 'Urlaub' });
|
||||||
|
await expect.element(page.getByText(/"Urlaub"/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
import TagInput from '$lib/components/TagInput.svelte';
|
import TagInput from '$lib/components/TagInput.svelte';
|
||||||
|
import SortDropdown from '$lib/components/SortDropdown.svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
@@ -11,9 +12,13 @@ let {
|
|||||||
senderId = $bindable(''),
|
senderId = $bindable(''),
|
||||||
receiverId = $bindable(''),
|
receiverId = $bindable(''),
|
||||||
tagNames = $bindable<string[]>([]),
|
tagNames = $bindable<string[]>([]),
|
||||||
|
tagQ = $bindable(''),
|
||||||
|
sort = $bindable('DATE'),
|
||||||
|
dir = $bindable('desc'),
|
||||||
showAdvanced = $bindable(false),
|
showAdvanced = $bindable(false),
|
||||||
initialSenderName = '',
|
initialSenderName = '',
|
||||||
initialReceiverName = '',
|
initialReceiverName = '',
|
||||||
|
isLoading = false,
|
||||||
onSearch,
|
onSearch,
|
||||||
onfocus,
|
onfocus,
|
||||||
onblur
|
onblur
|
||||||
@@ -24,13 +29,32 @@ let {
|
|||||||
senderId?: string;
|
senderId?: string;
|
||||||
receiverId?: string;
|
receiverId?: string;
|
||||||
tagNames?: string[];
|
tagNames?: string[];
|
||||||
|
tagQ?: string;
|
||||||
|
sort?: string;
|
||||||
|
dir?: string;
|
||||||
showAdvanced?: boolean;
|
showAdvanced?: boolean;
|
||||||
initialSenderName?: string;
|
initialSenderName?: string;
|
||||||
initialReceiverName?: string;
|
initialReceiverName?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
onSearch: () => void;
|
onSearch: () => void;
|
||||||
onfocus?: () => void;
|
onfocus?: () => void;
|
||||||
onblur?: () => void;
|
onblur?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
// Plain (non-reactive) flag — not $state, so no reactive assignment inside $effect
|
||||||
|
let sortDirMounted = false;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Read sort and dir so Svelte tracks them as dependencies of this effect.
|
||||||
|
// `void` suppresses the ESLint no-unused-expressions rule for bare variable reads.
|
||||||
|
void sort;
|
||||||
|
void dir;
|
||||||
|
if (!sortDirMounted) {
|
||||||
|
sortDirMounted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSearch();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-8 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
<div class="mb-8 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
@@ -49,15 +73,37 @@ let {
|
|||||||
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
/>
|
/>
|
||||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||||
<img
|
{#if isLoading}
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
<svg
|
||||||
alt=""
|
role="status"
|
||||||
aria-hidden="true"
|
aria-label="Suche läuft…"
|
||||||
class="h-4 w-4 opacity-40"
|
class="h-4 w-4 animate-spin text-ink-3"
|
||||||
/>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Mag-Glass-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4 opacity-40"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sort Dropdown -->
|
||||||
|
<SortDropdown bind:sort={sort} bind:dir={dir} />
|
||||||
|
|
||||||
<!-- Toggle Advanced Button -->
|
<!-- Toggle Advanced Button -->
|
||||||
<button
|
<button
|
||||||
onclick={() => (showAdvanced = !showAdvanced)}
|
onclick={() => (showAdvanced = !showAdvanced)}
|
||||||
@@ -98,7 +144,14 @@ let {
|
|||||||
<p class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase">
|
<p class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||||
{m.docs_filter_label_tags()}
|
{m.docs_filter_label_tags()}
|
||||||
</p>
|
</p>
|
||||||
<TagInput bind:tags={tagNames} allowCreation={false} />
|
<TagInput
|
||||||
|
bind:tags={tagNames}
|
||||||
|
allowCreation={false}
|
||||||
|
onTextInput={(text) => {
|
||||||
|
tagQ = text;
|
||||||
|
onSearch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sender -->
|
<!-- Sender -->
|
||||||
|
|||||||
60
frontend/src/routes/SearchFilterBar.svelte.spec.ts
Normal file
60
frontend/src/routes/SearchFilterBar.svelte.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import SearchFilterBar from './SearchFilterBar.svelte';
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
onSearch: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SearchFilterBar – sort controls', () => {
|
||||||
|
it('renders a sort select when sort and dir are provided', async () => {
|
||||||
|
render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc' });
|
||||||
|
const select = page.getByRole('combobox');
|
||||||
|
await expect.element(select).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects the active sort value in the select', async () => {
|
||||||
|
render(SearchFilterBar, { ...defaultProps, sort: 'TITLE', dir: 'asc' });
|
||||||
|
const select = page.getByRole('combobox');
|
||||||
|
await expect.element(select).toHaveValue('TITLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders direction toggle button', async () => {
|
||||||
|
render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'asc' });
|
||||||
|
const btn = page.getByRole('button', { name: /sortieren/i });
|
||||||
|
await expect.element(btn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SearchFilterBar – loading spinner', () => {
|
||||||
|
it('shows search icon when isLoading is false', async () => {
|
||||||
|
render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc', isLoading: false });
|
||||||
|
const spinner = page.getByRole('status');
|
||||||
|
await expect.element(spinner).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows spinner and hides search icon when isLoading is true', async () => {
|
||||||
|
render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc', isLoading: true });
|
||||||
|
const spinner = page.getByRole('status');
|
||||||
|
await expect.element(spinner).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SearchFilterBar – tagQ live filter', () => {
|
||||||
|
it('calls onSearch when tag text changes in TagInput', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
|
||||||
|
);
|
||||||
|
const onSearch = vi.fn();
|
||||||
|
render(SearchFilterBar, { ...defaultProps, onSearch, sort: 'DATE', dir: 'desc' });
|
||||||
|
// TagInput is only visible in advanced panel — open it first
|
||||||
|
const filterBtn = page.getByRole('button', { name: 'Filter', exact: true });
|
||||||
|
await filterBtn.click();
|
||||||
|
const tagTextbox = page.getByPlaceholder('Nach Schlagworten filtern...');
|
||||||
|
await tagTextbox.fill('fam');
|
||||||
|
await expect.poll(() => onSearch.mock.calls.length).toBeGreaterThan(0);
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -123,7 +123,10 @@ describe('home page load — search mode', () => {
|
|||||||
it('sets isDashboard false and skips widget APIs when q is set', async () => {
|
it('sets isDashboard false and skips widget APIs when q is set', async () => {
|
||||||
const mockGet = vi
|
const mockGet = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [{ id: 'd1' }] }) // search docs
|
.mockResolvedValueOnce({
|
||||||
|
response: { ok: true, status: 200 },
|
||||||
|
data: { documents: [{ id: 'd1' }], total: 1 }
|
||||||
|
}) // search docs
|
||||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // persons
|
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // persons
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
@@ -146,7 +149,10 @@ describe('home page load — search mode', () => {
|
|||||||
it('passes search params from the URL to the documents API', async () => {
|
it('passes search params from the URL to the documents API', async () => {
|
||||||
const mockGet = vi
|
const mockGet = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] })
|
.mockResolvedValueOnce({
|
||||||
|
response: { ok: true, status: 200 },
|
||||||
|
data: { documents: [], total: 0 }
|
||||||
|
})
|
||||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] });
|
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] });
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
@@ -161,6 +167,71 @@ describe('home page load — search mode', () => {
|
|||||||
expect(firstCall[1].params.query.q).toBe('Urlaub');
|
expect(firstCall[1].params.query.q).toBe('Urlaub');
|
||||||
expect(firstCall[1].params.query.from).toBe('2020-01-01');
|
expect(firstCall[1].params.query.from).toBe('2020-01-01');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sets isDashboard false when only tagQ is set', async () => {
|
||||||
|
const mockGet = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
response: { ok: true, status: 200 },
|
||||||
|
data: { documents: [{ id: 'd1' }], total: 1 }
|
||||||
|
}) // search docs
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // persons
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
url: makeUrl({ tagQ: 'fam' }),
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.isDashboard).toBe(false);
|
||||||
|
expect(result.documents).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes sort, dir, and tagQ params to the documents API', async () => {
|
||||||
|
const mockGet = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
response: { ok: true, status: 200 },
|
||||||
|
data: { documents: [], total: 0 }
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] });
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
|
||||||
|
await load({
|
||||||
|
url: makeUrl({ q: 'test', sort: 'TITLE', dir: 'asc', tagQ: 'fam' }),
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstCall = mockGet.mock.calls[0];
|
||||||
|
expect(firstCall[1].params.query.sort).toBe('TITLE');
|
||||||
|
expect(firstCall[1].params.query.dir).toBe('asc');
|
||||||
|
expect(firstCall[1].params.query.tagQ).toBe('fam');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns total from the DocumentSearchResult envelope', async () => {
|
||||||
|
const mockGet = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
response: { ok: true, status: 200 },
|
||||||
|
data: { documents: [{ id: 'd1' }], total: 42 }
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] });
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
url: makeUrl({ q: 'test' }),
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.documents).toHaveLength(1);
|
||||||
|
expect(result.total).toBe(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── 401 redirect ─────────────────────────────────────────────────────────────
|
// ─── 401 redirect ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ describe('Home page – search bar', () => {
|
|||||||
it('renders the full-text search input', async () => {
|
it('renders the full-text search input', async () => {
|
||||||
render(Page, { data: emptyData });
|
render(Page, { data: emptyData });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...'))
|
.element(page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026'))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
await page.screenshot({ path: 'test-results/screenshots/home-default.png' });
|
await page.screenshot({ path: 'test-results/screenshots/home-default.png' });
|
||||||
});
|
});
|
||||||
@@ -79,7 +79,7 @@ describe('Home page – search bar', () => {
|
|||||||
it('pre-fills the search input from filters.q', async () => {
|
it('pre-fills the search input from filters.q', async () => {
|
||||||
render(Page, { data: { ...emptyData, filters: { ...emptyData.filters, q: 'Urlaub' } } });
|
render(Page, { data: { ...emptyData, filters: { ...emptyData.filters, q: 'Urlaub' } } });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...'))
|
.element(page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026'))
|
||||||
.toHaveValue('Urlaub');
|
.toHaveValue('Urlaub');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -178,7 +178,7 @@ describe('Home page – search input keystroke preservation', () => {
|
|||||||
it('does not overwrite the search input while the user is focused and stale data arrives', async () => {
|
it('does not overwrite the search input while the user is focused and stale data arrives', async () => {
|
||||||
const { rerender } = render(Page, { data: emptyData });
|
const { rerender } = render(Page, { data: emptyData });
|
||||||
|
|
||||||
const input = page.getByPlaceholder('Suche in Titel, Inhalt, Ort...');
|
const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026');
|
||||||
|
|
||||||
// User types "abc" — input is focused
|
// User types "abc" — input is focused
|
||||||
await input.click();
|
await input.click();
|
||||||
@@ -239,3 +239,37 @@ describe('Home page – error state', () => {
|
|||||||
await page.screenshot({ path: 'test-results/screenshots/home-error.png' });
|
await page.screenshot({ path: 'test-results/screenshots/home-error.png' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Loading spinner ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Home page – loading spinner', () => {
|
||||||
|
it('does not show spinner by default', async () => {
|
||||||
|
render(Page, { data: emptyData });
|
||||||
|
const spinner = page.getByRole('status');
|
||||||
|
await expect.element(spinner).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Sort controls ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Home page – sort controls', () => {
|
||||||
|
it('pre-fills sort from filters.sort', async () => {
|
||||||
|
const data = {
|
||||||
|
...emptyData,
|
||||||
|
filters: { ...emptyData.filters, sort: 'TITLE', dir: 'asc', tagQ: '' }
|
||||||
|
};
|
||||||
|
render(Page, { data });
|
||||||
|
const select = page.getByRole('combobox');
|
||||||
|
await expect.element(select).toHaveValue('TITLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders direction toggle with asc indicator when dir is asc', async () => {
|
||||||
|
const data = {
|
||||||
|
...emptyData,
|
||||||
|
filters: { ...emptyData.filters, sort: 'DATE', dir: 'asc', tagQ: '' }
|
||||||
|
};
|
||||||
|
render(Page, { data });
|
||||||
|
const btn = page.getByRole('button', { name: /aufsteigend/i });
|
||||||
|
await expect.element(btn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user