Compare commits
10 Commits
879435c8d9
...
bc397048b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc397048b7 | ||
|
|
07dbe152e2 | ||
|
|
78fdb01ec1 | ||
|
|
769937e03d | ||
|
|
4fe10e1316 | ||
|
|
eeb78c98ec | ||
|
|
aeed6e0dac | ||
|
|
3f8f3cd938 | ||
|
|
2c0748d60e | ||
|
|
d1ad4d834c |
@@ -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) -> {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
36
frontend/src/lib/components/SortDropdown.svelte
Normal file
36
frontend/src/lib/components/SortDropdown.svelte
Normal 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>
|
||||
36
frontend/src/lib/components/SortDropdown.svelte.spec.ts
Normal file
36
frontend/src/lib/components/SortDropdown.svelte.spec.ts
Normal 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('↓');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
69
frontend/src/lib/utils/debounce.spec.ts
Normal file
69
frontend/src/lib/utils/debounce.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
12
frontend/src/lib/utils/debounce.ts
Normal file
12
frontend/src/lib/utils/debounce.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('/')}
|
||||
|
||||
51
frontend/src/routes/DocumentList.svelte.spec.ts
Normal file
51
frontend/src/routes/DocumentList.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 -->
|
||||
|
||||
46
frontend/src/routes/SearchFilterBar.svelte.spec.ts
Normal file
46
frontend/src/routes/SearchFilterBar.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user