diff --git a/packages/bindx-dataview/src/DataGrid.tsx b/packages/bindx-dataview/src/DataGrid.tsx index 4ec8839..4b5ba05 100644 --- a/packages/bindx-dataview/src/DataGrid.tsx +++ b/packages/bindx-dataview/src/DataGrid.tsx @@ -24,6 +24,7 @@ import { useDataViewKey } from './DataViewKeyProvider.js' import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from './DataViewContext.js' import { useDataGridSetup, QUERY_FILTER_NAME } from './useDataGridSetup.js' import { useListFetchAllData } from './useListFetchAllData.js' +import { usePagingTotalCount } from './usePagingTotalCount.js' export { QUERY_FILTER_NAME } @@ -128,17 +129,13 @@ function DataGridImpl>({ } }, [result.$status]) - // 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 (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) - } - }, [totalCount, result.$status, itemCount, setup.paging.queryLimit, setup.paging.queryOffset, setup.paging.setTotalCount]) + const countKey = useMemo(() => JSON.stringify(setup.combinedFilter ?? {}), [setup.combinedFilter]) + usePagingTotalCount(setup.paging, { + countedTotal: totalCount, + countKey, + isPageCurrent: result.$status === 'ready' && !result.$isRefetching, + pageItemCount: itemCount, + }) // ---- Reload ---- const [, setReloadCounter] = useState(0) diff --git a/packages/bindx-dataview/src/HasManyDataGrid.tsx b/packages/bindx-dataview/src/HasManyDataGrid.tsx index 1227111..54efff7 100644 --- a/packages/bindx-dataview/src/HasManyDataGrid.tsx +++ b/packages/bindx-dataview/src/HasManyDataGrid.tsx @@ -42,6 +42,7 @@ import { useDataViewKey } from './DataViewKeyProvider.js' import { DataViewProvider, type DataViewContextValue, type DataViewFetchAllData, type DataViewLoaderState } from './DataViewContext.js' import { useDataGridSetup } from './useDataGridSetup.js' import { buildHasManyRelationQuery, extractHasManyRelationRows } from './hasManyRelationQuery.js' +import { usePagingTotalCount } from './usePagingTotalCount.js' // ============================================================================ // Props @@ -80,6 +81,8 @@ interface ListState { status: 'loading' | 'error' | 'ready' items: Array<{ id: string; data: object }> totalCount?: number + /** The query options the items were loaded for. */ + optionsKey?: string } const INITIAL_LIST_STATE: ListState = { status: 'loading', items: [] } @@ -185,7 +188,7 @@ function HasManyDataGridImpl({ const relation = extractHasManyRelationRows(result.data, { alias, fieldName }) if (!relation) { - setListState({ status: 'ready', items: [] }) + setListState({ status: 'ready', items: [], optionsKey }) return } @@ -196,7 +199,7 @@ function HasManyDataGridImpl({ } }) - setListState({ status: 'ready', items, totalCount: relation.totalCount }) + setListState({ status: 'ready', items, totalCount: relation.totalCount, optionsKey }) } catch (error) { if (abortController.signal.aborted) return setListState({ status: 'error', items: [] }) @@ -238,14 +241,16 @@ function HasManyDataGridImpl({ } }, [listState.status]) - // Update total count from paginateRelation - useEffect(() => { - if (listState.totalCount !== undefined) { - setup.paging.setTotalCount(listState.totalCount) - } else if (listState.status === 'ready' && setup.paging.queryLimit !== undefined && setup.paging.queryOffset !== undefined && itemCount < setup.paging.queryLimit) { - setup.paging.setTotalCount(setup.paging.queryOffset + itemCount) - } - }, [listState.status, listState.totalCount, itemCount, setup.paging.queryLimit, setup.paging.queryOffset, setup.paging.setTotalCount]) + const countKey = useMemo( + () => JSON.stringify([parentEntityType, parentEntityId, fieldName, setup.combinedFilter ?? {}]), + [parentEntityType, parentEntityId, fieldName, setup.combinedFilter], + ) + usePagingTotalCount(setup.paging, { + countedTotal: listState.totalCount ?? null, + countKey, + isPageCurrent: listState.status === 'ready' && listState.optionsKey === optionsKey, + pageItemCount: itemCount, + }) // ---- Reload ---- const [, setReloadCounter] = useState(0) diff --git a/packages/bindx-dataview/src/select/SelectDataView.tsx b/packages/bindx-dataview/src/select/SelectDataView.tsx index d81c401..aa2a835 100644 --- a/packages/bindx-dataview/src/select/SelectDataView.tsx +++ b/packages/bindx-dataview/src/select/SelectDataView.tsx @@ -46,6 +46,7 @@ import { useSelectOptions } from './selectContext.js' import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from '../DataViewContext.js' import { useFilteringState, useSortingState, usePagingState, useSelectionState } from '../useDataViewState.js' import { useListFetchAllData } from '../useListFetchAllData.js' +import { usePagingTotalCount } from '../usePagingTotalCount.js' export interface SelectDataViewProps { /** Children rendered inside the DataView context */ @@ -185,15 +186,13 @@ function SelectDataViewImpl({ } }, [result.$status]) - // Update total count from the count query, falling back to the partial-page - // heuristic until the count resolves. - useEffect(() => { - 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) - } - }, [totalCount, result.$status, itemCount, paging.queryLimit, paging.queryOffset, paging.setTotalCount]) + const countKey = useMemo(() => JSON.stringify(combinedFilter ?? {}), [combinedFilter]) + usePagingTotalCount(paging, { + countedTotal: totalCount, + countKey, + isPageCurrent: result.$status === 'ready' && !result.$isRefetching, + pageItemCount: itemCount, + }) // ---- Reload ---- const [, setReloadCounter] = useState(0) diff --git a/packages/bindx-dataview/src/useDataViewState.ts b/packages/bindx-dataview/src/useDataViewState.ts index 10a0b16..2b25afc 100644 --- a/packages/bindx-dataview/src/useDataViewState.ts +++ b/packages/bindx-dataview/src/useDataViewState.ts @@ -5,7 +5,7 @@ * for persisting state across navigations. */ -import { useState, useCallback, useMemo } from 'react' +import { useState, useCallback, useEffect, useMemo } from 'react' import type { FilterHandler, FilterArtifact, @@ -304,7 +304,7 @@ export function usePagingState(options: UsePagingOptions = {}): PagingStateResul storageKey = 'dataview', } = options - const [pageIndex, setPageIndex] = useStoredState( + const [requestedPageIndex, setPageIndex] = useStoredState( currentPageStateStorage, [storageKey, 'pageIndex'], (stored) => stored ?? 0, @@ -316,11 +316,6 @@ export function usePagingState(options: UsePagingOptions = {}): PagingStateResul (stored) => stored ?? initialItemsPerPage, ) - const state = useMemo((): PagingState => ({ - pageIndex, - itemsPerPage: itemsPerPage ?? 0, - }), [pageIndex, itemsPerPage]) - const [totalCount, setTotalCount] = useState(null) const [refreshCounter, setRefreshCounter] = useState(0) @@ -329,6 +324,19 @@ export function usePagingState(options: UsePagingOptions = {}): PagingStateResul return Math.max(1, Math.ceil(totalCount / itemsPerPage)) }, [totalCount, itemsPerPage]) + // A requested page past the end (the total shrank, or a stored index outlived its result set) + // reads as the last page, so the query offset never points past the rows. + const pageIndex = totalPages === null ? requestedPageIndex : Math.min(requestedPageIndex, totalPages - 1) + + useEffect(() => { + if (pageIndex !== requestedPageIndex) setPageIndex(pageIndex) + }, [pageIndex, requestedPageIndex, setPageIndex]) + + const state = useMemo((): PagingState => ({ + pageIndex, + itemsPerPage: itemsPerPage ?? 0, + }), [pageIndex, itemsPerPage]) + const info: PagingInfo = useMemo(() => ({ totalCount, totalPages, diff --git a/packages/bindx-dataview/src/usePagingTotalCount.ts b/packages/bindx-dataview/src/usePagingTotalCount.ts new file mode 100644 index 0000000..b716738 --- /dev/null +++ b/packages/bindx-dataview/src/usePagingTotalCount.ts @@ -0,0 +1,90 @@ +import { useEffect, useRef } from 'react' +import type { PagingStateResult } from './useDataViewState.js' + +export interface PagingTotalSource { + /** Total reported by the data source, or null when it reports none. */ + readonly countedTotal: number | null + /** Identifies the counted row set (the filter); bounds learned for another row set are dropped. */ + readonly countKey: string + /** Whether the loaded rows answer the current query: filter, order, limit and offset. */ + readonly isPageCurrent: boolean + readonly pageItemCount: number +} + +/** What the loaded pages prove about the total: `atLeast <= total <= atMost`. */ +interface TotalBounds { + readonly atLeast: number + readonly atMost: number | null +} + +interface LoadedPage { + readonly offset: number + readonly limit: number + readonly itemCount: number +} + +type PageOutcome = + | { readonly type: 'total'; readonly totalCount: number } + | { readonly type: 'probe'; readonly bounds: TotalBounds; readonly pageIndex: number } + | { readonly type: 'unknown'; readonly bounds: TotalBounds } + +const NO_BOUNDS: TotalBounds = { atLeast: 0, atMost: null } + +/** + * Feeds the paging state its total: the counted total when the data source reports one, + * otherwise the total the loaded pages prove. Only a page that answers the current query counts. + * + * A page shorter than the limit ends the list. An empty page past offset 0 only bounds the total + * from above, so the grid bisects between the bounds, one page per query, until a page ends the list. + */ +export function usePagingTotalCount(paging: PagingStateResult, source: PagingTotalSource): void { + const { setTotalCount, goTo, queryLimit, queryOffset } = paging + const { countedTotal, countKey, isPageCurrent, pageItemCount } = source + const boundsRef = useRef<{ countKey: string; bounds: TotalBounds }>({ countKey, bounds: NO_BOUNDS }) + + useEffect(() => { + if (boundsRef.current.countKey !== countKey || countedTotal !== null) { + boundsRef.current = { countKey, bounds: NO_BOUNDS } + } + if (countedTotal !== null) { + setTotalCount(countedTotal) + return + } + if (!isPageCurrent || queryLimit === undefined || queryOffset === undefined) { + return + } + + const outcome = inferTotalFromPage(boundsRef.current.bounds, { offset: queryOffset, limit: queryLimit, itemCount: pageItemCount }) + if (outcome.type === 'total') { + boundsRef.current = { countKey, bounds: NO_BOUNDS } + setTotalCount(outcome.totalCount) + return + } + boundsRef.current = { countKey, bounds: outcome.bounds } + if (outcome.type === 'probe') { + goTo(outcome.pageIndex) + } + }, [countedTotal, countKey, isPageCurrent, pageItemCount, queryLimit, queryOffset, setTotalCount, goTo]) +} + +function inferTotalFromPage(bounds: TotalBounds, { offset, limit, itemCount }: LoadedPage): PageOutcome { + const isEmptyPastStart = itemCount === 0 && offset > 0 + if (itemCount < limit && !isEmptyPastStart) { + return { type: 'total', totalCount: offset + itemCount } + } + + const narrowed: TotalBounds = isEmptyPastStart + ? { atLeast: bounds.atLeast, atMost: Math.min(bounds.atMost ?? offset, offset) } + : { atLeast: Math.max(bounds.atLeast, offset + limit), atMost: bounds.atMost } + + if (narrowed.atMost === null) { + return { type: 'unknown', bounds: narrowed } + } + if (narrowed.atLeast >= narrowed.atMost) { + return { type: 'total', totalCount: narrowed.atMost } + } + // The page holding the middle row starts below `atMost` and ends above `atLeast`, + // so whatever it returns halves the interval. + const middleRow = Math.floor((narrowed.atLeast + narrowed.atMost) / 2) + return { type: 'probe', bounds: narrowed, pageIndex: Math.floor(middleRow / limit) } +} diff --git a/packages/bindx-react/src/hooks/useEntity.ts b/packages/bindx-react/src/hooks/useEntity.ts index 97294e6..0e12393 100644 --- a/packages/bindx-react/src/hooks/useEntity.ts +++ b/packages/bindx-react/src/hooks/useEntity.ts @@ -93,8 +93,10 @@ export type NotFoundEntityResult = UseEntityResultBase & { /** * Ready state — full EntityAccessor with status metadata. * - * `$isRefetching` is `true` while a background re-fetch is in flight - * (triggered by a `queryKey` change while ready data is already present). + * `$isRefetching` is `true` while the data answers an earlier query: from the re-fetch + * that a `queryKey` change starts, while ready data is already present, until its result + * arrives. Unlike `useEntityList`, the flag turns on when the re-fetch starts, one render + * after the change, not on the render that changes the query. * The accessor identity and field values remain stable until the new data * arrives, so the subtree does not unmount — useful for stale-while-revalidate * indicators (subtle spinner, "stale" badge, etc.). diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index 7ec691f..e9409fc 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -58,8 +58,8 @@ export type ErrorEntityListResult = EntityListResultBase & { } /** - * `$isRefetching` is `true` while a background re-fetch is in flight - * (triggered by a `queryKey` change while ready data is already present). + * `$isRefetching` is `true` while the items answer an earlier query: from the render + * that changes the options or the `queryKey` until the re-fetch returns. * The accessor identity stays stable so the subtree does not unmount — * stale-while-revalidate semantics. */ @@ -217,16 +217,24 @@ export function useEntityList( return `${entityType}:${selectionContentKey}` }, [options.queryKey, selectionContentKey, entityType]) + const requestKey = useMemo( + () => JSON.stringify([effectiveQueryKey, optionsKey]), + [effectiveQueryKey, optionsKey], + ) + // --- List state tracking --- const listStateRef = useRef<{ status: 'loading' | 'error' | 'ready' items: Array<{ id: string; data: object }> error?: FieldError isRefetching: boolean + /** The request the items were loaded for. */ + requestKey: string | null }>({ status: 'loading', items: [], isRefetching: false, + requestKey: null, }) const versionRef = useRef(0) @@ -350,6 +358,9 @@ export function useEntityList( const state = listStateRef.current const version = versionRef.current const storeVersion = store.getVersion() + // Until the data-loading effect marks the refetch, the items of the previous request are + // already stale on the render that changed it. + const isRefetching = state.isRefetching || state.requestKey !== requestKey const cache = listCacheRef.current if ( @@ -357,7 +368,7 @@ export function useEntityList( cache.version === version && cache.storeVersion === storeVersion && cache.status === state.status && - cache.isRefetching === state.isRefetching && + cache.isRefetching === isRefetching && cache.accessorCache === itemAccessorCache ) { return cache.result @@ -382,7 +393,7 @@ export function useEntityList( result = { $status: 'ready', $isLoading: false, - $isRefetching: state.isRefetching, + $isRefetching: isRefetching, $isError: false, $error: null, $isDirty: false, @@ -398,13 +409,13 @@ export function useEntityList( version, storeVersion, status: state.status, - isRefetching: state.isRefetching, + isRefetching, accessorCache: itemAccessorCache, result, } return result - }, [store, itemAccessorCache, addItem, removeItem, moveItem, resolveItemId]) + }, [store, requestKey, itemAccessorCache, addItem, removeItem, moveItem, resolveItemId]) const isEqual = useCallback( (a: UseEntityListResult, b: UseEntityListResult): boolean => { @@ -429,7 +440,7 @@ export function useEntityList( if (prev.status === 'ready') { listStateRef.current = { ...prev, isRefetching: true } } else { - listStateRef.current = { status: 'loading', items: [], isRefetching: false } + listStateRef.current = { status: 'loading', items: [], isRefetching: false, requestKey: null } } versionRef.current++ store.notify() @@ -468,7 +479,7 @@ export function useEntityList( // Revalidation preserves local edits while advancing the server baseline. dispatcher.dispatch(refreshServerData(entityType, item.id, item.data)) } - listStateRef.current = { status: 'ready', items, isRefetching: false } + listStateRef.current = { status: 'ready', items, isRefetching: false, requestKey } versionRef.current++ store.notify() }) @@ -481,6 +492,7 @@ export function useEntityList( items: [], error: createLoadError(normalizedError), isRefetching: false, + requestKey, } versionRef.current++ store.notify() @@ -492,7 +504,7 @@ export function useEntityList( return () => { abortController.abort() } - }, [entityType, optionsKey, effectiveQueryKey, batcher, dispatcher, store, selectionMeta]) + }, [entityType, optionsKey, requestKey, batcher, dispatcher, store, selectionMeta]) return accessor } diff --git a/tests/react/dataview/dataGridPageIndexOutOfRange.test.tsx b/tests/react/dataview/dataGridPageIndexOutOfRange.test.tsx new file mode 100644 index 0000000..447ab17 --- /dev/null +++ b/tests/react/dataview/dataGridPageIndexOutOfRange.test.tsx @@ -0,0 +1,232 @@ +// DataGrid-level coverage for https://github.com/contember/bindx/issues/127: a page index past the +// last page — restored from storage or left behind by a narrower filter — lands on the last page with rows. +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, cleanup, act, fireEvent } from '@testing-library/react' +import React, { type ReactElement } 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, type StateStorage } from '@contember/bindx-dataview' +import { TestTable, TestPagination, getByTestId, queryByTestId, getRowCount, getCellText } from './helpers.js' + +afterEach(() => { + cleanup() +}) + +interface Article { + id: string + title: string + published: boolean +} + +const localSchema = defineSchema<{ Article: Article }>({ + entities: { + Article: { + fields: { + id: scalar(), + title: scalar(), + published: scalar(), + }, + }, + }, +}) + +const PUBLISHED_ONLY = { published: { eq: true } } + +/** `rowCount` articles; the first three are published. */ +function createData(rowCount: number): Record>> { + const Article: Record> = {} + for (let i = 1; i <= rowCount; i++) { + const id = `a${String(i).padStart(2, '0')}` + Article[id] = { id, title: `Article ${i}`, published: i <= 3 } + } + return { Article } +} + +/** + * Records the offset of every list query. Without `answersCount` it answers count queries + * without a count, like an adapter that implements none. + */ +class RecordingAdapter implements BackendAdapter { + public readonly listOffsets: (number | undefined)[] = [] + + constructor(private readonly inner: MockAdapter, private readonly answersCount: boolean) {} + + async query(queries: readonly Query[], options?: QueryOptions): Promise { + for (const query of queries) { + if (query.type === 'list') this.listOffsets.push(query.offset) + } + const results = await this.inner.query(queries, options) + if (this.answersCount) return results + return results.map((result): QueryResult => result.type === 'count' ? { type: 'list', data: [] } : result) + } + + persist(...args: Parameters): ReturnType { + return this.inner.persist(...args) + } +} + +function createCountingAdapter(rowCount: number): RecordingAdapter { + return new RecordingAdapter(new MockAdapter(createData(rowCount), { delay: 0 }), true) +} + +function createCountlessAdapter(rowCount: number): RecordingAdapter { + return new RecordingAdapter(new MockAdapter(createData(rowCount), { delay: 0 }), false) +} + +/** Serializes like the web storages do, starting with a page index stored by an earlier visit. */ +function createStorageWithPageIndex(pageIndex: number): StateStorage & { readonly written: Map } { + const serialized = new Map([['dataview:pageIndex', JSON.stringify(pageIndex)]]) + const written = new Map() + return { + written, + get: (key: string): T | undefined => { + const raw = serialized.get(key) + return raw === undefined ? undefined : JSON.parse(raw) + }, + set: (key: string, value: unknown): void => { + serialized.set(key, JSON.stringify(value)) + written.set(key, value) + }, + remove: (key: string): void => { + serialized.delete(key) + }, + } +} + +interface GridProps { + adapter: BackendAdapter + storage?: StateStorage + filter?: Record +} + +function Grid({ adapter, storage, filter }: GridProps): ReactElement { + return ( + + + {it => ( + <> + + + + + )} + + + ) +} + +function pageInfo(container: Element): string | null { + return getByTestId(container, 'datagrid-pagination-info').textContent +} + +function totalInfo(container: Element): string | null | undefined { + return queryByTestId(container, 'datagrid-pagination-total')?.textContent +} + +function isDisabled(container: Element, testId: string): boolean { + const button = getByTestId(container, testId) + return button instanceof HTMLButtonElement && button.disabled +} + +async function click(container: Element, testId: string): Promise { + await act(async () => { + fireEvent.click(getByTestId(container, testId)) + }) +} + +/** Walks from the first page to the last and returns the titles of every row on the way. */ +async function readAllPages(container: Element): Promise { + await click(container, 'datagrid-pagination-first') + await waitFor(() => expect(pageInfo(container)).toStartWith('Page 1 ')) + + const titles: string[] = [] + for (let page = 1; ; page++) { + await waitFor(() => expect(pageInfo(container)).toStartWith(`Page ${page} `)) + await waitFor(() => expect(getRowCount(container)).toBeGreaterThan(0)) + for (let row = 0; row < getRowCount(container); row++) { + titles.push(getCellText(container, row, 'title')) + } + if (isDisabled(container, 'datagrid-pagination-next')) return titles + await click(container, 'datagrid-pagination-next') + } +} + +const ALL_SEVEN_TITLES = ['Article 1', 'Article 2', 'Article 3', 'Article 4', 'Article 5', 'Article 6', 'Article 7'] + +describe('DataGrid — page index beyond the last page', () => { + test('a stored page past the end lands on the last page when the adapter counts', async () => { + const adapter = createCountingAdapter(7) + const storage = createStorageWithPageIndex(14) + const { container } = render() + + await waitFor(() => expect(pageInfo(container)).toBe('Page 4 of 4')) + await waitFor(() => expect(getRowCount(container)).toBe(1)) + expect(getCellText(container, 0, 'title')).toBe('Article 7') + expect(totalInfo(container)).toBe('7 total') + expect(storage.written.get('dataview:pageIndex')).toBe(3) + expect(adapter.listOffsets).toEqual([28, 6]) + }) + + test('a stored page past the end bisects to the last page when the adapter does not count', async () => { + const adapter = createCountlessAdapter(7) + const storage = createStorageWithPageIndex(14) + const { container } = render() + + await waitFor(() => expect(pageInfo(container)).toBe('Page 4 of 4')) + await waitFor(() => expect(getRowCount(container)).toBe(1)) + expect(getCellText(container, 0, 'title')).toBe('Article 7') + expect(totalInfo(container)).toBe('7 total') + expect(storage.written.get('dataview:pageIndex')).toBe(3) + expect(adapter.listOffsets).toEqual([28, 14, 6]) + + expect((await readAllPages(container)).sort()).toEqual(ALL_SEVEN_TITLES) + }) + + test('a stored page far past the end costs a logarithmic number of queries when the adapter does not count', async () => { + const adapter = createCountlessAdapter(7) + const { container } = render() + + await waitFor(() => expect(pageInfo(container)).toBe('Page 4 of 4')) + await waitFor(() => expect(getRowCount(container)).toBe(1)) + expect(totalInfo(container)).toBe('7 total') + // The interval of possible totals, [0, 1000] after the first empty page, halves with every query. + expect(adapter.listOffsets.length).toBeLessThanOrEqual(1 + Math.ceil(Math.log2(1000))) + expect(adapter.listOffsets.at(-1)).toBe(6) + }) + + test('an adapter with no rows and no count ends on an empty first page', async () => { + const adapter = createCountlessAdapter(0) + const { container } = render() + + await waitFor(() => expect(pageInfo(container)).toBe('Page 1 of 1')) + await waitFor(() => expect(queryByTestId(container, 'datagrid-empty')).not.toBeNull()) + expect(totalInfo(container)).toBe('0 total') + expect(adapter.listOffsets).toEqual([28, 14, 6, 2, 0]) + }) + + test('a narrower filter on the last page moves to the new last page when the adapter does not count', async () => { + const adapter = createCountlessAdapter(9) + const { container, rerender } = render() + + await waitFor(() => expect(getRowCount(container)).toBe(2)) + for (let page = 2; page <= 5; page++) { + await click(container, 'datagrid-pagination-next') + await waitFor(() => expect(pageInfo(container)).toStartWith(`Page ${page}`)) + } + await waitFor(() => expect(pageInfo(container)).toBe('Page 5 of 5')) + const offsetsBeforeFilter = adapter.listOffsets.length + + rerender() + + await waitFor(() => expect(pageInfo(container)).toBe('Page 2 of 2')) + await waitFor(() => expect(getRowCount(container)).toBe(1)) + expect(getCellText(container, 0, 'title')).toBe('Article 3') + expect(totalInfo(container)).toBe('3 total') + expect(isDisabled(container, 'datagrid-pagination-next')).toBe(true) + expect(adapter.listOffsets.slice(offsetsBeforeFilter)).toEqual([8, 4, 2]) + + expect((await readAllPages(container)).sort()).toEqual(['Article 1', 'Article 2', 'Article 3']) + }) +}) diff --git a/tests/react/dataview/hasManyDataGridPageIndexOutOfRange.test.tsx b/tests/react/dataview/hasManyDataGridPageIndexOutOfRange.test.tsx new file mode 100644 index 0000000..8e428ff --- /dev/null +++ b/tests/react/dataview/hasManyDataGridPageIndexOutOfRange.test.tsx @@ -0,0 +1,109 @@ +// HasManyDataGrid coverage for https://github.com/contember/bindx/issues/127 when the relation +// reports no total: the grid infers it from the pages it loads for the current query only. +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { cleanup, render, waitFor } from '@testing-library/react' +import React from 'react' +import { BindxProvider, Entity, MockAdapter, defineSchema, entityDef, hasMany, scalar } from '@contember/bindx-react' +import type { BackendAdapter, Query, QueryResult, QueryOptions } from '@contember/bindx' +import { DataGridTextColumn, HasManyDataGrid, type StateStorage } from '@contember/bindx-dataview' +import { TestTable, TestPagination, getByTestId, queryByTestId, getRowCount, getCellText } from './helpers.js' + +afterEach(cleanup) + +interface Article { + id: string + title: string +} + +interface Author { + id: string + articles: Article[] +} + +const schema = defineSchema<{ Article: Article; Author: Author }>({ + entities: { + Article: { fields: { id: scalar(), title: scalar() } }, + Author: { fields: { id: scalar(), articles: hasMany('Article') } }, + }, +}) +const authorDef = entityDef('Author') + +/** Records the offset of every relation page and drops the relation's total from the response. */ +class TotallessRelationAdapter implements BackendAdapter { + public readonly relationOffsets: (number | undefined)[] = [] + + constructor(private readonly inner: MockAdapter) {} + + async query(queries: readonly Query[], options?: QueryOptions): Promise { + for (const query of queries) { + const relationField = query.type === 'get' ? query.spec.fields.find(field => field.isArray) : undefined + if (relationField) this.relationOffsets.push(relationField.offset) + } + const results = await this.inner.query(queries, options) + return results.map(result => result.type === 'get' && result.data !== null ? { type: 'get', data: withoutRelationTotals(result.data) } : result) + } + + persist(...args: Parameters): ReturnType { + return this.inner.persist(...args) + } +} + +/** Copying an array keeps its rows and drops the non-enumerable `totalCount` the relation carries. */ +function withoutRelationTotals(data: Record): Record { + return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, Array.isArray(value) ? [...value] : value])) +} + +function createStorageWithPageIndex(key: string, pageIndex: number): StateStorage { + const serialized = new Map([[key, JSON.stringify(pageIndex)]]) + return { + get: (storedKey: string): T | undefined => { + const raw = serialized.get(storedKey) + return raw === undefined ? undefined : JSON.parse(raw) + }, + set: (storedKey: string, value: unknown): void => { + serialized.set(storedKey, JSON.stringify(value)) + }, + remove: (storedKey: string): void => { + serialized.delete(storedKey) + }, + } +} + +describe('HasManyDataGrid — page index beyond the last page', () => { + test('a stored page past the end bisects to the last page when the relation reports no total', async () => { + const articles = Array.from({ length: 7 }, (_, i) => ({ id: `a${i + 1}`, title: `Article ${i + 1}` })) + const adapter = new TotallessRelationAdapter(new MockAdapter({ + Article: Object.fromEntries(articles.map(article => [article.id, article])), + Author: { author: { id: 'author', articles } }, + }, { delay: 0 })) + + const { container } = render( + + + {author => ( + + {it => ( + <> + + + + + )} + + )} + + , + ) + + await waitFor(() => expect(getByTestId(container, 'datagrid-pagination-info').textContent).toBe('Page 4 of 4')) + await waitFor(() => expect(getRowCount(container)).toBe(1)) + expect(getCellText(container, 0, 'title')).toBe('Article 7') + expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('7 total') + expect(adapter.relationOffsets.filter(offset => offset !== undefined)).toEqual([28, 14, 6]) + }) +}) diff --git a/tests/react/dataview/pagingPageIndexOutOfRange.test.tsx b/tests/react/dataview/pagingPageIndexOutOfRange.test.tsx new file mode 100644 index 0000000..fbf08e4 --- /dev/null +++ b/tests/react/dataview/pagingPageIndexOutOfRange.test.tsx @@ -0,0 +1,117 @@ +// Regression test for https://github.com/contember/bindx/issues/127 +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { renderHook, cleanup, act } from '@testing-library/react' +import { usePagingState, type StateStorage } from '@contember/bindx-dataview' + +afterEach(() => { + cleanup() +}) + +/** Serializes like the web storages do, starting with a page index stored by an earlier visit. */ +function createStorageWithPageIndex(pageIndex: number): StateStorage & { readonly written: Map } { + const serialized = new Map([['dataview:pageIndex', JSON.stringify(pageIndex)]]) + const written = new Map() + return { + written, + get: (key: string): T | undefined => { + const raw = serialized.get(key) + return raw === undefined ? undefined : JSON.parse(raw) + }, + set: (key: string, value: unknown): void => { + serialized.set(key, JSON.stringify(value)) + written.set(key, value) + }, + remove: (key: string): void => { + serialized.delete(key) + }, + } +} + +describe('usePagingState — page index beyond the last page', () => { + test('should move back into range when the total count shrinks below the current page', () => { + const { result } = renderHook(() => usePagingState({ initialItemsPerPage: 50 })) + + act(() => { + result.current.setTotalCount(800) + }) + act(() => { + result.current.goTo(14) + }) + // A narrower filter: the count query now reports 33 rows, i.e. one page. + act(() => { + result.current.setTotalCount(33) + }) + + expect(result.current.info.totalPages).toBe(1) + expect(result.current.state.pageIndex).toBeLessThan(1) + expect(result.current.queryOffset).toBe(0) + }) + + test('should not query past the last page when a stored page index exceeds the total', () => { + const storage = createStorageWithPageIndex(14) + const { result } = renderHook(() => + usePagingState({ initialItemsPerPage: 50, currentPageStateStorage: storage }), + ) + + act(() => { + result.current.setTotalCount(33) + }) + + expect(result.current.info.totalPages).toBe(1) + expect(result.current.state.pageIndex).toBeLessThan(1) + expect(result.current.queryOffset).toBe(0) + expect(storage.written.get('dataview:pageIndex')).toBe(0) + }) + + test('should keep a stored page index while the total is unknown', () => { + const { result } = renderHook(() => + usePagingState({ initialItemsPerPage: 50, currentPageStateStorage: createStorageWithPageIndex(14) }), + ) + + expect(result.current.info.totalPages).toBeNull() + expect(result.current.state.pageIndex).toBe(14) + expect(result.current.queryOffset).toBe(700) + }) + + test('should move to the last existing page and navigate from there', () => { + const { result } = renderHook(() => usePagingState({ initialItemsPerPage: 50 })) + + act(() => { + result.current.setTotalCount(800) + }) + act(() => { + result.current.goTo(14) + }) + act(() => { + result.current.setTotalCount(120) + }) + + expect(result.current.state.pageIndex).toBe(2) + expect(result.current.queryOffset).toBe(100) + expect(result.current.hasNext).toBe(false) + + act(() => { + result.current.previous() + }) + + expect(result.current.state.pageIndex).toBe(1) + }) + + test('should clamp to the first page when the total drops to zero', () => { + const { result } = renderHook(() => usePagingState({ initialItemsPerPage: 50 })) + + act(() => { + result.current.setTotalCount(800) + }) + act(() => { + result.current.goTo(14) + }) + act(() => { + result.current.setTotalCount(0) + }) + + expect(result.current.state.pageIndex).toBe(0) + expect(result.current.queryOffset).toBe(0) + }) +}) diff --git a/tests/react/hooks/useEntityList/refetchingOnQueryChange.test.tsx b/tests/react/hooks/useEntityList/refetchingOnQueryChange.test.tsx new file mode 100644 index 0000000..b3ca386 --- /dev/null +++ b/tests/react/hooks/useEntityList/refetchingOnQueryChange.test.tsx @@ -0,0 +1,57 @@ +import '../../../setup' +import { afterEach, expect, test } from 'bun:test' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import React, { useState } from 'react' +import { BindxProvider, MockAdapter, defineSchema, entityDef, scalar, useEntityList } from '@contember/bindx-react' + +afterEach(cleanup) + +interface Article { + id: string + title: string +} + +const schema = defineSchema<{ Article: Article }>({ + entities: { Article: { fields: { id: scalar(), title: scalar() } } }, +}) +const articleDef = entityDef
('Article') + +interface RenderObservation { + readonly offset: number + readonly isRefetching: boolean + readonly titles: readonly string[] +} + +test('items of the previous offset are reported as refetching from the render that changes the offset', async () => { + const rows = Object.fromEntries(['a', 'b', 'c', 'd'].map(id => [id, { id, title: `Title ${id}` }])) + const observations: RenderObservation[] = [] + let setOffset: (offset: number) => void = () => {} + + function List(): React.JSX.Element { + const [offset, setOffsetState] = useState(0) + setOffset = setOffsetState + const articles = useEntityList(articleDef, { limit: 2, offset, orderBy: [{ id: 'asc' }] }, a => a.id().title()) + if (articles.$status !== 'ready') return
Loading
+ const titles = articles.items.map(item => item.title.value ?? '') + observations.push({ offset, isRefetching: articles.$isRefetching, titles }) + return
{`${offset}: ${titles.join(', ')}`}
+ } + + const view = render( + + + , + ) + await waitFor(() => expect(view.getByText('0: Title a, Title b')).toBeDefined()) + + act(() => { + setOffset(2) + }) + await waitFor(() => expect(view.getByText('2: Title c, Title d')).toBeDefined()) + + const settledAtOffsetTwo = observations.filter(observation => observation.offset === 2 && !observation.isRefetching) + expect(settledAtOffsetTwo.length).toBeGreaterThan(0) + for (const observation of settledAtOffsetTwo) { + expect(observation.titles).toEqual(['Title c', 'Title d']) + } +})