diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index f661e8aa..6252a5ad 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -12,12 +12,14 @@ import java.util.UUID; import io.swagger.v3.oas.annotations.Parameter; +import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.dto.DocumentVersionSummary; import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.dto.DocumentSort; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.DocumentVersion; 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.RequestParam; 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.multipart.MultipartFile; @@ -186,15 +190,22 @@ public class DocumentController { } @GetMapping("/search") - public ResponseEntity> search( + public ResponseEntity search( @RequestParam(required = false) String q, @RequestParam(required = false) LocalDate from, @RequestParam(required = false) LocalDate to, @RequestParam(required = false) UUID senderId, @RequestParam(required = false) UUID receiverId, @RequestParam(required = false, name = "tag") List tags, - @Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status) { - return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, status)); + @RequestParam(required = false) String tagQ, + @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 results = documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir); + return ResponseEntity.ok(DocumentSearchResult.of(results)); } // --- VERSIONS --- diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSearchResult.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSearchResult.java new file mode 100644 index 00000000..a0c4af45 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSearchResult.java @@ -0,0 +1,16 @@ +package org.raddatz.familienarchiv.dto; + +import org.raddatz.familienarchiv.model.Document; + +import java.util.List; + +public record DocumentSearchResult(List 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 documents) { + return new DocumentSearchResult(documents, documents.size()); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSort.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSort.java new file mode 100644 index 00000000..1a276d7f --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentSort.java @@ -0,0 +1,5 @@ +package org.raddatz.familienarchiv.dto; + +public enum DocumentSort { + DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentSort.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentSort.java new file mode 100644 index 00000000..6456ef54 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentSort.java @@ -0,0 +1,5 @@ +package org.raddatz.familienarchiv.model; + +public enum DocumentSort { + DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java index cf50675b..b936f16c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentSpecifications.java @@ -8,24 +8,58 @@ import java.util.UUID; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; +import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Tag; import org.springframework.data.jpa.domain.Specification; import org.springframework.util.StringUtils; 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 hasText(String text) { return (root, query, cb) -> { if (!StringUtils.hasText(text)) return null; String likePattern = "%" + text.toLowerCase() + "%"; + // LEFT JOIN on sender (ManyToOne — no duplicate rows) + Join senderJoin = root.join("sender", JoinType.LEFT); + + // EXISTS subquery for receiver name — avoids duplicate rows for multi-receiver docs + Subquery receiverSub = query.subquery(Long.class); + Root receiverRoot = receiverSub.from(Document.class); + Join 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 tagSub = query.subquery(Long.class); + Root tagRoot = tagSub.from(Document.class); + Join 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( cb.like(cb.lower(root.get("title")), likePattern), cb.like(cb.lower(root.get("originalFilename")), 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) + ); }; } @@ -55,13 +89,13 @@ public class DocumentSpecifications { return cb.lessThanOrEqualTo(root.get("documentDate"), end); }; } - + // Filtert nach Status public static Specification hasStatus(DocumentStatus 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 hasTags(List tags) { return (root, query, cb) -> { if (tags == null || tags.isEmpty()) @@ -72,15 +106,13 @@ public class DocumentSpecifications { for (String tagName : tags) { 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 subquery = query.subquery(Long.class); Root subRoot = subquery.from(Document.class); Join subTags = subRoot.join("tags"); subquery.select(subRoot.get("id")) .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()) ); @@ -90,5 +122,26 @@ public class DocumentSpecifications { return cb.and(predicates.toArray(new Predicate[0])); }; } - -} \ No newline at end of file + + // Filtert nach partiellem Tag-Namen (ILIKE) — für Live-Tag-Suche + public static Specification hasTagPartial(String tagQ) { + return (root, query, cb) -> { + if (!StringUtils.hasText(tagQ)) + return null; + String likePattern = "%" + tagQ.toLowerCase() + "%"; + + Subquery subquery = query.subquery(Long.class); + Root subRoot = subquery.from(Document.class); + Join 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); + }; + } + +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index fe5826ee..7d1bef2b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.dto.DocumentSort; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Tag; @@ -26,10 +27,12 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -280,16 +283,78 @@ public class DocumentService { } // 1. Allgemeine Suche (für das Suchfeld im Frontend) - public List searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List tags, DocumentStatus status) { + public List searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir) { Specification spec = Specification.where(hasText(text)) .and(isBetween(from, to)) .and(hasSender(sender)) .and(hasReceiver(receiver)) .and(hasTags(tags)) + .and(hasTagPartial(tagQ)) .and(hasStatus(status)); - // Neueste zuerst (nach Erstellungsdatum) - return documentRepository.findAll(spec, Sort.by(Sort.Direction.DESC, "createdAt")); + // SENDER and RECEIVER are sorted in-memory because JPA's Sort.by("sender.lastName") + // 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 results = documentRepository.findAll(spec); + return sortByFirstReceiver(results, dir); + } + if (sort == DocumentSort.SENDER) { + List 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 sortBySender(List documents, String dir) { + boolean ascending = "ASC".equalsIgnoreCase(dir); + Comparator 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 sortByFirstReceiver(List documents, String dir) { + boolean ascending = "ASC".equalsIgnoreCase(dir); + Comparator 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 diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index 1166d873..d8f93e4e 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -58,7 +58,7 @@ class DocumentControllerTest { @Test @WithMockUser 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()); mockMvc.perform(get("/api/documents/search")) @@ -68,13 +68,13 @@ class DocumentControllerTest { @Test @WithMockUser 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()); mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED")) .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 @@ -84,6 +84,32 @@ class DocumentControllerTest { .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 ───────────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java index 447f26e3..c2691213 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/DocumentSpecificationsTest.java @@ -250,6 +250,62 @@ class DocumentSpecificationsTest { assertThat(result).isEmpty(); } + @Test + void hasText_findsByPartialSenderLastName() { + List result = documentRepository.findAll(Specification.where(hasText("üller"))); + assertThat(result).extracting(Document::getTitle) + .containsExactlyInAnyOrder("Alter Brief", "Neuerer Brief"); + } + + @Test + void hasText_findsByPartialReceiverLastName() { + List result = documentRepository.findAll(Specification.where(hasText("schmid"))); + assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); + } + + @Test + void hasText_findsByPartialTagName() { + List 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 result = documentRepository.findAll(Specification.where(hasText("schmid"))); + assertThat(result).hasSize(1); + } + + // ─── hasTagPartial ──────────────────────────────────────────────────────── + + @Test + void hasTagPartial_returnsAllDocuments_whenTextIsNull() { + List result = documentRepository.findAll(Specification.where(hasTagPartial(null))); + assertThat(result).hasSize(3); + } + + @Test + void hasTagPartial_findsByPartialTagName() { + List result = documentRepository.findAll(Specification.where(hasTagPartial("amili"))); + assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief"); + } + + @Test + void hasTagPartial_isCaseInsensitive() { + List result = documentRepository.findAll(Specification.where(hasTagPartial("URLAUB"))); + assertThat(result).extracting(Document::getTitle).containsExactly("Neuerer Brief"); + } + + @Test + void hasTagPartial_returnsEmpty_whenNoTagMatches() { + List result = documentRepository.findAll(Specification.where(hasTagPartial("xyz"))); + assertThat(result).isEmpty(); + } + // ─── hasStatus ──────────────────────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index fb01c7a2..67db2519 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.dto.DocumentSort; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.Person; 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))) .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)); } @@ -1209,7 +1210,7 @@ class DocumentServiceTest { when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class))) .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)); } @@ -1273,4 +1274,66 @@ class DocumentServiceTest { verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort)); 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 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 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 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"); + } } diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 860891f4..a78baf18 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -52,7 +52,15 @@ "login_label_username": "Benutzername", "login_label_password": "Passwort", "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_reset_title": "Filter zurücksetzen", "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_list_title": "Alle Schlagworte", "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_updated": "Schlagwort umbenannt.", "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_btn_new_user": "Neuer 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_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_groups_list_title": "Alle Gruppen", "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_group_new_heading": "Neue Gruppe anlegen", "admin_group_edit_heading": "Gruppe: {name}", @@ -425,7 +433,7 @@ "notification_load_more": "Ältere laden", "notification_empty_history": "Keine Benachrichtigungen", "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_unread": "ungelesen", "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_draw_tooltip": "Klicken und ziehen, um einen Textbereich zu markieren", "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" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 110d0c13..5e447abb 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -52,7 +52,15 @@ "login_label_username": "Username", "login_label_password": "Password", "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_reset_title": "Reset filter", "docs_filter_label_tags": "Tags", @@ -193,7 +201,7 @@ "admin_user_delete_confirm": "Really delete user {username}?", "admin_btn_new_user": "New User", "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_select_prompt": "Select a user from the list.", "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_draw_tooltip": "Click and drag to mark a text region", "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" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 8ac74c35..95641956 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -52,7 +52,15 @@ "login_label_username": "Usuario", "login_label_password": "Contraseña", "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_reset_title": "Restablecer filtro", "docs_filter_label_tags": "Etiquetas", @@ -193,7 +201,7 @@ "admin_user_delete_confirm": "¿Realmente eliminar al usuario {username}?", "admin_btn_new_user": "Nuevo usuario", "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_select_prompt": "Selecciona un usuario de la lista.", "admin_btn_new_group": "Nuevo grupo", @@ -205,7 +213,7 @@ "admin_group_edit_heading": "Grupo: {name}", "admin_group_updated": "Grupo guardado.", "admin_group_created": "Grupo creado.", - "admin_groups_section_standard": "Est\u00e1ndar", + "admin_groups_section_standard": "Estándar", "admin_groups_section_administrative": "Administrativo", "admin_perm_read_all": "Solo lectura", "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_draw_tooltip": "Haga clic y arrastre para marcar una región de texto", "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" } diff --git a/frontend/src/lib/components/SortDropdown.svelte b/frontend/src/lib/components/SortDropdown.svelte new file mode 100644 index 00000000..f6b2f7ec --- /dev/null +++ b/frontend/src/lib/components/SortDropdown.svelte @@ -0,0 +1,52 @@ + + +
+ +
+ + +
+ +
diff --git a/frontend/src/lib/components/SortDropdown.svelte.spec.ts b/frontend/src/lib/components/SortDropdown.svelte.spec.ts new file mode 100644 index 00000000..2c4282a8 --- /dev/null +++ b/frontend/src/lib/components/SortDropdown.svelte.spec.ts @@ -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('↓'); + }); +}); diff --git a/frontend/src/lib/components/TagInput.svelte b/frontend/src/lib/components/TagInput.svelte index 369bfec9..78eaeca8 100644 --- a/frontend/src/lib/components/TagInput.svelte +++ b/frontend/src/lib/components/TagInput.svelte @@ -6,9 +6,10 @@ import { clickOutside } from '$lib/actions/clickOutside'; interface Props { tags?: string[]; 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 suggestions: string[] = $state([]); @@ -43,6 +44,7 @@ function addTag(tag: string) { suggestions = []; showSuggestions = false; activeIndex = -1; + onTextInput?.(''); } function removeTag(index: number) { @@ -101,7 +103,7 @@ function handleKeydown(e: KeyboardEvent) { fetchSuggestions(inputVal)} + oninput={() => { fetchSuggestions(inputVal); onTextInput?.(inputVal); }} onkeydown={handleKeydown} onfocus={() => fetchSuggestions(inputVal)} placeholder={tags.length === 0 diff --git a/frontend/src/lib/components/TagInput.svelte.spec.ts b/frontend/src/lib/components/TagInput.svelte.spec.ts index e0f78da9..9e00915d 100644 --- a/frontend/src/lib/components/TagInput.svelte.spec.ts +++ b/frontend/src/lib/components/TagInput.svelte.spec.ts @@ -208,3 +208,36 @@ describe('TagInput – autocomplete', () => { 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(['']); + }); +}); diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 52be38ff..36541dba 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -100,6 +100,38 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -212,6 +244,54 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -628,6 +708,22 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -724,8 +820,6 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -876,6 +970,35 @@ export interface components { sender?: components["schemas"]["Person"]; 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: { username?: string; email?: string; @@ -895,6 +1018,20 @@ export interface components { name?: 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: { content?: string; mentionedUserIds?: string[]; @@ -907,6 +1044,8 @@ export interface components { /** Format: uuid */ annotationId?: string; /** Format: uuid */ + blockId?: string; + /** Format: uuid */ parentId?: string; /** Format: uuid */ authorId?: string; @@ -1038,18 +1177,18 @@ export interface components { /** Format: int32 */ number?: number; sort?: components["schemas"]["SortObject"]; - /** Format: int32 */ - numberOfElements?: number; first?: boolean; last?: boolean; + /** Format: int32 */ + numberOfElements?: number; empty?: boolean; }; PageableObject: { + paged?: boolean; /** Format: int32 */ pageNumber?: number; /** Format: int32 */ pageSize?: number; - paged?: boolean; /** Format: int64 */ offset?: number; sort?: components["schemas"]["SortObject"]; @@ -1085,6 +1224,22 @@ export interface components { snapshot: 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: { /** Format: uuid */ 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: { parameters: { 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: { parameters: { 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: { parameters: { query?: { @@ -2365,8 +2764,13 @@ export interface operations { senderId?: string; receiverId?: string; tag?: string[]; + tagQ?: string; /** @description Filter by document status */ 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; path?: never; @@ -2380,7 +2784,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["Document"][]; + "*/*": components["schemas"]["DocumentSearchResult"]; }; }; }; @@ -2500,7 +2904,6 @@ export interface operations { }; }; }; - // getResetTokenForTest removed — @Operation(hidden=true) on AuthE2EController. importStatus: { parameters: { query?: never; diff --git a/frontend/src/lib/utils/debounce.spec.ts b/frontend/src/lib/utils/debounce.spec.ts new file mode 100644 index 00000000..6ee1c067 --- /dev/null +++ b/frontend/src/lib/utils/debounce.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/utils/debounce.ts b/frontend/src/lib/utils/debounce.ts new file mode 100644 index 00000000..b8958e45 --- /dev/null +++ b/frontend/src/lib/utils/debounce.ts @@ -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 void>(fn: T, delay: number): T { + let timer: ReturnType; + return ((...args: Parameters) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }) as T; +} diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index 3646840a..380c7e3b 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -13,8 +13,11 @@ export async function load({ url, fetch }) { const senderId = url.searchParams.get('senderId') || ''; const receiverId = url.searchParams.get('receiverId') || ''; 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); @@ -30,7 +33,10 @@ export async function load({ url, fetch }) { to: to || undefined, senderId: senderId || 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'); } - 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 { id: string; firstName: string; @@ -80,6 +88,7 @@ export async function load({ url, fetch }) { return { isDashboard, documents, + total, stats, incompleteDocs, recentDocs, @@ -87,7 +96,7 @@ export async function load({ url, fetch }) { senderName: senderObj ? `${senderObj.firstName} ${senderObj.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 }; } catch (e) { @@ -96,11 +105,12 @@ export async function load({ url, fetch }) { return { isDashboard, documents: [], + total: 0, stats: null, incompleteDocs: [], recentDocs: [], 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 }; } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index cff0779d..5d863fbb 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,5 +1,6 @@ @@ -41,6 +45,11 @@ let { {/if} + +{#if total > 0} +

{m.docs_result_count({ count: total })}

+{/if} +
{#if error} @@ -162,7 +171,7 @@ let {

{m.docs_empty_heading()}

- {m.docs_empty_text()} + {q ? m.docs_empty_for_term({ term: q }) : m.docs_empty_text()}