Skip to content

Commit cbd4f52

Browse files
committed
fix: align collection queries and pagination with v2 contracts
1 parent 136f3d9 commit cbd4f52

9 files changed

Lines changed: 79 additions & 28 deletions

File tree

docs/collection-query-contract.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Collection query and pagination contract
2+
3+
A saved collection is a query over the caller's accessible workspaces. An empty
4+
or whitespace-only query adds no restriction. A collection's workspace location
5+
does not replace its query scope; explicit workspace restrictions belong in CQL.
6+
Completion filters and other sub-filters narrow that same scope before counting
7+
and pagination. Removing a sub-filter must not turn an unrestricted collection
8+
into an empty result.
9+
10+
Item lists and backlog resolve saved queries through `resolveItemListQLContext`.
11+
Delta membership checks use the item list service. Board metadata projects the
12+
matching workspace IDs through the same CQL evaluator and permission scope,
13+
including when the query is empty.
14+
15+
The v2 response contract uses `page`, `page_size`, `total_items`, and `total_pages`.
16+
`collectionService.js` adapts `page_size` to the collection store's existing
17+
`limit` option once, for both items and backlog. Continuations must use that
18+
effective server size, especially when the server caps a requested size.
19+
Headers, pagination controls, and remaining counts all use `total_items`.
20+
The collection query editor opts into empty searches and consumes canonical v2
21+
pagination directly. The general search page still waits for a query.
22+
23+
Regression coverage lives in the sibling `core-tests` repository:
24+
25+
- `tests/collection_empty_query_test.go`: empty and whitespace queries,
26+
completion sub-filters, page contents and totals, board metadata, and
27+
inaccessible workspace exclusion.
28+
- `frontend/src/lib/features/collections/collectionService.test.js`: the v2
29+
pagination response adapter for item and backlog continuation.
30+
- `e2e/tests/collection-empty-query.spec.ts`: navigation beyond fifty rows,
31+
both list and query-editor pagination, toggling completion visibility from
32+
page two, and preference persistence.
33+
- `frontend/src/lib/stores/searchStore.test.js`: unrestricted empty collection
34+
searches and the general search page's initial idle state.
35+
36+
## Validation commands
37+
38+
Run from `core`:
39+
40+
```sh
41+
../core-tests/overlay.sh . -- -tags=test -count=1 -run 'TestEmptyCollection|TestCollectionMetadata|TestItemsBatch' ./tests
42+
TEST_DB_TYPE=postgres TEST_POSTGRES_DSN='postgresql://localhost:5432/postgres?sslmode=disable' ../core-tests/overlay.sh . -- -tags=test -count=1 -run 'TestEmptyCollection|TestCollectionMetadata|TestItemsBatch' ./tests
43+
../core-tests/overlay.sh . -- -tags=test -count=1 -run 'TestItemCRUDService|TestCollection.*Board|TestBoardConfiguration' ./internal/services
44+
../core-tests/run-overlay-script.sh . scripts/run-frontend-tests.sh src/lib/features/collections src/lib/stores/collectionContext.test.js src/lib/stores/collectionCompletion.test.js src/lib/stores/searchStore.test.js
45+
E2E_KEEP_ARTIFACTS=1 ../core-tests/run-e2e.sh tests/collection-empty-query.spec.ts tests/collection-completed-visibility.spec.ts tests/collection-search-columns.spec.ts --retries=0
46+
./scripts/run-golangci-lint.sh run --timeout=5m
47+
```
48+
49+
Go files are formatted with `gofmt`. Changed JavaScript and TypeScript files are
50+
checked with Biome using `frontend/biome.json`; Svelte files are excluded.
51+
Full repository suites are outside this focused validation.

frontend/src/lib/components/Pagination.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
variant="default"
103103
size="small"
104104
icon={ChevronLeft}
105+
dataTestid="pagination-previous"
105106
onclick={() => goToPage(currentPage - 1)}
106107
disabled={isFirstPage}
107108
class="px-2"
@@ -134,6 +135,7 @@
134135
variant="default"
135136
size="small"
136137
icon={ChevronRight}
138+
dataTestid="pagination-next"
137139
onclick={() => goToPage(currentPage + 1)}
138140
disabled={isLastPage}
139141
class="px-2"
@@ -152,4 +154,4 @@
152154
.pagination-container.compact .flex {
153155
gap: 0.5rem;
154156
}
155-
</style>
157+
</style>

