Compare commits
9 Commits
79250fb705
...
a863f8baad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a863f8baad | ||
|
|
1f86e6e238 | ||
|
|
c82bd61ad4 | ||
|
|
56f7282a9d | ||
|
|
110024245d | ||
|
|
972048d57d | ||
|
|
1c1ab0c72a | ||
|
|
6ac3f6b176 | ||
|
|
12023513b2 |
@@ -19,7 +19,7 @@ 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.model.DocumentSort;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
@@ -41,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;
|
||||
|
||||
@@ -199,6 +201,9 @@ public class DocumentController {
|
||||
@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));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import org.raddatz.familienarchiv.model.Document;
|
||||
import java.util.List;
|
||||
|
||||
public record DocumentSearchResult(List<Document> documents, long total) {
|
||||
/**
|
||||
* Creates a result where total equals the list size.
|
||||
* No pagination yet — the full matched set is always returned.
|
||||
* When pagination is added, total must come from a DB COUNT query, not list.size().
|
||||
*/
|
||||
public static DocumentSearchResult of(List<Document> documents) {
|
||||
return new DocumentSearchResult(documents, documents.size());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
public enum DocumentSort {
|
||||
DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE
|
||||
}
|
||||
@@ -6,7 +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.model.DocumentSort;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
@@ -32,6 +32,7 @@ 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;
|
||||
@@ -291,6 +292,9 @@ public class DocumentService {
|
||||
.and(hasTagPartial(tagQ))
|
||||
.and(hasStatus(status));
|
||||
|
||||
// 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);
|
||||
@@ -308,9 +312,9 @@ public class DocumentService {
|
||||
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 SENDER -> Sort.by(direction, "sender.lastName").and(Sort.by(direction, "sender.firstName"));
|
||||
case UPLOAD_DATE -> Sort.by(direction, "createdAt");
|
||||
default -> Sort.by(direction, "documentDate");
|
||||
};
|
||||
@@ -327,7 +331,8 @@ public class DocumentService {
|
||||
return documents.stream()
|
||||
.sorted(Comparator.comparing(doc -> {
|
||||
Person s = doc.getSender();
|
||||
return s != null ? s.getLastName() + " " + s.getFirstName() : "";
|
||||
if (s == null || s.getLastName() == null) return "";
|
||||
return s.getLastName() + " " + Objects.toString(s.getFirstName(), "");
|
||||
}, nullSafeComparator))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -84,6 +84,13 @@ 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 {
|
||||
|
||||
@@ -10,7 +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.model.DocumentSort;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
@@ -1293,4 +1293,47 @@ class DocumentServiceTest {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"docs_sort_receiver": "Empfänger",
|
||||
"docs_sort_upload": "Hochgeladen",
|
||||
"docs_result_count": "{count} Dokumente",
|
||||
"docs_empty_for_term": "Keine Dokumente f\u00fcr \"{term}\" gefunden",
|
||||
"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",
|
||||
@@ -182,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.",
|
||||
@@ -201,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}",
|
||||
@@ -433,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.",
|
||||
@@ -464,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"
|
||||
}
|
||||
|
||||
@@ -201,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",
|
||||
@@ -464,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"
|
||||
}
|
||||
|
||||
@@ -201,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",
|
||||
@@ -213,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",
|
||||
@@ -464,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"
|
||||
}
|
||||
|
||||
@@ -14,22 +14,38 @@ function toggleDir() {
|
||||
</script>
|
||||
|
||||
<div class="inline-flex items-stretch">
|
||||
<select
|
||||
role="combobox"
|
||||
bind:value={sort}
|
||||
class="appearance-none border border-line bg-muted px-4 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"
|
||||
>
|
||||
<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>
|
||||
<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' ? 'Aufsteigend sortieren' : 'Absteigend sortieren'}
|
||||
aria-label={dir === 'asc' ? m.sort_dir_asc() : m.sort_dir_desc()}
|
||||
>
|
||||
{dir === 'asc' ? '↑' : '↓'}
|
||||
</button>
|
||||
|
||||
@@ -47,7 +47,7 @@ let {
|
||||
|
||||
<!-- RESULT COUNT -->
|
||||
{#if total > 0}
|
||||
<p class="mb-3 font-sans text-sm text-ink-2">{m.docs_result_count({ count: total })}</p>
|
||||
<p class="mb-3 font-sans text-base text-ink-2">{m.docs_result_count({ count: total })}</p>
|
||||
{/if}
|
||||
|
||||
<!-- DOCUMENT LIST -->
|
||||
|
||||
@@ -45,7 +45,8 @@ let {
|
||||
let sortDirMounted = false;
|
||||
|
||||
$effect(() => {
|
||||
// Track sort and dir so this effect re-runs when either changes
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user