Compare commits

...

9 Commits

Author SHA1 Message Date
Marcel
a863f8baad docs(search): explain void sort/dir ESLint workaround in SearchFilterBar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:50:52 +02:00
Marcel
1f86e6e238 fix(a11y): bump result count text to text-base (16px) for senior readability
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:50:00 +02:00
Marcel
c82bd61ad4 feat(a11y): fix SortDropdown accessibility — label, aria-label i18n, chevron
- Add sr-only <label> for the sort <select> (WCAG 1.3.1)
- Replace hardcoded German aria-label with Paraglide sort_dir_asc/desc keys
- Add custom SVG chevron overlay to restore visual dropdown indicator
  (appearance-none had removed the native browser arrow)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:49:06 +02:00
Marcel
56f7282a9d test(search): add empty-receivers edge case for RECEIVER sort
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:45:01 +02:00
Marcel
110024245d docs(search): document in-memory sort tradeoff and total=size() limitation
Add TODO comment explaining why SENDER/RECEIVER sort is in-memory
(JPA INNER JOIN drops null-sender docs) and note that pagination
will require a DB COUNT query in DocumentSearchResult.of().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:41:17 +02:00
Marcel
972048d57d fix(search): treat null sender.lastName as empty in sort key
A sender with lastName=null produced sort key "null Bob" which sorted
before names starting with lowercase letters (n < s, t, u, v...).
Now returns "" for null lastName, which the comparator places at end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:39:30 +02:00
Marcel
1c1ab0c72a feat(search): reject invalid dir parameter with 400
Previously any value other than ASC/DESC silently defaulted to
DESC with no feedback. Now returns 400 Bad Request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:34:38 +02:00
Marcel
6ac3f6b176 refactor(search): remove dead SENDER case from resolveSort switch
SENDER and RECEIVER are handled by in-memory sort before resolveSort
is called, making those switch cases unreachable. Removed and added
a comment making the invariant explicit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:31:39 +02:00
Marcel
12023513b2 refactor(search): move DocumentSort from model/ to dto/
DocumentSort is a query parameter enum, not a JPA entity.
Placing it in model/ violated the layer boundary — model/ should
contain only domain entities.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 16:29:35 +02:00
12 changed files with 124 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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