frontend/src/lib/features/collections/CollectionBacklog.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -832,7 +832,7 @@
832832
>
833833
{collectionStore.backlogLoadingMore ? t('common.loading') : t('common.loadMore')}
834834
{#if collectionStore.backlogPagination?.total_items}
835-
({collectionStore.backlogPagination.total - collectionStore.backlogItems.length} {t('common.remaining')})
835+
({collectionStore.backlogPagination.total_items - collectionStore.backlogItems.length} {t('common.remaining')})
836836
{/if}
837837
</button>
838838
</div>

frontend/src/lib/features/collections/CollectionList.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,11 +456,11 @@
456456
</div>
457457
458458
<!-- Pagination -->
459-
{#if itemsPagination && itemsPagination.total > 0 && workItems.length > 0}
459+
{#if itemsPagination && itemsPagination.total_items > 0 && workItems.length > 0}
460460
<div class="mt-6">
461461
<Pagination
462462
currentPage={itemsPagination.page}
463-
totalItems={itemsPagination.total}
463+
totalItems={itemsPagination.total_items}
464464
itemsPerPage={itemsPagination.limit}
465465
maxItems={10000}
466466
onpageChange={handlePageChange}

frontend/src/lib/features/collections/Collections.svelte

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
let { collectionId = null } = $props();
3535
3636
// Each Collections instance owns a fresh search store — no cross-page leakage.
37-
const store = createWorkItemSearchStore();
37+
const store = createWorkItemSearchStore({ allowEmptyQuery: true });
3838
/** @type {Record<string, any>} */
3939
let storeState = $state({});
4040
const unsubscribeStore = store.subscribe((value) => (storeState = value));
@@ -154,7 +154,8 @@
154154
155155
async function loadBoardConfiguration(id) {
156156
try {
157-
const config = await api.collections.getBoardConfiguration(id);
157+
const bootstrap = await api.collections.getBoardConfigurationBootstrap(id);
158+
const config = bootstrap?.board_configuration ?? null;
158159
boardConfig = config;
159160
listColumns = listColumnsFromConfig(config);
160161
} catch (error) {
@@ -506,12 +507,12 @@
506507
rowAttrs={(item) => ({ 'data-testid': `collection-result-${item.id}` })}
507508
/>
508509
509-
{#if itemsPagination && itemsPagination.total > 0}
510+
{#if itemsPagination && itemsPagination.total_items > 0}
510511
<div class="mt-6">
511512
<Pagination
512513
currentPage={itemsPagination.page}
513-
totalItems={itemsPagination.total}
514-
itemsPerPage={itemsPagination.limit}
514+
totalItems={itemsPagination.total_items}
515+
itemsPerPage={itemsPagination.page_size}
515516
maxItems={10000}
516517
onpageChange={handlePageChange}
517518
onpageSizeChange={handlePageSizeChange}

frontend/src/lib/features/collections/collectionService.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { api } from '../../api.js';
22

3+
function collectionPagination(pagination) {
4+
if (!pagination) return null;
5+
// Store continuations use limit; v2 responses name the effective size page_size.
6+
return { ...pagination, limit: pagination.page_size };
7+
}
8+
39
/**
410
* Fetches items for a collection (or all workspace items if no collection).
511
* Handles QL query resolution and correct API parameter naming.
@@ -42,7 +48,7 @@ export async function fetchCollectionItems(
4248

4349
const response = await api.items.getAll(filters);
4450
const items = response?.data ?? [];
45-
const pagination = response?.pagination ?? null;
51+
const pagination = collectionPagination(response?.pagination);
4652
const sortableFields = response?.meta?.sortable_fields ?? [];
4753
const watermark = response?.meta?.watermark ?? 0;
4854

@@ -80,7 +86,7 @@ export async function fetchCollectionBacklog(
8086
include_watermark: true,
8187
});
8288
const items = response?.data ?? [];
83-
const pagination = response?.pagination ?? null;
89+
const pagination = collectionPagination(response?.pagination);
8490
const watermark = response?.meta?.watermark ?? 0;
8591
return { items, collectionName, pagination, watermark };
8692
}

frontend/src/lib/stores/searchStore.svelte.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { warningToast } from './toasts.svelte.js';
1515
* URL round-trip, and the polished raw-mode UX (confirm-then-snapshot,
1616
* tryParseToBuilder on reset, warning toast for dropped clauses).
1717
*/
18-
export function createWorkItemSearchStore() {
18+
export function createWorkItemSearchStore({ allowEmptyQuery = false } = {}) {
1919
// ===== Filter state =====
2020
const searchQuery = writable('');
2121
const selectedWorkspaces = writable([]);
@@ -174,7 +174,7 @@ export function createWorkItemSearchStore() {
174174
// ===== Search execution =====
175175
async function executeSearch({ page = 1, limit = 50 } = {}) {
176176
const finalQl = get(qlQuery);
177-
if (!finalQl?.trim()) {
177+
if (!allowEmptyQuery && !finalQl?.trim()) {
178178
workItems.set([]);
179179
pagination.set(null);
180180
qlError.set(null);

internal/services/collection_application_service.go

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -541,13 +541,10 @@ func (s *CollectionApplicationService) GetBoardConfigurationBootstrap(ctx contex
541541
if err != nil {
542542
return nil, err
543543
}
544-
referencedWorkspaceIDs := []int{}
545-
if collection.QLQuery != "" {
546-
referencedWorkspaceIDs, err = s.items.ListDistinctWorkspaceIDsWithQLContext(ctx, collection.QLQuery, accessibleWorkspaceIDs, userID)
547-
if err != nil {
548-
slog.Warn("board configuration bootstrap: collection CQL workspace projection failed", "collection_id", collection.ID, "error", err)
549-
referencedWorkspaceIDs = []int{}
550-
}
544+
referencedWorkspaceIDs, err := s.items.ListDistinctWorkspaceIDsWithQLContext(ctx, collection.QLQuery, accessibleWorkspaceIDs, userID)
545+
if err != nil {
546+
slog.Warn("board configuration bootstrap: collection CQL workspace projection failed", "collection_id", collection.ID, "error", err)
547+
referencedWorkspaceIDs = []int{}
551548
}
552549
if len(referencedWorkspaceIDs) == 0 {
553550
candidate := fallbackWorkspaceID

internal/services/item_crud_service.go

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -606,12 +606,6 @@ func (s *ItemCRUDService) ListWithQLPageContext(ctx context.Context, params List
606606
filters.QLArgs = resolvedQL.args
607607
}
608608

609-
// If collection was resolved but produced no effective query, return empty results.
610-
// A collection with no filter means "nothing to show yet."
611-
if resolvedQL.collectionResolved && filters.QLQuery == "" {
612-
return repository.ItemListPage{Items: []models.Item{}}, nil
613-
}
614-
615609
// Apply workspace_id filter only when no collection was resolved
616610
if !resolvedQL.collectionResolved && params.WorkspaceID > 0 {
617611
filters.WorkspaceID = &params.WorkspaceID
@@ -666,11 +660,11 @@ func (s *ItemCRUDService) ListDistinctWorkspaceIDsWithQLContext(
666660
workspaceIDs []int,
667661
userID int,
668662
) ([]int, error) {
669-
if len(workspaceIDs) == 0 || strings.TrimSpace(qlQuery) == "" {
663+
if len(workspaceIDs) == 0 {
670664
return []int{}, nil
671665
}
672666

673-
qlSQL, qlArgs, err := s.evaluateQLContext(ctx, qlQuery, cql.UserContext(userID))
667+
qlSQL, qlArgs, err := s.evaluateQLContext(ctx, strings.TrimSpace(qlQuery), cql.UserContext(userID))
674668
if err != nil {
675669
return nil, err
676670
}

0 commit comments

Comments
 (0)