Compare commits

...

10 Commits

Author SHA1 Message Date
Marcel
bc397048b7 fix(search): use in-memory sort for SENDER to include documents with null sender
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
INNER JOIN from Sort.by("sender.lastName") was excluding docs without a sender.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 14:15:03 +02:00
Marcel
07dbe152e2 feat(search): show result count and term-aware empty state in DocumentList
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 14:03:16 +02:00
Marcel
78fdb01ec1 feat(search): wire sort/dir/tagQ state into page.svelte and URL params
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:58:53 +02:00
Marcel
769937e03d feat(search): read sort/dir/tagQ from URL and unwrap DocumentSearchResult envelope
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:53:54 +02:00
Marcel
4fe10e1316 feat(search): add sort/dir/tagQ props to SearchFilterBar with SortDropdown
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:50:45 +02:00
Marcel
eeb78c98ec feat(search): add onTextInput callback to TagInput for live tag filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:42:08 +02:00
Marcel
aeed6e0dac feat(search): add SortDropdown component with direction toggle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:39:26 +02:00
Marcel
3f8f3cd938 feat(i18n): add sort, result count, and empty-state translation keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:35:46 +02:00
Marcel
2c0748d60e feat(utils): add debounce utility with full test coverage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:23:14 +02:00
Marcel
d1ad4d834c chore: regenerate API types with search envelope and new sort/tagQ params
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:21:41 +02:00
20 changed files with 907 additions and 26 deletions

View File

@@ -291,11 +291,15 @@ public class DocumentService {
.and(hasTagPartial(tagQ))
.and(hasStatus(status));
Sort springSort = resolveSort(sort, dir);
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);
}
@@ -312,6 +316,22 @@ public class DocumentService {
};
}
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();
return s != null ? s.getLastName() + " " + s.getFirstName() : "";
}, nullSafeComparator))
.toList();
}
private List<Document> sortByFirstReceiver(List<Document> documents, String dir) {
boolean ascending = "ASC".equalsIgnoreCase(dir);
Comparator<String> nullSafeComparator = (a, b) -> {

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.model.DocumentSort;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
@@ -1273,4 +1274,23 @@ 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");
}
}

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\u00fcr \"{term}\" gefunden",
"docs_btn_filter": "Filter",
"docs_btn_reset_title": "Filter zurücksetzen",
"docs_filter_label_tags": "Schlagworte",

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",

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",

View File

@@ -0,0 +1,36 @@
<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-center gap-1">
<select
role="combobox"
bind:value={sort}
class="border-brand-sand rounded border bg-white px-2 py-1 font-sans text-sm text-brand-navy focus:ring-2 focus:ring-brand-mint focus:outline-none"
>
<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>
<button
type="button"
onclick={toggleDir}
class="border-brand-sand hover:bg-brand-sand flex items-center justify-center rounded border bg-white px-2 py-1 text-sm text-brand-navy transition-colors"
aria-label={dir === 'asc' ? 'Aufsteigend sortieren' : 'Absteigend sortieren'}
>
{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([]);
@@ -101,7 +102,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,24 @@ 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();
});
});

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,6 +13,9 @@ 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;
@@ -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

@@ -19,6 +19,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 +42,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 +72,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,6 +97,9 @@ 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}
onSearch={handleTextSearch}
@@ -119,6 +131,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-sm 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,6 +12,9 @@ let {
senderId = $bindable(''),
receiverId = $bindable(''),
tagNames = $bindable<string[]>([]),
tagQ = $bindable(''),
sort = $bindable('DATE'),
dir = $bindable('desc'),
showAdvanced = $bindable(false),
initialSenderName = '',
initialReceiverName = '',
@@ -24,6 +28,9 @@ let {
senderId?: string;
receiverId?: string;
tagNames?: string[];
tagQ?: string;
sort?: string;
dir?: string;
showAdvanced?: boolean;
initialSenderName?: string;
initialReceiverName?: string;
@@ -31,6 +38,20 @@ let {
onfocus?: () => void;
onblur?: () => void;
} = $props();
// Plain (non-reactive) flag — not $state, so no reactive assignment inside $effect
let sortDirMounted = false;
$effect(() => {
// Track sort and dir so this effect re-runs when either changes
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">
@@ -58,6 +79,9 @@ let {
</div>
</div>
<!-- Sort Dropdown -->
<SortDropdown bind:sort={sort} bind:dir={dir} />
<!-- Toggle Advanced Button -->
<button
onclick={() => (showAdvanced = !showAdvanced)}
@@ -98,7 +122,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,46 @@
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 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
@@ -163,6 +169,50 @@ describe('home page load — search mode', () => {
});
});
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 ─────────────────────────────────────────────────────────────
describe('home page load — auth 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,27 @@ describe('Home page error state', () => {
await page.screenshot({ path: 'test-results/screenshots/home-error.png' });
});
});
// ─── 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();
});
});