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(hasTagPartial(tagQ))
|
||||||
.and(hasStatus(status));
|
.and(hasStatus(status));
|
||||||
|
|
||||||
Sort springSort = resolveSort(sort, dir);
|
|
||||||
if (sort == DocumentSort.RECEIVER) {
|
if (sort == DocumentSort.RECEIVER) {
|
||||||
List<Document> results = documentRepository.findAll(spec);
|
List<Document> results = documentRepository.findAll(spec);
|
||||||
return sortByFirstReceiver(results, dir);
|
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);
|
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) {
|
private List<Document> sortByFirstReceiver(List<Document> documents, String dir) {
|
||||||
boolean ascending = "ASC".equalsIgnoreCase(dir);
|
boolean ascending = "ASC".equalsIgnoreCase(dir);
|
||||||
Comparator<String> nullSafeComparator = (a, b) -> {
|
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.dto.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentSort;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
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).findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort));
|
||||||
verify(documentRepository, never()).findSinglePersonCorrespondence(any(), any(), any(), any());
|
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_username": "Benutzername",
|
||||||
"login_label_password": "Passwort",
|
"login_label_password": "Passwort",
|
||||||
"login_btn_submit": "Anmelden",
|
"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_filter": "Filter",
|
||||||
"docs_btn_reset_title": "Filter zurücksetzen",
|
"docs_btn_reset_title": "Filter zurücksetzen",
|
||||||
"docs_filter_label_tags": "Schlagworte",
|
"docs_filter_label_tags": "Schlagworte",
|
||||||
|
|||||||
@@ -52,7 +52,15 @@
|
|||||||
"login_label_username": "Username",
|
"login_label_username": "Username",
|
||||||
"login_label_password": "Password",
|
"login_label_password": "Password",
|
||||||
"login_btn_submit": "Sign in",
|
"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_filter": "Filter",
|
||||||
"docs_btn_reset_title": "Reset filter",
|
"docs_btn_reset_title": "Reset filter",
|
||||||
"docs_filter_label_tags": "Tags",
|
"docs_filter_label_tags": "Tags",
|
||||||
|
|||||||
@@ -52,7 +52,15 @@
|
|||||||
"login_label_username": "Usuario",
|
"login_label_username": "Usuario",
|
||||||
"login_label_password": "Contraseña",
|
"login_label_password": "Contraseña",
|
||||||
"login_btn_submit": "Iniciar sesión",
|
"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_filter": "Filtrar",
|
||||||
"docs_btn_reset_title": "Restablecer filtro",
|
"docs_btn_reset_title": "Restablecer filtro",
|
||||||
"docs_filter_label_tags": "Etiquetas",
|
"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 {
|
interface Props {
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
allowCreation?: boolean;
|
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 inputVal = $state('');
|
||||||
let suggestions: string[] = $state([]);
|
let suggestions: string[] = $state([]);
|
||||||
@@ -101,7 +102,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={inputVal}
|
bind:value={inputVal}
|
||||||
oninput={() => fetchSuggestions(inputVal)}
|
oninput={() => { fetchSuggestions(inputVal); onTextInput?.(inputVal); }}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
onfocus={() => fetchSuggestions(inputVal)}
|
onfocus={() => fetchSuggestions(inputVal)}
|
||||||
placeholder={tags.length === 0
|
placeholder={tags.length === 0
|
||||||
|
|||||||
@@ -208,3 +208,24 @@ describe('TagInput – autocomplete', () => {
|
|||||||
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
|
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;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/users": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -212,6 +244,54 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/documents/{documentId}/comments": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -628,6 +708,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/documents/search": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -724,8 +820,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/admin/import-status": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -876,6 +970,35 @@ export interface components {
|
|||||||
sender?: components["schemas"]["Person"];
|
sender?: components["schemas"]["Person"];
|
||||||
tags?: components["schemas"]["Tag"][];
|
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: {
|
CreateUserRequest: {
|
||||||
username?: string;
|
username?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -895,6 +1018,20 @@ export interface components {
|
|||||||
name?: string;
|
name?: string;
|
||||||
permissions?: 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: {
|
CreateCommentDTO: {
|
||||||
content?: string;
|
content?: string;
|
||||||
mentionedUserIds?: string[];
|
mentionedUserIds?: string[];
|
||||||
@@ -907,6 +1044,8 @@ export interface components {
|
|||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
annotationId?: string;
|
annotationId?: string;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
|
blockId?: string;
|
||||||
|
/** Format: uuid */
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
authorId?: string;
|
authorId?: string;
|
||||||
@@ -1038,18 +1177,18 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
number?: number;
|
number?: number;
|
||||||
sort?: components["schemas"]["SortObject"];
|
sort?: components["schemas"]["SortObject"];
|
||||||
/** Format: int32 */
|
|
||||||
numberOfElements?: number;
|
|
||||||
first?: boolean;
|
first?: boolean;
|
||||||
last?: boolean;
|
last?: boolean;
|
||||||
|
/** Format: int32 */
|
||||||
|
numberOfElements?: number;
|
||||||
empty?: boolean;
|
empty?: boolean;
|
||||||
};
|
};
|
||||||
PageableObject: {
|
PageableObject: {
|
||||||
|
paged?: boolean;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
pageNumber?: number;
|
pageNumber?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
paged?: boolean;
|
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
offset?: number;
|
offset?: number;
|
||||||
sort?: components["schemas"]["SortObject"];
|
sort?: components["schemas"]["SortObject"];
|
||||||
@@ -1085,6 +1224,22 @@ export interface components {
|
|||||||
snapshot: string;
|
snapshot: string;
|
||||||
changedFields: 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: {
|
IncompleteDocumentDTO: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
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: {
|
getAllUsers: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
getDocumentComments: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
search_1: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@@ -2365,8 +2764,13 @@ export interface operations {
|
|||||||
senderId?: string;
|
senderId?: string;
|
||||||
receiverId?: string;
|
receiverId?: string;
|
||||||
tag?: string[];
|
tag?: string[];
|
||||||
|
tagQ?: string;
|
||||||
/** @description Filter by document status */
|
/** @description Filter by document status */
|
||||||
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
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;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -2380,7 +2784,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"*/*": components["schemas"]["Document"][];
|
"*/*": components["schemas"]["DocumentSearchResult"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -2500,7 +2904,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
// getResetTokenForTest removed — @Operation(hidden=true) on AuthE2EController.
|
|
||||||
importStatus: {
|
importStatus: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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 senderId = url.searchParams.get('senderId') || '';
|
||||||
const receiverId = url.searchParams.get('receiverId') || '';
|
const receiverId = url.searchParams.get('receiverId') || '';
|
||||||
const tags = url.searchParams.getAll('tag');
|
const tags = url.searchParams.getAll('tag');
|
||||||
|
const sort = url.searchParams.get('sort') || 'DATE';
|
||||||
|
const dir = url.searchParams.get('dir') || 'desc';
|
||||||
|
const tagQ = url.searchParams.get('tagQ') || '';
|
||||||
|
|
||||||
const isDashboard = !q && !from && !to && !senderId && !receiverId && !tags.length;
|
const isDashboard = !q && !from && !to && !senderId && !receiverId && !tags.length;
|
||||||
|
|
||||||
@@ -30,7 +33,10 @@ export async function load({ url, fetch }) {
|
|||||||
to: to || undefined,
|
to: to || undefined,
|
||||||
senderId: senderId || undefined,
|
senderId: senderId || undefined,
|
||||||
receiverId: receiverId || 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');
|
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 {
|
const allPersons = (personsResult.data ?? []) as {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@@ -80,6 +88,7 @@ export async function load({ url, fetch }) {
|
|||||||
return {
|
return {
|
||||||
isDashboard,
|
isDashboard,
|
||||||
documents,
|
documents,
|
||||||
|
total,
|
||||||
stats,
|
stats,
|
||||||
incompleteDocs,
|
incompleteDocs,
|
||||||
recentDocs,
|
recentDocs,
|
||||||
@@ -87,7 +96,7 @@ export async function load({ url, fetch }) {
|
|||||||
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}` : '',
|
||||||
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.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
|
error: null as string | null
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -96,11 +105,12 @@ export async function load({ url, fetch }) {
|
|||||||
return {
|
return {
|
||||||
isDashboard,
|
isDashboard,
|
||||||
documents: [],
|
documents: [],
|
||||||
|
total: 0,
|
||||||
stats: null,
|
stats: null,
|
||||||
incompleteDocs: [],
|
incompleteDocs: [],
|
||||||
recentDocs: [],
|
recentDocs: [],
|
||||||
initialValues: { senderName: '', receiverName: '' },
|
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
|
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 senderId = $state(untrack(() => data.filters?.senderId || ''));
|
||||||
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
||||||
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
|
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) =>
|
const hasAdvancedFilters = (filters: typeof data.filters) =>
|
||||||
(filters?.tags?.length ?? 0) > 0 ||
|
(filters?.tags?.length ?? 0) > 0 ||
|
||||||
@@ -39,6 +42,9 @@ function triggerSearch() {
|
|||||||
if (senderId) params.set('senderId', senderId);
|
if (senderId) params.set('senderId', senderId);
|
||||||
if (receiverId) params.set('receiverId', receiverId);
|
if (receiverId) params.set('receiverId', receiverId);
|
||||||
if (tagNames) tagNames.forEach((tag) => params.append('tag', tag));
|
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 });
|
goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +72,9 @@ $effect(() => {
|
|||||||
senderId = data.filters?.senderId || '';
|
senderId = data.filters?.senderId || '';
|
||||||
receiverId = data.filters?.receiverId || '';
|
receiverId = data.filters?.receiverId || '';
|
||||||
tagNames = data.filters?.tags || [];
|
tagNames = data.filters?.tags || [];
|
||||||
|
sort = data.filters?.sort || 'DATE';
|
||||||
|
dir = data.filters?.dir || 'desc';
|
||||||
|
tagQ = data.filters?.tagQ || '';
|
||||||
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
|
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,6 +97,9 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
|
|||||||
bind:receiverId={receiverId}
|
bind:receiverId={receiverId}
|
||||||
bind:tagNames={tagNames}
|
bind:tagNames={tagNames}
|
||||||
bind:showAdvanced={showAdvanced}
|
bind:showAdvanced={showAdvanced}
|
||||||
|
bind:sort={sort}
|
||||||
|
bind:dir={dir}
|
||||||
|
bind:tagQ={tagQ}
|
||||||
initialSenderName={data.initialValues?.senderName}
|
initialSenderName={data.initialValues?.senderName}
|
||||||
initialReceiverName={data.initialValues?.receiverName}
|
initialReceiverName={data.initialValues?.receiverName}
|
||||||
onSearch={handleTextSearch}
|
onSearch={handleTextSearch}
|
||||||
@@ -119,6 +131,12 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
|
|||||||
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
|
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { formatDate } from '$lib/utils/date';
|
|||||||
let {
|
let {
|
||||||
documents,
|
documents,
|
||||||
canWrite,
|
canWrite,
|
||||||
error
|
error,
|
||||||
|
total = 0,
|
||||||
|
q = ''
|
||||||
}: {
|
}: {
|
||||||
documents: {
|
documents: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,6 +22,8 @@ let {
|
|||||||
}[];
|
}[];
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
total?: number;
|
||||||
|
q?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -41,6 +45,11 @@ let {
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 -->
|
<!-- DOCUMENT LIST -->
|
||||||
<div class="border border-line bg-surface shadow-sm">
|
<div class="border border-line bg-surface shadow-sm">
|
||||||
{#if error}
|
{#if error}
|
||||||
@@ -162,7 +171,7 @@ let {
|
|||||||
</div>
|
</div>
|
||||||
<h3 class="font-serif text-lg font-medium text-ink">{m.docs_empty_heading()}</h3>
|
<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">
|
<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>
|
</p>
|
||||||
<button
|
<button
|
||||||
onclick={() => goto('/')}
|
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">
|
<script lang="ts">
|
||||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||||
import TagInput from '$lib/components/TagInput.svelte';
|
import TagInput from '$lib/components/TagInput.svelte';
|
||||||
|
import SortDropdown from '$lib/components/SortDropdown.svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
@@ -11,6 +12,9 @@ let {
|
|||||||
senderId = $bindable(''),
|
senderId = $bindable(''),
|
||||||
receiverId = $bindable(''),
|
receiverId = $bindable(''),
|
||||||
tagNames = $bindable<string[]>([]),
|
tagNames = $bindable<string[]>([]),
|
||||||
|
tagQ = $bindable(''),
|
||||||
|
sort = $bindable('DATE'),
|
||||||
|
dir = $bindable('desc'),
|
||||||
showAdvanced = $bindable(false),
|
showAdvanced = $bindable(false),
|
||||||
initialSenderName = '',
|
initialSenderName = '',
|
||||||
initialReceiverName = '',
|
initialReceiverName = '',
|
||||||
@@ -24,6 +28,9 @@ let {
|
|||||||
senderId?: string;
|
senderId?: string;
|
||||||
receiverId?: string;
|
receiverId?: string;
|
||||||
tagNames?: string[];
|
tagNames?: string[];
|
||||||
|
tagQ?: string;
|
||||||
|
sort?: string;
|
||||||
|
dir?: string;
|
||||||
showAdvanced?: boolean;
|
showAdvanced?: boolean;
|
||||||
initialSenderName?: string;
|
initialSenderName?: string;
|
||||||
initialReceiverName?: string;
|
initialReceiverName?: string;
|
||||||
@@ -31,6 +38,20 @@ let {
|
|||||||
onfocus?: () => void;
|
onfocus?: () => void;
|
||||||
onblur?: () => void;
|
onblur?: () => void;
|
||||||
} = $props();
|
} = $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>
|
</script>
|
||||||
|
|
||||||
<div class="mb-8 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
<div class="mb-8 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
@@ -58,6 +79,9 @@ let {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sort Dropdown -->
|
||||||
|
<SortDropdown bind:sort={sort} bind:dir={dir} />
|
||||||
|
|
||||||
<!-- Toggle Advanced Button -->
|
<!-- Toggle Advanced Button -->
|
||||||
<button
|
<button
|
||||||
onclick={() => (showAdvanced = !showAdvanced)}
|
onclick={() => (showAdvanced = !showAdvanced)}
|
||||||
@@ -98,7 +122,14 @@ let {
|
|||||||
<p class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase">
|
<p class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||||
{m.docs_filter_label_tags()}
|
{m.docs_filter_label_tags()}
|
||||||
</p>
|
</p>
|
||||||
<TagInput bind:tags={tagNames} allowCreation={false} />
|
<TagInput
|
||||||
|
bind:tags={tagNames}
|
||||||
|
allowCreation={false}
|
||||||
|
onTextInput={(text) => {
|
||||||
|
tagQ = text;
|
||||||
|
onSearch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sender -->
|
<!-- 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 () => {
|
it('sets isDashboard false and skips widget APIs when q is set', async () => {
|
||||||
const mockGet = vi
|
const mockGet = vi
|
||||||
.fn()
|
.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
|
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // persons
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
@@ -146,7 +149,10 @@ describe('home page load — search mode', () => {
|
|||||||
it('passes search params from the URL to the documents API', async () => {
|
it('passes search params from the URL to the documents API', async () => {
|
||||||
const mockGet = vi
|
const mockGet = vi
|
||||||
.fn()
|
.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: [] });
|
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] });
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
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 ─────────────────────────────────────────────────────────────
|
// ─── 401 redirect ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('home page load — auth redirect', () => {
|
describe('home page load — auth redirect', () => {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ describe('Home page – search bar', () => {
|
|||||||
it('renders the full-text search input', async () => {
|
it('renders the full-text search input', async () => {
|
||||||
render(Page, { data: emptyData });
|
render(Page, { data: emptyData });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...'))
|
.element(page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026'))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
await page.screenshot({ path: 'test-results/screenshots/home-default.png' });
|
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 () => {
|
it('pre-fills the search input from filters.q', async () => {
|
||||||
render(Page, { data: { ...emptyData, filters: { ...emptyData.filters, q: 'Urlaub' } } });
|
render(Page, { data: { ...emptyData, filters: { ...emptyData.filters, q: 'Urlaub' } } });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...'))
|
.element(page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026'))
|
||||||
.toHaveValue('Urlaub');
|
.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 () => {
|
it('does not overwrite the search input while the user is focused and stale data arrives', async () => {
|
||||||
const { rerender } = render(Page, { data: emptyData });
|
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
|
// User types "abc" — input is focused
|
||||||
await input.click();
|
await input.click();
|
||||||
@@ -239,3 +239,27 @@ describe('Home page – error state', () => {
|
|||||||
await page.screenshot({ path: 'test-results/screenshots/home-error.png' });
|
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