Compare commits
4 Commits
3311dc29ae
...
4f741a8701
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f741a8701 | ||
|
|
a8edd0d9fd | ||
|
|
baf7ae3420 | ||
|
|
6808d9fb03 |
@@ -0,0 +1,137 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.test.annotation.DirtiesContext;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end paged search test with real PostgreSQL (Testcontainers). Covers the
|
||||||
|
* Specification→Pageable→Page→DTO path that unit tests mock around. Seeds 120
|
||||||
|
* UPLOADED documents and asserts the slice/total/totalPages arithmetic holds
|
||||||
|
* against the actual JPA query.
|
||||||
|
*
|
||||||
|
* <p>Closes the integration-coverage gap Sara flagged on PR #316.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||||
|
class DocumentSearchPagedIntegrationTest {
|
||||||
|
|
||||||
|
private static final int FIXTURE_SIZE = 120;
|
||||||
|
|
||||||
|
@MockitoBean S3Client s3Client;
|
||||||
|
@Autowired DocumentService documentService;
|
||||||
|
@Autowired DocumentRepository documentRepository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
// Deterministic date spread so DATE-DESC order is predictable:
|
||||||
|
// document #0 has the oldest date, document #119 has the newest.
|
||||||
|
for (int i = 0; i < FIXTURE_SIZE; i++) {
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.title("Dok-" + String.format("%03d", i))
|
||||||
|
.originalFilename("dok-" + i + ".pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.documentDate(LocalDate.of(1900, 1, 1).plusDays(i))
|
||||||
|
.build();
|
||||||
|
documentRepository.save(doc);
|
||||||
|
}
|
||||||
|
assertThat(documentRepository.count()).isEqualTo(FIXTURE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.DATE, "DESC", null,
|
||||||
|
PageRequest.of(0, 50));
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(50);
|
||||||
|
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||||
|
assertThat(result.pageNumber()).isZero();
|
||||||
|
assertThat(result.pageSize()).isEqualTo(50);
|
||||||
|
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_lastPartialPage_returnsRemainingItems() {
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.DATE, "DESC", null,
|
||||||
|
PageRequest.of(2, 50));
|
||||||
|
|
||||||
|
// Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
|
||||||
|
assertThat(result.items()).hasSize(20);
|
||||||
|
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||||
|
assertThat(result.pageNumber()).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.DATE, "DESC", null,
|
||||||
|
PageRequest.of(99, 50));
|
||||||
|
|
||||||
|
assertThat(result.items()).isEmpty();
|
||||||
|
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_senderSort_pageOne_slicesInMemory_withCorrectTotal() {
|
||||||
|
// SENDER sort path fetches all + sorts + slices in-memory (see scaling
|
||||||
|
// comment in DocumentService). Proves that the in-memory slice path
|
||||||
|
// returns the correct total from a real repository fetch.
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.SENDER, "asc", null,
|
||||||
|
PageRequest.of(1, 50));
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(50);
|
||||||
|
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||||
|
assertThat(result.pageNumber()).isEqualTo(1);
|
||||||
|
assertThat(result.totalPages()).isEqualTo(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_differentPagesReturnDisjointSlices() {
|
||||||
|
DocumentSearchResult page0 = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.DATE, "DESC", null,
|
||||||
|
PageRequest.of(0, 50));
|
||||||
|
DocumentSearchResult page1 = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.DATE, "DESC", null,
|
||||||
|
PageRequest.of(1, 50));
|
||||||
|
|
||||||
|
// No document id should appear on both pages — slicing must be exclusive.
|
||||||
|
var idsOnPage0 = page0.items().stream()
|
||||||
|
.map(item -> item.document().getId())
|
||||||
|
.toList();
|
||||||
|
var idsOnPage1 = page1.items().stream()
|
||||||
|
.map(item -> item.document().getId())
|
||||||
|
.toList();
|
||||||
|
for (UUID id : idsOnPage0) {
|
||||||
|
assertThat(idsOnPage1).doesNotContain(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ import static org.mockito.Mockito.when;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class DocumentServiceSortTest {
|
class DocumentServiceSortTest {
|
||||||
|
|
||||||
|
private static final Pageable UNPAGED = org.springframework.data.domain.PageRequest.of(0, 10_000);
|
||||||
|
|
||||||
@Mock DocumentRepository documentRepository;
|
@Mock DocumentRepository documentRepository;
|
||||||
@Mock PersonService personService;
|
@Mock PersonService personService;
|
||||||
@Mock FileService fileService;
|
@Mock FileService fileService;
|
||||||
@@ -57,7 +59,7 @@ class DocumentServiceSortTest {
|
|||||||
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, UNPAGED);
|
||||||
|
|
||||||
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
||||||
assertThat(result.items()).hasSize(2);
|
assertThat(result.items()).hasSize(2);
|
||||||
@@ -79,7 +81,7 @@ class DocumentServiceSortTest {
|
|||||||
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||||
|
|
||||||
// Expect: rank order restored (id1 first)
|
// Expect: rank order restored (id1 first)
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||||
@@ -98,7 +100,7 @@ class DocumentServiceSortTest {
|
|||||||
.thenReturn(List.of(doc2, doc1));
|
.thenReturn(List.of(doc2, doc1));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, null, null, null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
"Brief", null, null, null, null, null, null, null, null, null, null, UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import org.raddatz.familienarchiv.model.Tag;
|
|||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
@@ -46,6 +47,12 @@ import static org.mockito.Mockito.*;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class DocumentServiceTest {
|
class DocumentServiceTest {
|
||||||
|
|
||||||
|
// Used by tests that don't care about paging. 10 000 is chosen large enough
|
||||||
|
// to hold any fixture in this file but small enough that totalPages math
|
||||||
|
// stays in int range. Swap to `PageRequest.of(0, 10_000)` elsewhere is a
|
||||||
|
// red flag — use this constant.
|
||||||
|
private static final Pageable UNPAGED = PageRequest.of(0, 10_000);
|
||||||
|
|
||||||
@Mock DocumentRepository documentRepository;
|
@Mock DocumentRepository documentRepository;
|
||||||
@Mock PersonService personService;
|
@Mock PersonService personService;
|
||||||
@Mock FileService fileService;
|
@Mock FileService fileService;
|
||||||
@@ -1428,7 +1435,7 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, UNPAGED);
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||||
}
|
}
|
||||||
@@ -1438,7 +1445,7 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, UNPAGED);
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||||
}
|
}
|
||||||
@@ -1516,7 +1523,7 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(withSender, noSender));
|
.thenReturn(List.of(withSender, noSender));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(2);
|
assertThat(result.items()).hasSize(2);
|
||||||
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
|
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
|
||||||
@@ -1536,7 +1543,7 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(noReceivers, withReceiver));
|
.thenReturn(List.of(noReceivers, withReceiver));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||||
.containsExactly("Has Receiver", "No Receivers");
|
.containsExactly("Has Receiver", "No Receivers");
|
||||||
@@ -1558,7 +1565,7 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(docNullName, docSmith));
|
.thenReturn(List.of(docNullName, docSmith));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||||
|
|
||||||
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||||
@@ -1580,7 +1587,7 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(1);
|
assertThat(result.items()).hasSize(1);
|
||||||
SearchMatchData md = result.items().get(0).matchData();
|
SearchMatchData md = result.items().get(0).matchData();
|
||||||
@@ -1595,7 +1602,7 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, null, null, null,
|
null, null, null, null, null, null, null, null, null, null, null,
|
||||||
org.springframework.data.domain.PageRequest.of(0, 10_000));
|
UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items()).isEmpty();
|
assertThat(result.items()).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -1614,7 +1621,7 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, org.springframework.data.domain.PageRequest.of(0, 10_000));
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||||
|
|
||||||
SearchMatchData md = result.items().get(0).matchData();
|
SearchMatchData md = result.items().get(0).matchData();
|
||||||
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ const { page, totalPages, makeHref, ariaLabel }: Props = $props();
|
|||||||
|
|
||||||
const hasPrev = $derived(page > 0);
|
const hasPrev = $derived(page > 0);
|
||||||
const hasNext = $derived(page < totalPages - 1);
|
const hasNext = $derived(page < totalPages - 1);
|
||||||
const linkBase =
|
const controlBase =
|
||||||
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-40';
|
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink';
|
||||||
|
const linkBase = `${controlBase} transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none`;
|
||||||
|
const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if totalPages > 1}
|
{#if totalPages > 1}
|
||||||
@@ -25,16 +27,30 @@ const linkBase =
|
|||||||
aria-label={ariaLabel ?? m.pagination_nav_label()}
|
aria-label={ariaLabel ?? m.pagination_nav_label()}
|
||||||
class="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-between"
|
class="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-between"
|
||||||
>
|
>
|
||||||
<a
|
<!--
|
||||||
data-testid="pagination-prev"
|
At the bounds we render a <span aria-hidden="true"> instead of an
|
||||||
aria-label={m.pagination_prev()}
|
<a aria-disabled>. aria-disabled on a link is the documented pattern
|
||||||
aria-disabled={!hasPrev}
|
but screen readers still announce "Previous, link, disabled" — which
|
||||||
href={hasPrev ? makeHref(page - 1) : undefined}
|
is confusing on a pagination control where the disabled state is
|
||||||
class={linkBase}
|
purely visual. Hiding the element from the AT tree entirely is the
|
||||||
>
|
cleaner semantic.
|
||||||
<span aria-hidden="true">«</span>
|
-->
|
||||||
{m.pagination_prev()}
|
{#if hasPrev}
|
||||||
</a>
|
<a
|
||||||
|
data-testid="pagination-prev"
|
||||||
|
aria-label={m.pagination_prev()}
|
||||||
|
href={makeHref(page - 1)}
|
||||||
|
class={linkBase}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">«</span>
|
||||||
|
{m.pagination_prev()}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span data-testid="pagination-prev" aria-hidden="true" class={disabledBase}>
|
||||||
|
<span aria-hidden="true">«</span>
|
||||||
|
{m.pagination_prev()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<span
|
<span
|
||||||
data-testid="pagination-page-label"
|
data-testid="pagination-page-label"
|
||||||
@@ -44,15 +60,21 @@ const linkBase =
|
|||||||
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<a
|
{#if hasNext}
|
||||||
data-testid="pagination-next"
|
<a
|
||||||
aria-label={m.pagination_next()}
|
data-testid="pagination-next"
|
||||||
aria-disabled={!hasNext}
|
aria-label={m.pagination_next()}
|
||||||
href={hasNext ? makeHref(page + 1) : undefined}
|
href={makeHref(page + 1)}
|
||||||
class={linkBase}
|
class={linkBase}
|
||||||
>
|
>
|
||||||
{m.pagination_next()}
|
{m.pagination_next()}
|
||||||
<span aria-hidden="true">»</span>
|
<span aria-hidden="true">»</span>
|
||||||
</a>
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span data-testid="pagination-next" aria-hidden="true" class={disabledBase}>
|
||||||
|
{m.pagination_next()}
|
||||||
|
<span aria-hidden="true">»</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -33,12 +33,14 @@ describe('Pagination', () => {
|
|||||||
await expect.element(prev).toHaveAttribute('href', '/documents?page=3');
|
await expect.element(prev).toHaveAttribute('href', '/documents?page=3');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables prev on page 0 (no href, aria-disabled="true")', async () => {
|
it('renders disabled prev as an aria-hidden non-link so screen readers skip it', async () => {
|
||||||
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||||
|
|
||||||
const prev = page.getByTestId('pagination-prev');
|
const prev = page.getByTestId('pagination-prev');
|
||||||
await expect.element(prev).toHaveAttribute('aria-disabled', 'true');
|
// Not a link — no href, no role=link
|
||||||
await expect.element(prev).not.toHaveAttribute('href');
|
await expect.element(prev).not.toHaveAttribute('href');
|
||||||
|
// Hidden from assistive tech — AT shouldn't read "Previous, link, disabled"
|
||||||
|
await expect.element(prev).toHaveAttribute('aria-hidden', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders next as a link pointing at page + 1 when not on last page', async () => {
|
it('renders next as a link pointing at page + 1 when not on last page', async () => {
|
||||||
@@ -48,12 +50,12 @@ describe('Pagination', () => {
|
|||||||
await expect.element(next).toHaveAttribute('href', '/documents?page=1');
|
await expect.element(next).toHaveAttribute('href', '/documents?page=1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables next on the last page (no href, aria-disabled="true")', async () => {
|
it('renders disabled next as an aria-hidden non-link on the last page', async () => {
|
||||||
render(Pagination, { page: 2, totalPages: 3, makeHref });
|
render(Pagination, { page: 2, totalPages: 3, makeHref });
|
||||||
|
|
||||||
const next = page.getByTestId('pagination-next');
|
const next = page.getByTestId('pagination-next');
|
||||||
await expect.element(next).toHaveAttribute('aria-disabled', 'true');
|
|
||||||
await expect.element(next).not.toHaveAttribute('href');
|
await expect.element(next).not.toHaveAttribute('href');
|
||||||
|
await expect.element(next).toHaveAttribute('aria-hidden', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls makeHref with p-1 and p+1', async () => {
|
it('calls makeHref with p-1 and p+1', async () => {
|
||||||
|
|||||||
@@ -36,46 +36,84 @@ let showAdvanced = $state(untrack(hasAdvancedFilters));
|
|||||||
|
|
||||||
let searchTimer: ReturnType<typeof setTimeout>;
|
let searchTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
type FilterSnapshot = {
|
||||||
|
q: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
senderId: string;
|
||||||
|
receiverId: string;
|
||||||
|
tags: string[];
|
||||||
|
sort: string;
|
||||||
|
dir: string;
|
||||||
|
tagQ: string;
|
||||||
|
tagOp: 'AND' | 'OR';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a URLSearchParams from a filter snapshot. Single source of truth for
|
||||||
|
* which params the `/documents` URL understands — add a filter here and both
|
||||||
|
* filter-change nav (triggerSearch) and page nav (buildPageHref) will pick it
|
||||||
|
* up. `page` is appended only when > 0 so the default page 0 stays out of the
|
||||||
|
* URL, keeping the filter-change-resets-to-page-0 behaviour implicit.
|
||||||
|
*/
|
||||||
|
function buildSearchParams(filters: FilterSnapshot, targetPage?: number): SvelteURLSearchParams {
|
||||||
|
const params = new SvelteURLSearchParams();
|
||||||
|
if (filters.q) params.set('q', filters.q);
|
||||||
|
if (filters.from) params.set('from', filters.from);
|
||||||
|
if (filters.to) params.set('to', filters.to);
|
||||||
|
if (filters.senderId) params.set('senderId', filters.senderId);
|
||||||
|
if (filters.receiverId) params.set('receiverId', filters.receiverId);
|
||||||
|
filters.tags.forEach((tag) => params.append('tag', tag));
|
||||||
|
if (filters.sort) params.set('sort', filters.sort);
|
||||||
|
if (filters.dir) params.set('dir', filters.dir);
|
||||||
|
if (filters.tagQ) params.set('tagQ', filters.tagQ);
|
||||||
|
if (filters.tagOp === 'OR') params.set('tagOp', 'OR');
|
||||||
|
if (targetPage !== undefined && targetPage > 0) params.set('page', String(targetPage));
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuilds the URL from the CURRENT local filter state. `page` is intentionally
|
* Rebuilds the URL from the CURRENT local filter state. `page` is intentionally
|
||||||
* not carried over — any filter change implicitly resets back to page 0, which
|
* not carried over — any filter change implicitly resets back to page 0.
|
||||||
* is the expected behaviour. For page-only navigation use {@link buildPageHref}
|
|
||||||
* instead, which preserves every filter from the server `data`.
|
|
||||||
*/
|
*/
|
||||||
function triggerSearch() {
|
function triggerSearch() {
|
||||||
const params = new SvelteURLSearchParams();
|
const params = buildSearchParams({
|
||||||
if (q) params.set('q', q);
|
q,
|
||||||
if (from) params.set('from', from);
|
from,
|
||||||
if (to) params.set('to', to);
|
to,
|
||||||
if (senderId) params.set('senderId', senderId);
|
senderId,
|
||||||
if (receiverId) params.set('receiverId', receiverId);
|
receiverId,
|
||||||
tagNames.forEach((tag) => params.append('tag', tag.name));
|
tags: tagNames.map((t) => t.name),
|
||||||
if (sort) params.set('sort', sort);
|
sort,
|
||||||
if (dir) params.set('dir', dir);
|
dir,
|
||||||
if (tagQ) params.set('tagQ', tagQ);
|
tagQ,
|
||||||
if (tagOperator === 'OR') params.set('tagOp', 'OR');
|
tagOp: tagOperator
|
||||||
|
});
|
||||||
goto(`/documents?${params.toString()}`, { keepFocus: true, noScroll: true });
|
goto(`/documents?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the href for a Pagination prev/next link. Preserves every current
|
* Builds the href for a Pagination prev/next link. Preserves every filter
|
||||||
* filter param and only updates `page`. Uses a normal <a href> (not goto)
|
* param from server `data` and updates `page`. Uses a normal <a href> (not
|
||||||
* so SvelteKit's default scroll restoration brings the user to the top of
|
* goto) so SvelteKit's default scroll restoration brings the user to the top
|
||||||
* the new slice — the expected behaviour for page navigation.
|
* of the new slice — the expected behaviour for page navigation.
|
||||||
*/
|
*/
|
||||||
function buildPageHref(targetPage: number): string {
|
function buildPageHref(targetPage: number): string {
|
||||||
const params = new SvelteURLSearchParams();
|
const params = buildSearchParams(
|
||||||
if (data.q) params.set('q', data.q);
|
{
|
||||||
if (data.from) params.set('from', data.from);
|
q: data.q || '',
|
||||||
if (data.to) params.set('to', data.to);
|
from: data.from || '',
|
||||||
if (data.senderId) params.set('senderId', data.senderId);
|
to: data.to || '',
|
||||||
if (data.receiverId) params.set('receiverId', data.receiverId);
|
senderId: data.senderId || '',
|
||||||
(data.tags || []).forEach((t: string) => params.append('tag', t));
|
receiverId: data.receiverId || '',
|
||||||
if (data.sort) params.set('sort', data.sort);
|
tags: data.tags || [],
|
||||||
if (data.dir) params.set('dir', data.dir);
|
sort: data.sort || '',
|
||||||
if (data.tagQ) params.set('tagQ', data.tagQ);
|
dir: data.dir || '',
|
||||||
if (data.tagOp === 'OR') params.set('tagOp', 'OR');
|
tagQ: data.tagQ || '',
|
||||||
if (targetPage > 0) params.set('page', String(targetPage));
|
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND'
|
||||||
|
},
|
||||||
|
targetPage
|
||||||
|
);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return qs ? `/documents?${qs}` : '/documents';
|
return qs ? `/documents?${qs}` : '/documents';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user