diff --git a/packages/bindx-dataview/src/DataGrid.tsx b/packages/bindx-dataview/src/DataGrid.tsx index 733346fb..7989ba84 100644 --- a/packages/bindx-dataview/src/DataGrid.tsx +++ b/packages/bindx-dataview/src/DataGrid.tsx @@ -18,6 +18,7 @@ import type { StateStorageOrName } from './stateStorage.js' import { useBindxContext, useEntityList, + useEntityCount, } from '@contember/bindx-react' import { useDataViewKey } from './DataViewKeyProvider.js' import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from './DataViewContext.js' @@ -108,6 +109,12 @@ function DataGridImpl>({ const items = result.$status === 'ready' ? result.items : [] const itemCount = items.length + // ---- Total count via a standalone count query (keyed on filter only) ---- + const { count: totalCount } = useEntityCount(entity, { + filter: setup.combinedFilter, + refreshToken: setup.paging.totalCountRefreshToken, + }) + // Update loader state useEffect(() => { if (result.$status === 'ready') { @@ -120,12 +127,17 @@ function DataGridImpl>({ } }, [result.$status]) - // Update total count when data is ready + // Update total count from the count query. Falls back to the partial-page + // heuristic (last page returns fewer rows than the page size) when the count + // is not yet known — e.g. before it resolves or with adapters that don't + // implement count queries. useEffect(() => { - if (result.$status === 'ready' && setup.paging.queryLimit !== undefined && setup.paging.queryOffset !== undefined && itemCount < setup.paging.queryLimit) { + if (totalCount !== null) { + setup.paging.setTotalCount(totalCount) + } else if (result.$status === 'ready' && setup.paging.queryLimit !== undefined && setup.paging.queryOffset !== undefined && itemCount < setup.paging.queryLimit) { setup.paging.setTotalCount(setup.paging.queryOffset + itemCount) } - }, [result.$status, itemCount, setup.paging.queryLimit, setup.paging.queryOffset, setup.paging.setTotalCount]) + }, [totalCount, result.$status, itemCount, setup.paging.queryLimit, setup.paging.queryOffset, setup.paging.setTotalCount]) // ---- Reload ---- const [, setReloadCounter] = useState(0) diff --git a/packages/bindx-dataview/src/select/SelectDataView.tsx b/packages/bindx-dataview/src/select/SelectDataView.tsx index efde0646..2f10cdaf 100644 --- a/packages/bindx-dataview/src/select/SelectDataView.tsx +++ b/packages/bindx-dataview/src/select/SelectDataView.tsx @@ -37,6 +37,7 @@ import type { FieldRef } from '@contember/bindx' import { useBindxContext, useEntityList, + useEntityCount, createCollectorProxy, mergeSelections, collectSelection, @@ -165,6 +166,12 @@ function SelectDataViewImpl({ const items = result.$status === 'ready' ? result.items : [] const itemCount = items.length + // ---- Total count via a standalone count query (keyed on filter only) ---- + const { count: totalCount } = useEntityCount(options, { + filter: combinedFilter, + refreshToken: paging.totalCountRefreshToken, + }) + // Update loader state useEffect(() => { if (result.$status === 'ready') { @@ -177,12 +184,15 @@ function SelectDataViewImpl({ } }, [result.$status]) - // Update total count for paging + // Update total count from the count query, falling back to the partial-page + // heuristic until the count resolves. useEffect(() => { - if (result.$status === 'ready' && paging.queryLimit !== undefined && paging.queryOffset !== undefined && itemCount < paging.queryLimit) { + if (totalCount !== null) { + paging.setTotalCount(totalCount) + } else if (result.$status === 'ready' && paging.queryLimit !== undefined && paging.queryOffset !== undefined && itemCount < paging.queryLimit) { paging.setTotalCount(paging.queryOffset + itemCount) } - }, [result.$status, itemCount, paging.queryLimit, paging.queryOffset, paging.setTotalCount]) + }, [totalCount, result.$status, itemCount, paging.queryLimit, paging.queryOffset, paging.setTotalCount]) // ---- Reload ---- const [, setReloadCounter] = useState(0) diff --git a/packages/bindx-dataview/src/useDataView.ts b/packages/bindx-dataview/src/useDataView.ts index f008f389..94c233fa 100644 --- a/packages/bindx-dataview/src/useDataView.ts +++ b/packages/bindx-dataview/src/useDataView.ts @@ -6,7 +6,7 @@ * uses internally, exposed for building custom grid/list UIs. */ -import { useMemo } from 'react' +import { useEffect, useMemo } from 'react' import type { EntityDef, EntityAccessor, @@ -18,7 +18,7 @@ import type { DataViewLayout, SelectionValues, } from '@contember/bindx' -import { useEntityList } from '@contember/bindx-react' +import { useEntityList, useEntityCount } from '@contember/bindx-react' import { useFilteringState, useSortingState, @@ -136,6 +136,19 @@ export function useDataView( const items = result.$status === 'ready' ? result.items : [] + // Total count via a standalone count query (keyed on filter only), so paging + // does not recompute it and "page X of Y" / jump-to-last work on full pages. + const { count: totalCount } = useEntityCount(entity, { + filter: combinedFilter, + refreshToken: paging.totalCountRefreshToken, + }) + const { setTotalCount } = paging + useEffect(() => { + if (totalCount !== null) { + setTotalCount(totalCount) + } + }, [totalCount, setTotalCount]) + return { status: result.$status, error: result.$status === 'error' ? result.$error : undefined, diff --git a/packages/bindx-dataview/src/useDataViewState.ts b/packages/bindx-dataview/src/useDataViewState.ts index e9a4a94d..b0ac01cb 100644 --- a/packages/bindx-dataview/src/useDataViewState.ts +++ b/packages/bindx-dataview/src/useDataViewState.ts @@ -260,6 +260,11 @@ export interface PagingStateResult { setItemsPerPage(count: number | null): void setTotalCount(count: number): void refreshTotalCount(): void + /** + * Increments whenever {@link refreshTotalCount} is called. A data source + * computing the total (e.g. a count query) watches this to re-fetch. + */ + readonly totalCountRefreshToken: number readonly queryLimit: number | undefined readonly queryOffset: number | undefined } @@ -327,9 +332,11 @@ export function usePagingState(options: UsePagingOptions = {}): PagingStateResul [setItemsPerPageRaw, setPageIndex], ) + // Bump the refresh token so count-query consumers re-fetch. The previous + // total is kept (stale-while-revalidate) and overwritten once the new count + // resolves, avoiding a flash of "unknown total". const refreshTotalCount = useCallback((): void => { setRefreshCounter(c => c + 1) - setTotalCount(null) }, []) return { @@ -345,6 +352,7 @@ export function usePagingState(options: UsePagingOptions = {}): PagingStateResul setItemsPerPage, setTotalCount, refreshTotalCount, + totalCountRefreshToken: refreshCounter, queryLimit: itemsPerPage ?? undefined, queryOffset: itemsPerPage === null ? undefined : pageIndex * itemsPerPage, } diff --git a/packages/bindx-react/src/hooks/index.ts b/packages/bindx-react/src/hooks/index.ts index 1b1db52d..ec33cb9a 100644 --- a/packages/bindx-react/src/hooks/index.ts +++ b/packages/bindx-react/src/hooks/index.ts @@ -39,6 +39,12 @@ export { type UseEntityListResult, } from './useEntityList.js' +export { + useEntityCount, + type UseEntityCountOptions, + type UseEntityCountResult, +} from './useEntityCount.js' + export { ContemberBindxProvider, schemaNamesToDef, diff --git a/packages/bindx-react/src/hooks/useEntityCount.ts b/packages/bindx-react/src/hooks/useEntityCount.ts new file mode 100644 index 00000000..1c5ad18d --- /dev/null +++ b/packages/bindx-react/src/hooks/useEntityCount.ts @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { EntityDef } from '@contember/bindx' +import { useBindxContext } from './BackendAdapterContext.js' + +// ============================================================================ +// Options & result +// ============================================================================ + +export interface UseEntityCountOptions { + /** Filter criteria — the count reflects rows matching this filter. */ + filter?: Record + /** + * Re-fetch the count whenever this value changes. Lets an external + * controller (e.g. a paging refresh button) force a recount without + * changing the filter. + */ + refreshToken?: number +} + +export interface UseEntityCountResult { + /** Total number of rows matching the filter, or `null` until first resolved. */ + readonly count: number | null + /** Whether the count query is currently in flight. */ + readonly isLoading: boolean + /** Re-issue the count query (e.g. after a mutation changes the row set). */ + refresh(): void +} + +// ============================================================================ +// Hook +// ============================================================================ + +/** + * Fetches the total number of entities matching a filter. + * + * Issued as a standalone count query (Contember `paginate.pageInfo.totalCount`), + * keyed only on the filter — not on pagination — so paging through a list does not + * recompute the count. Batched into the same request as any sibling list query. + */ +export function useEntityCount( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + entity: EntityDef, + options: UseEntityCountOptions = {}, +): UseEntityCountResult { + const entityType = entity.$name + const { batcher } = useBindxContext() + + const [count, setCount] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [refreshCounter, setRefreshCounter] = useState(0) + + const filterKey = useMemo( + () => JSON.stringify(options.filter ?? {}), + [options.filter], + ) + + const refresh = useCallback((): void => { + setRefreshCounter(c => c + 1) + }, []) + + useEffect(() => { + const abortController = new AbortController() + setIsLoading(true) + + const fetchCount = async (): Promise => { + try { + const filter = JSON.parse(filterKey) as Record + const result = await batcher.enqueue( + { + type: 'count', + entityType, + filter: Object.keys(filter).length > 0 ? filter : undefined, + }, + { signal: abortController.signal }, + ) + + if (abortController.signal.aborted) return + if (result.type !== 'count') return + + setCount(result.count) + setIsLoading(false) + } catch (error) { + if (abortController.signal.aborted) return + setIsLoading(false) + } + } + + fetchCount() + + return () => { + abortController.abort() + } + }, [entityType, filterKey, refreshCounter, options.refreshToken, batcher]) + + return { count, isLoading, refresh } +} diff --git a/packages/bindx-react/src/index.ts b/packages/bindx-react/src/index.ts index 112386b1..951b8501 100644 --- a/packages/bindx-react/src/index.ts +++ b/packages/bindx-react/src/index.ts @@ -204,6 +204,9 @@ export type { ErrorEntityListResult, ReadyEntityListResult, UseEntityListResult, + // Count hook types + UseEntityCountOptions, + UseEntityCountResult, // Persistence hook types PersistApi, EntityPersistApi, @@ -290,6 +293,7 @@ export { // Standalone hooks useEntity, useEntityList, + useEntityCount, // Persistence usePersist, usePersistEntity, diff --git a/packages/bindx/src/adapter/ContemberAdapter.ts b/packages/bindx/src/adapter/ContemberAdapter.ts index c64cac7c..d7e3dd91 100644 --- a/packages/bindx/src/adapter/ContemberAdapter.ts +++ b/packages/bindx/src/adapter/ContemberAdapter.ts @@ -17,7 +17,7 @@ import { } from '@contember/bindx-client' import type { GraphQlClient } from '@contember/graphql-client' import type { QuerySpec, QueryFieldSpec } from '../selection/buildQuery.js' -import type { BackendAdapter, Query, QueryResult, QueryOptions, GetQuery, ListQuery, PersistResult, CreateResult, DeleteResult } from './types.js' +import type { BackendAdapter, Query, QueryResult, QueryOptions, GetQuery, ListQuery, CountQuery, PersistResult, CreateResult, DeleteResult } from './types.js' import type { ContemberMutationResult } from '../errors/pathMapper.js' import type { SchemaRegistry } from '../schema/SchemaRegistry.js' import { GraphQlField } from '@contember/graphql-builder' @@ -56,6 +56,8 @@ export class ContemberAdapter implements BackendAdapter { if (q.type === 'get') { contentQueries[key] = this.buildGetQuery(q) + } else if (q.type === 'count') { + contentQueries[key] = this.buildCountQuery(q) } else { contentQueries[key] = this.buildListQuery(q) } @@ -74,6 +76,9 @@ export class ContemberAdapter implements BackendAdapter { if (q.type === 'get') { const unwrapped = data ? unwrapPaginateFields(data as Record, q.spec) : null return { type: 'get' as const, data: unwrapped } + } else if (q.type === 'count') { + const connection = data as { pageInfo?: { totalCount?: number } } | null + return { type: 'count' as const, count: connection?.pageInfo?.totalCount ?? 0 } } else { const items = (data ?? []) as readonly Record[] return { type: 'list' as const, data: items.map(item => unwrapPaginateFields(item, q.spec)) } @@ -114,6 +119,23 @@ export class ContemberAdapter implements BackendAdapter { ) } + private buildCountQuery(query: CountQuery): ContentQuery { + const args = buildListArgs(query.entityType, { filter: query.filter }, 'paginate') + const selectionSet = [ + new GraphQlField(null, 'pageInfo', {}, [ + new GraphQlField(null, 'totalCount'), + ]), + ] + + return new ContentOperation( + 'query', + `paginate${query.entityType}`, + args, + selectionSet, + value => value, + ) + } + async persist( entityType: string, id: string, diff --git a/packages/bindx/src/adapter/MockAdapter.ts b/packages/bindx/src/adapter/MockAdapter.ts index 49e09d7c..b7d7f195 100644 --- a/packages/bindx/src/adapter/MockAdapter.ts +++ b/packages/bindx/src/adapter/MockAdapter.ts @@ -1,4 +1,4 @@ -import type { BackendAdapter, Query, QueryResult, QueryOptions, GetQuery, ListQuery, PersistResult, CreateResult, DeleteResult } from './types.js' +import type { BackendAdapter, Query, QueryResult, QueryOptions, GetQuery, ListQuery, CountQuery, PersistResult, CreateResult, DeleteResult } from './types.js' import { MockQueryEngine } from './MockQueryEngine.js' /** @@ -83,6 +83,8 @@ export class MockAdapter implements BackendAdapter { return queries.map(q => { if (q.type === 'get') { return this.executeGet(q) + } else if (q.type === 'count') { + return this.executeCount(q) } else { return this.executeList(q) } @@ -149,6 +151,21 @@ export class MockAdapter implements BackendAdapter { return { type: 'list', data: results } } + private executeCount(query: CountQuery): QueryResult { + const entityStore = this.store[query.entityType] + if (!entityStore) { + return { type: 'count', count: 0 } + } + + let entities = Object.values(entityStore) + if (query.filter) { + entities = this.queryEngine.filter(entities, query.filter as Record) + } + + this.log('executeCount result', entities.length) + return { type: 'count', count: entities.length } + } + async persist( entityType: string, id: string, diff --git a/packages/bindx/src/adapter/index.ts b/packages/bindx/src/adapter/index.ts index 8a00d8a9..627fb9be 100644 --- a/packages/bindx/src/adapter/index.ts +++ b/packages/bindx/src/adapter/index.ts @@ -4,9 +4,11 @@ export type { Query, GetQuery, ListQuery, + CountQuery, QueryResult, GetQueryResult, ListQueryResult, + CountQueryResult, PersistResult, CreateResult, DeleteResult, diff --git a/packages/bindx/src/adapter/types.ts b/packages/bindx/src/adapter/types.ts index 162abacc..df0c7ca1 100644 --- a/packages/bindx/src/adapter/types.ts +++ b/packages/bindx/src/adapter/types.ts @@ -47,10 +47,23 @@ export interface ListQuery { readonly spec: QuerySpec } +/** + * Query for the total number of entities matching a filter. + * + * Issued independently from {@link ListQuery} and keyed only on the filter + * (not on limit/offset/orderBy), so paging through a list does not recompute + * the count. Mirrors Contember's `paginate { pageInfo { totalCount } }`. + */ +export interface CountQuery { + readonly type: 'count' + readonly entityType: string + readonly filter?: EntityWhere +} + /** * Union of all query types */ -export type Query = GetQuery | ListQuery +export type Query = GetQuery | ListQuery | CountQuery /** * Result for a single get query @@ -68,10 +81,18 @@ export interface ListQueryResult { readonly data: readonly Record[] } +/** + * Result for a count query + */ +export interface CountQueryResult { + readonly type: 'count' + readonly count: number +} + /** * Union of query results */ -export type QueryResult = GetQueryResult | ListQueryResult +export type QueryResult = GetQueryResult | ListQueryResult | CountQueryResult /** * Result of a persist operation. diff --git a/packages/bindx/src/index.ts b/packages/bindx/src/index.ts index e7435026..304bacd9 100644 --- a/packages/bindx/src/index.ts +++ b/packages/bindx/src/index.ts @@ -87,9 +87,11 @@ export type { Query, GetQuery, ListQuery, + CountQuery, QueryResult, GetQueryResult, ListQueryResult, + CountQueryResult, PersistResult, CreateResult, DeleteResult, diff --git a/tests/react/dataview/dataGridCountQuery.test.tsx b/tests/react/dataview/dataGridCountQuery.test.tsx new file mode 100644 index 00000000..f3656c64 --- /dev/null +++ b/tests/react/dataview/dataGridCountQuery.test.tsx @@ -0,0 +1,149 @@ +// Covers the count-query mechanism behind DataGrid pagination (issue #44): +// - the total reflects the active filter (not the whole table), +// - jumping to the last page is enabled and lands on the right rows, +// - the count is keyed on the filter only, so paging does NOT re-issue it. +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, cleanup, act, fireEvent } from '@testing-library/react' +import React from 'react' +import { BindxProvider, MockAdapter, defineSchema, scalar } from '@contember/bindx-react' +import type { BackendAdapter, Query, QueryResult, QueryOptions } from '@contember/bindx' +import { schema } from '../../shared/index.js' +import { DataGrid, DataGridTextColumn } from '@contember/bindx-dataview' +import { TestTable, TestPagination, getByTestId, queryByTestId, getRowCount, getCellText } from './helpers.js' + +afterEach(() => { + cleanup() +}) + +interface Article { + id: string + title: string + published: boolean +} + +interface TestSchema { + Article: Article +} + +const localSchema = defineSchema({ + entities: { + Article: { + fields: { + id: scalar(), + title: scalar(), + published: scalar(), + }, + }, + }, +}) + +// 6 articles, 4 of them published. +function createData(): Record>> { + const Article: Record> = {} + for (let i = 1; i <= 6; i++) { + Article[`a${i}`] = { id: `a${i}`, title: `Article ${i}`, published: i % 2 === 0 ? false : true } + } + // published === true for a1, a3, a5 (3 rows); false for a2, a4, a6 (3 rows) + return { Article } +} + +/** Wraps MockAdapter and records how many count queries were issued. */ +class CountSpyAdapter implements BackendAdapter { + public countQueries = 0 + constructor(private readonly inner: MockAdapter) {} + + async query(queries: readonly Query[], options?: QueryOptions): Promise { + this.countQueries += queries.filter(q => q.type === 'count').length + return this.inner.query(queries, options) + } + persist(...args: Parameters): ReturnType { + return this.inner.persist(...args) + } +} + +function renderGrid(adapter: BackendAdapter, itemsPerPage: number, staticFilter?: Record) { + return render( + + + {it => ( + <> + + + + + )} + + , + ) +} + +describe('DataGrid count query', () => { + test('total reflects the active filter, not the whole table', async () => { + const adapter = new MockAdapter(createData(), { delay: 0 }) + // Only published === true → 3 of 6 rows. With itemsPerPage=2 that is 2 pages. + const { container } = renderGrid(adapter, 2, { published: { eq: true } }) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-table')).not.toBeNull() + }) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('3 total') + }) + expect(getByTestId(container, 'datagrid-pagination-info').textContent).toBe('Page 1 of 2') + }) + + test('jump to last page is enabled and lands on the final partial page', async () => { + const adapter = new MockAdapter(createData(), { delay: 0 }) + const { container } = renderGrid(adapter, 4) // 6 rows → 2 pages (4 + 2) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('6 total') + }) + + const last = getByTestId(container, 'datagrid-pagination-last') as HTMLButtonElement + expect(last.disabled).toBe(false) + + await act(async () => { + fireEvent.click(last) + }) + + await waitFor(() => { + expect(getByTestId(container, 'datagrid-pagination-info').textContent).toBe('Page 2 of 2') + }) + // Second page holds the remaining 2 rows. + expect(getRowCount(container)).toBe(2) + expect(getCellText(container, 0, 'title')).toBe('Article 5') + }) + + test('count is keyed on the filter — paging does not re-issue it', async () => { + const spy = new CountSpyAdapter(new MockAdapter(createData(), { delay: 0 })) + const { container } = renderGrid(spy, 2) // 6 rows → 3 pages + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('6 total') + }) + + const countAfterInitialLoad = spy.countQueries + expect(countAfterInitialLoad).toBeGreaterThan(0) + + // Navigate forward two pages — the filter is unchanged, so no new count query. + await act(async () => { + fireEvent.click(getByTestId(container, 'datagrid-pagination-next')) + }) + await waitFor(() => { + expect(getByTestId(container, 'datagrid-pagination-info').textContent).toBe('Page 2 of 3') + }) + await act(async () => { + fireEvent.click(getByTestId(container, 'datagrid-pagination-next')) + }) + await waitFor(() => { + expect(getByTestId(container, 'datagrid-pagination-info').textContent).toBe('Page 3 of 3') + }) + + // Total still correct, and the count query did not fire again for paging. + expect(getByTestId(container, 'datagrid-pagination-total').textContent).toBe('6 total') + expect(spy.countQueries).toBe(countAfterInitialLoad) + }) +}) diff --git a/tests/react/dataview/dataGridPaginationTotalCount.test.tsx b/tests/react/dataview/dataGridPaginationTotalCount.test.tsx new file mode 100644 index 00000000..fa550cb7 --- /dev/null +++ b/tests/react/dataview/dataGridPaginationTotalCount.test.tsx @@ -0,0 +1,116 @@ +// Regression test for https://github.com/contember/bindx/issues/44 +// +// Top-level never issues a COUNT query. DataGrid.tsx only sets the +// total count lazily, when the *current* page returns fewer rows than the page +// size (`itemCount < queryLimit`) — i.e. only on a partial last page. On any +// list that spans more than one full page, the first page is full, the +// condition is never met, and `paging.info.totalCount` / `totalPages` stay +// `null`. Downstream that means: no row count shown, no "page X of Y", and the +// "jump to last page" control is permanently disabled (it short-circuits on +// `totalPages === null`). +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, cleanup } from '@testing-library/react' +import React from 'react' +import { BindxProvider, MockAdapter, defineSchema, scalar } from '@contember/bindx-react' +import { schema } from '../../shared/index.js' +import { DataGrid, DataGridTextColumn } from '@contember/bindx-dataview' +import { TestTable, TestPagination, getByTestId, queryByTestId, getRowCount } from './helpers.js' + +afterEach(() => { + cleanup() +}) + +interface Article { + id: string + title: string +} + +interface TestSchema { + Article: Article +} + +const localSchema = defineSchema({ + entities: { + Article: { + fields: { + id: scalar(), + title: scalar(), + }, + }, + }, +}) + +// 5 articles → at itemsPerPage=2 the list spans 3 pages (2 + 2 + 1). +function createData(): Record>> { + const Article: Record> = {} + for (let i = 1; i <= 5; i++) { + Article[`a${i}`] = { id: `a${i}`, title: `Article ${i}` } + } + return { Article } +} + +function renderGrid(itemsPerPage: number) { + const adapter = new MockAdapter(createData(), { delay: 0 }) + return render( + + + {it => ( + <> + + + + + )} + + , + ) +} + +describe('DataGrid pagination — total count on multi-page lists', () => { + test('should expose totalCount for a list spanning multiple pages', async () => { + const { container } = renderGrid(2) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-table')).not.toBeNull() + }) + + // First page is full (2 === itemsPerPage), but there are 5 rows in total. + expect(getRowCount(container)).toBe(2) + + // EXPECTED: the grid knows the total is 5. + // ACTUAL (bug): totalCount stays null, so the total element is never rendered. + expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('5 total') + }) + + test('should report totalPages so "jump to last page" is usable', async () => { + const { container } = renderGrid(2) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-table')).not.toBeNull() + }) + + // EXPECTED: "Page 1 of 3". + // ACTUAL (bug): "Page 1" — totalPages is null. + expect(getByTestId(container, 'datagrid-pagination-info').textContent).toBe('Page 1 of 3') + + // EXPECTED: the last-page button is actionable. + // ACTUAL (bug): totalPages === null disables it (and paging.last() is a no-op). + expect((getByTestId(container, 'datagrid-pagination-last') as HTMLButtonElement).disabled).toBe(false) + }) + + // Control — documents the one case that DOES work today: a single partial + // page (row count below the page size) satisfies the lazy heuristic, so the + // total is reported. This is why small tables look fine while larger ones + // silently lose their count. + test('control — single partial page exposes totalCount', async () => { + const { container } = renderGrid(50) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-table')).not.toBeNull() + }) + + expect(getRowCount(container)).toBe(5) + expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('5 total') + }) +}) diff --git a/tests/unit/adapter/countQuery.test.ts b/tests/unit/adapter/countQuery.test.ts new file mode 100644 index 00000000..20f18ee6 --- /dev/null +++ b/tests/unit/adapter/countQuery.test.ts @@ -0,0 +1,66 @@ +import '../../setup' +import { describe, test, expect } from 'bun:test' +import { MockAdapter } from '@contember/bindx' +import type { CountQuery, CountQueryResult } from '@contember/bindx' + +interface Article { + id: string + title: string + published: boolean +} + +function createAdapter(): MockAdapter { + return new MockAdapter( + { + Article: { + a1: { id: 'a1', title: 'A', published: true }, + a2: { id: 'a2', title: 'B', published: false }, + a3: { id: 'a3', title: 'C', published: true }, + a4: { id: 'a4', title: 'D', published: true }, + }, + }, + { delay: 0 }, + ) +} + +describe('MockAdapter count query', () => { + test('counts all rows when no filter is given', async () => { + const adapter = createAdapter() + const query: CountQuery = { type: 'count', entityType: 'Article' } + + const [result] = await adapter.query([query]) + + expect(result?.type).toBe('count') + expect((result as CountQueryResult).count).toBe(4) + }) + + test('counts only rows matching the filter', async () => { + const adapter = createAdapter() + const query: CountQuery
= { type: 'count', entityType: 'Article', filter: { published: { eq: true } } } + + const [result] = await adapter.query([query]) + + expect((result as CountQueryResult).count).toBe(3) + }) + + test('returns 0 for an unknown entity type', async () => { + const adapter = createAdapter() + const query: CountQuery = { type: 'count', entityType: 'Missing' } + + const [result] = await adapter.query([query]) + + expect((result as CountQueryResult).count).toBe(0) + }) + + test('count is independent of limit/offset (whole filtered set)', async () => { + const adapter = createAdapter() + // A list query is paginated, but a sibling count reports the full filtered size. + const [list, count] = await adapter.query([ + { type: 'list', entityType: 'Article', limit: 2, offset: 0, spec: { fields: [{ name: 'id', sourcePath: ['id'] }] } }, + { type: 'count', entityType: 'Article' }, + ]) + + expect(list?.type).toBe('list') + expect((count as CountQueryResult).count).toBe(4) + }) +})