feat(search): extended search, sort options, live tag filter, result count #183

Merged
marcel merged 26 commits from feat/issue-180-extended-search-sort into main 2026-04-06 19:18:12 +02:00
27 changed files with 1308 additions and 63 deletions

View File

@@ -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<List<Document>> search(
public ResponseEntity<DocumentSearchResult> 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<String> 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<Document> results = documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir);
return ResponseEntity.ok(DocumentSearchResult.of(results));
}
// --- VERSIONS ---

View File

@@ -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());
}
}

View File

@@ -0,0 +1,5 @@
package org.raddatz.familienarchiv.dto;
public enum DocumentSort {
DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE
}

View File

@@ -0,0 +1,5 @@
package org.raddatz.familienarchiv.model;
public enum DocumentSort {
DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE
}

View File

@@ -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<Document> 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<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(
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<Document> 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<Document> hasTags(List<String> 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<Long> subquery = query.subquery(Long.class);
Root<Document> subRoot = subquery.from(Document.class);
Join<Document, Tag> 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]));
};
}
}
// 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);
};
}
}

View File

@@ -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<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))
.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<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

View File

@@ -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

View File

@@ -250,6 +250,62 @@ class DocumentSpecificationsTest {
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 ────────────────────────────────────────────────────────────
@Test

View File

@@ -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<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");
}
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View 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>

View 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('↓');
});
});

View File

@@ -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) {
<input
type="text"
bind:value={inputVal}
oninput={() => fetchSuggestions(inputVal)}
oninput={() => { fetchSuggestions(inputVal); onTextInput?.(inputVal); }}
onkeydown={handleKeydown}
onfocus={() => fetchSuggestions(inputVal)}
placeholder={tags.length === 0

View File

@@ -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(['']);
});
});

View File

@@ -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;

View 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);
});
});

View 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;
}

View File

@@ -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
};
}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { navigating } from '$app/state';
import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import SearchFilterBar from './SearchFilterBar.svelte';
@@ -19,6 +20,9 @@ let to = $state(untrack(() => data.filters?.to || ''));
let senderId = $state(untrack(() => data.filters?.senderId || ''));
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
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) =>
(filters?.tags?.length ?? 0) > 0 ||
@@ -39,6 +43,9 @@ function triggerSearch() {
if (senderId) params.set('senderId', senderId);
if (receiverId) params.set('receiverId', receiverId);
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 });
}
@@ -66,6 +73,9 @@ $effect(() => {
senderId = data.filters?.senderId || '';
receiverId = data.filters?.receiverId || '';
tagNames = data.filters?.tags || [];
sort = data.filters?.sort || 'DATE';
dir = data.filters?.dir || 'desc';
tagQ = data.filters?.tagQ || '';
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
});
@@ -88,8 +98,12 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
bind:receiverId={receiverId}
bind:tagNames={tagNames}
bind:showAdvanced={showAdvanced}
bind:sort={sort}
bind:dir={dir}
bind:tagQ={tagQ}
initialSenderName={data.initialValues?.senderName}
initialReceiverName={data.initialValues?.receiverName}
isLoading={navigating.to !== null}
onSearch={handleTextSearch}
onfocus={() => (qFocused = true)}
onblur={() => (qFocused = false)}
@@ -119,6 +133,12 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
</div>
{: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}
</main>

View File

@@ -6,7 +6,9 @@ import { formatDate } from '$lib/utils/date';
let {
documents,
canWrite,
error
error,
total = 0,
q = ''
}: {
documents: {
id: string;
@@ -20,6 +22,8 @@ let {
}[];
canWrite: boolean;
error?: string | null;
total?: number;
q?: string;
} = $props();
</script>
@@ -41,6 +45,11 @@ let {
{/if}
</div>
<!-- RESULT COUNT -->
{#if total > 0}
<p class="mb-3 font-sans text-base text-ink-2">{m.docs_result_count({ count: total })}</p>
{/if}
<!-- DOCUMENT LIST -->
<div class="border border-line bg-surface shadow-sm">
{#if error}
@@ -162,7 +171,7 @@ let {
</div>
<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">
{m.docs_empty_text()}
{q ? m.docs_empty_for_term({ term: q }) : m.docs_empty_text()}
</p>
<button
onclick={() => goto('/')}

View 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();
});
});

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import TagInput from '$lib/components/TagInput.svelte';
import SortDropdown from '$lib/components/SortDropdown.svelte';
import { slide } from 'svelte/transition';
import { m } from '$lib/paraglide/messages.js';
@@ -11,9 +12,13 @@ let {
senderId = $bindable(''),
receiverId = $bindable(''),
tagNames = $bindable<string[]>([]),
tagQ = $bindable(''),
sort = $bindable('DATE'),
dir = $bindable('desc'),
showAdvanced = $bindable(false),
initialSenderName = '',
initialReceiverName = '',
isLoading = false,
onSearch,
onfocus,
onblur
@@ -24,13 +29,32 @@ let {
senderId?: string;
receiverId?: string;
tagNames?: string[];
tagQ?: string;
sort?: string;
dir?: string;
showAdvanced?: boolean;
initialSenderName?: string;
initialReceiverName?: string;
isLoading?: boolean;
onSearch: () => void;
onfocus?: () => void;
onblur?: () => void;
} = $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>
<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"
/>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<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 isLoading}
<svg
role="status"
aria-label="Suche läuft…"
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>
<!-- Sort Dropdown -->
<SortDropdown bind:sort={sort} bind:dir={dir} />
<!-- Toggle Advanced Button -->
<button
onclick={() => (showAdvanced = !showAdvanced)}
@@ -98,7 +144,14 @@ let {
<p class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.docs_filter_label_tags()}
</p>
<TagInput bind:tags={tagNames} allowCreation={false} />
<TagInput
bind:tags={tagNames}
allowCreation={false}
onTextInput={(text) => {
tagQ = text;
onSearch();
}}
/>
</div>
<!-- Sender -->

View 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();
});
});

View File

@@ -123,7 +123,10 @@ describe('home page load — search mode', () => {
it('sets isDashboard false and skips widget APIs when q is set', async () => {
const mockGet = vi
.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
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
@@ -146,7 +149,10 @@ describe('home page load — search mode', () => {
it('passes search params from the URL to the documents API', async () => {
const mockGet = vi
.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: [] });
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
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.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 ─────────────────────────────────────────────────────────────

View File

@@ -56,7 +56,7 @@ describe('Home page search bar', () => {
it('renders the full-text search input', async () => {
render(Page, { data: emptyData });
await expect
.element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...'))
.element(page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026'))
.toBeInTheDocument();
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 () => {
render(Page, { data: { ...emptyData, filters: { ...emptyData.filters, q: 'Urlaub' } } });
await expect
.element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...'))
.element(page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026'))
.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 () => {
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
await input.click();
@@ -239,3 +239,37 @@ describe('Home page error state', () => {
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();
});
});