Compare commits
2 Commits
19cd17d9cd
...
c6137a26a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6137a26a2 | ||
|
|
a3c3f14aea |
@@ -15,24 +15,45 @@ public record DocumentSearchResult(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int pageSize,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int totalPages
|
||||
int totalPages,
|
||||
/**
|
||||
* Total number of undated documents (meta_date IS NULL) matching the current
|
||||
* filter context (q/tags/sender/receiver/status) across ALL pages — not the
|
||||
* undated rows on the current page. Computed independently of the "Nur
|
||||
* undatierte" toggle so it never collapses to the page slice (issue #668).
|
||||
*/
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
long undatedCount
|
||||
) {
|
||||
/**
|
||||
* Single-page convenience factory used by empty-result shortcuts and by tests that
|
||||
* don't care about paging. Treats the whole list as page 0 of itself.
|
||||
* don't care about paging. Treats the whole list as page 0 of itself. The undated
|
||||
* count defaults to 0 — the service overlays the real global count via
|
||||
* {@link #withUndatedCount(long)} before returning.
|
||||
*/
|
||||
public static DocumentSearchResult of(List<DocumentListItem> items) {
|
||||
int size = items.size();
|
||||
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
|
||||
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1, 0L);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paged factory used by the service when it has a real Pageable + full match count
|
||||
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
|
||||
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice). The undated
|
||||
* count defaults to 0 — the service overlays the real global count via
|
||||
* {@link #withUndatedCount(long)} before returning.
|
||||
*/
|
||||
public static DocumentSearchResult paged(List<DocumentListItem> slice, Pageable pageable, long totalElements) {
|
||||
int pageSize = pageable.getPageSize();
|
||||
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
|
||||
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
|
||||
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages, 0L);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with the global undated count overlaid, leaving every other
|
||||
* field untouched. Lets the service compute the count once and attach it to
|
||||
* whichever result shape the search path produced.
|
||||
*/
|
||||
public DocumentSearchResult withUndatedCount(long undatedCount) {
|
||||
return new DocumentSearchResult(items, totalElements, pageNumber, pageSize, totalPages, undatedCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,6 +669,43 @@ public class DocumentService {
|
||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, boolean undated, Pageable pageable) {
|
||||
boolean hasText = StringUtils.hasText(text);
|
||||
|
||||
List<UUID> rankedIds = null;
|
||||
if (hasText) {
|
||||
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
|
||||
// FTS matched nothing → no results and, by definition, no undated matches either.
|
||||
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
||||
}
|
||||
|
||||
// Global undated count for the current filter (q/tags/sender/receiver/status),
|
||||
// forcing undatedOnly(true) and IGNORING the user's "Nur undatierte" toggle so
|
||||
// it never collapses to the page slice and never double-counts (issue #668).
|
||||
long undatedCount = countUndatedForFilter(hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
||||
|
||||
return runSearch(text, hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, sort, dir, tagOperator, undated, pageable)
|
||||
.withUndatedCount(undatedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts every undated document (meta_date IS NULL) matching the active filter,
|
||||
* across all pages, independent of the undated toggle. Reuses {@link #buildSearchSpec}
|
||||
* with {@code undated=true} forced so the count tracks q/tags/sender/receiver/status.
|
||||
* A {@code from}/{@code to} range excludes undated rows by the collision rule (#668),
|
||||
* so the count is legitimately 0 inside a date range.
|
||||
*/
|
||||
private long countUndatedForFilter(boolean hasText, List<UUID> ftsIds,
|
||||
LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
||||
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
|
||||
Specification<Document> undatedSpec = buildSearchSpec(
|
||||
hasText, ftsIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, true);
|
||||
return documentRepository.count(undatedSpec);
|
||||
}
|
||||
|
||||
/** The original search dispatch — produces the page slice + totals, sans undated count. */
|
||||
private DocumentSearchResult runSearch(String text, boolean hasText, List<UUID> rankedIds,
|
||||
LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
||||
List<String> tags, String tagQ, DocumentStatus status,
|
||||
DocumentSort sort, String dir, TagOperator tagOperator,
|
||||
boolean undated, Pageable pageable) {
|
||||
// Pure-text RELEVANCE: push pagination into SQL — skip findAllMatchingIdsByFts entirely (ADR-008).
|
||||
// An active undated filter must NOT take this path: it bypasses buildSearchSpec, so the
|
||||
// undatedOnly predicate would be silently dropped.
|
||||
@@ -676,12 +713,6 @@ public class DocumentService {
|
||||
return relevanceSortedPageFromSql(text, pageable);
|
||||
}
|
||||
|
||||
List<UUID> rankedIds = null;
|
||||
if (hasText) {
|
||||
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
|
||||
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
||||
}
|
||||
|
||||
Specification<Document> spec = buildSearchSpec(
|
||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
|
||||
|
||||
|
||||
@@ -108,6 +108,83 @@ class DocumentSearchPagedIntegrationTest {
|
||||
assertThat(result.totalPages()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_undatedCount_isGlobalFilteredTotal_notPageSlice() {
|
||||
// Seed 70 undated docs on top of the 120 dated ones. With a 50-per-page
|
||||
// window the undated rows span multiple pages, so a page-local count could
|
||||
// never exceed 50 — the global count must be the full 70 (issue #668).
|
||||
int undatedTotal = 70;
|
||||
for (int i = 0; i < undatedTotal; i++) {
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Undatiert-" + String.format("%03d", i))
|
||||
.originalFilename("undatiert-" + i + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.metaDatePrecision(DatePrecision.UNKNOWN)
|
||||
.documentDate(null)
|
||||
.build());
|
||||
}
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
|
||||
// Global undated count is the full undated total, independent of page size.
|
||||
assertThat(result.undatedCount()).isEqualTo(undatedTotal);
|
||||
// Total matches both dated + undated (no undated-only filter applied).
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE + undatedTotal);
|
||||
// The first DATE-DESC page is all dated rows (nulls last), so a page-local
|
||||
// tally would report 0 undated — proving the count is not page-derived.
|
||||
assertThat(result.items()).allMatch(item -> item.documentDate() != null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_undatedCount_ignoresUndatedOnlyToggle() {
|
||||
// The "Nur undatierte" toggle must not skew the count: whether undated=true or
|
||||
// false, the global undated count for the same filter is identical (issue #668).
|
||||
int undatedTotal = 12;
|
||||
for (int i = 0; i < undatedTotal; i++) {
|
||||
documentRepository.save(Document.builder()
|
||||
.title("U-" + i)
|
||||
.originalFilename("u-" + i + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.metaDatePrecision(DatePrecision.UNKNOWN)
|
||||
.documentDate(null)
|
||||
.build());
|
||||
}
|
||||
|
||||
DocumentSearchResult unfiltered = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
DocumentSearchResult undatedOnly = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, true, PageRequest.of(0, 50));
|
||||
|
||||
assertThat(unfiltered.undatedCount()).isEqualTo(undatedTotal);
|
||||
assertThat(undatedOnly.undatedCount()).isEqualTo(undatedTotal);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_undatedCount_isZero_insideDateRange() {
|
||||
// A from/to range excludes undated rows by the collision rule (#668), so the
|
||||
// global undated count inside a range is legitimately 0 even when undated docs exist.
|
||||
for (int i = 0; i < 5; i++) {
|
||||
documentRepository.save(Document.builder()
|
||||
.title("U-range-" + i)
|
||||
.originalFilename("u-range-" + i + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.metaDatePrecision(DatePrecision.UNKNOWN)
|
||||
.documentDate(null)
|
||||
.build());
|
||||
}
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31),
|
||||
null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.undatedCount()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_differentPagesReturnDisjointSlices() {
|
||||
DocumentSearchResult page0 = documentService.searchDocuments(
|
||||
|
||||
@@ -99,4 +99,32 @@ class DocumentSearchResultTest {
|
||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void undatedCount_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField("undatedCount").getAnnotation(Schema.class);
|
||||
assertThat(schema).isNotNull();
|
||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void factories_default_undatedCount_to_zero() {
|
||||
assertThat(DocumentSearchResult.of(List.of()).undatedCount()).isZero();
|
||||
assertThat(DocumentSearchResult.paged(List.of(), PageRequest.of(0, 50), 0L).undatedCount()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void withUndatedCount_overlays_count_and_preserves_other_fields() {
|
||||
DocumentSearchResult base = DocumentSearchResult.paged(
|
||||
List.of(item(UUID.randomUUID())), PageRequest.of(1, 50), 120L);
|
||||
|
||||
DocumentSearchResult withCount = base.withUndatedCount(7L);
|
||||
|
||||
assertThat(withCount.undatedCount()).isEqualTo(7L);
|
||||
assertThat(withCount.items()).isEqualTo(base.items());
|
||||
assertThat(withCount.totalElements()).isEqualTo(120L);
|
||||
assertThat(withCount.pageNumber()).isEqualTo(1);
|
||||
assertThat(withCount.pageSize()).isEqualTo(50);
|
||||
assertThat(withCount.totalPages()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2471,6 +2471,8 @@ export interface components {
|
||||
pageSize: number;
|
||||
/** Format: int32 */
|
||||
totalPages: number;
|
||||
/** Format: int64 */
|
||||
undatedCount: number;
|
||||
};
|
||||
MatchOffset: {
|
||||
/** Format: int32 */
|
||||
|
||||
@@ -16,6 +16,7 @@ let {
|
||||
tagQ = $bindable(''),
|
||||
tagOperator = $bindable<'AND' | 'OR'>('AND'),
|
||||
undated = $bindable(false),
|
||||
undatedCount = 0,
|
||||
sort = $bindable('DATE'),
|
||||
dir = $bindable('desc'),
|
||||
showAdvanced = $bindable(false),
|
||||
@@ -37,6 +38,7 @@ let {
|
||||
tagQ?: string;
|
||||
tagOperator?: 'AND' | 'OR';
|
||||
undated?: boolean;
|
||||
undatedCount?: number;
|
||||
sort?: string;
|
||||
dir?: string;
|
||||
showAdvanced?: boolean;
|
||||
@@ -275,6 +277,18 @@ $effect(() => {
|
||||
{#if undated}✓{/if}
|
||||
</span>
|
||||
{m.docs_filter_undated_only()}
|
||||
<!-- Global count of undated docs matching the current filter across ALL
|
||||
pages (not the page slice). Stays visible regardless of the toggle
|
||||
state so it advertises the triage backlog size (issue #668). -->
|
||||
{#if undatedCount > 0}
|
||||
<span
|
||||
data-testid="undated-count"
|
||||
class="inline-flex min-w-[1.5rem] items-center justify-center rounded-full px-1.5 py-0.5 text-[0.65rem] leading-none tabular-nums {undated
|
||||
? 'bg-primary-fg/20 text-primary-fg'
|
||||
: 'bg-line text-ink-2'}"
|
||||
>{undatedCount}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -162,6 +162,20 @@ describe('SearchFilterBar – undated-only toggle (#668)', () => {
|
||||
await page.getByTestId('undated-only-toggle').click();
|
||||
await expect.poll(() => onSearchImmediate.mock.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows the global undated count chip when undatedCount > 0', async () => {
|
||||
// The count is the backend's global filtered total (#668), passed straight
|
||||
// through — the chip must render it verbatim, not a page-derived number.
|
||||
render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc', undatedCount: 42 });
|
||||
await openAdvanced();
|
||||
await expect.element(page.getByTestId('undated-count')).toHaveTextContent('42');
|
||||
});
|
||||
|
||||
it('hides the undated count chip when undatedCount is 0', async () => {
|
||||
render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc', undatedCount: 0 });
|
||||
await openAdvanced();
|
||||
await expect.element(page.getByTestId('undated-count')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchFilterBar – tagQ live filter', () => {
|
||||
|
||||
@@ -85,6 +85,7 @@ export async function load({ url, fetch }) {
|
||||
pageNumber: 0,
|
||||
pageSize: PAGE_SIZE,
|
||||
totalPages: 0,
|
||||
undatedCount: 0,
|
||||
q,
|
||||
from,
|
||||
to,
|
||||
@@ -116,6 +117,8 @@ export async function load({ url, fetch }) {
|
||||
pageNumber: result.data?.pageNumber ?? page,
|
||||
pageSize: result.data?.pageSize ?? PAGE_SIZE,
|
||||
totalPages: result.data?.totalPages ?? 0,
|
||||
// Global undated count for the active filter, across all pages (issue #668).
|
||||
undatedCount: result.data?.undatedCount ?? 0,
|
||||
q,
|
||||
from,
|
||||
to,
|
||||
|
||||
@@ -268,6 +268,7 @@ $effect(() => {
|
||||
bind:tagQ={tagQ}
|
||||
bind:tagOperator={tagOperator}
|
||||
bind:undated={undated}
|
||||
undatedCount={data.undatedCount ?? 0}
|
||||
initialSenderName={initialSenderName}
|
||||
initialReceiverName={initialReceiverName}
|
||||
navKey={navKey}
|
||||
|
||||
@@ -225,6 +225,51 @@ describe('documents page load — search params', () => {
|
||||
expect(result.totalElements).toBe(42);
|
||||
});
|
||||
|
||||
it('forwards the global undatedCount from the search result (#668)', async () => {
|
||||
// The backend returns the global undated total for the active filter across
|
||||
// ALL pages; the loader must pass it straight through, not recompute it locally.
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: {
|
||||
items: [],
|
||||
totalElements: 200,
|
||||
pageNumber: 0,
|
||||
pageSize: 50,
|
||||
totalPages: 4,
|
||||
undatedCount: 73
|
||||
}
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ q: 'test' }),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.undatedCount).toBe(73);
|
||||
});
|
||||
|
||||
it('defaults undatedCount to 0 when the search result omits it', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl(),
|
||||
request: new Request('http://localhost/documents'),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.undatedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('returns filter values in the result for pre-filling the UI', async () => {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
|
||||
Reference in New Issue
Block a user