diff --git a/packages/bindx-dataview/src/DataGrid.tsx b/packages/bindx-dataview/src/DataGrid.tsx index 4b5ba057..d0d4938e 100644 --- a/packages/bindx-dataview/src/DataGrid.tsx +++ b/packages/bindx-dataview/src/DataGrid.tsx @@ -5,7 +5,7 @@ * For has-many relation grids, use HasManyDataGrid instead. */ -import { memo, type ReactElement, type ReactNode, useEffect, useMemo, useCallback, useRef, useState } from 'react' +import { memo, type ReactElement, type ReactNode, useEffect, useMemo, useCallback, useState } from 'react' import type { EntityDef, EntityAccessor, @@ -21,7 +21,8 @@ import { useEntityCount, } from '@contember/bindx-react' import { useDataViewKey } from './DataViewKeyProvider.js' -import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from './DataViewContext.js' +import { DataViewProvider, type DataViewContextValue } from './DataViewContext.js' +import { resolveLoaderState } from './loaderState.js' import { useDataGridSetup, QUERY_FILTER_NAME } from './useDataGridSetup.js' import { useListFetchAllData } from './useListFetchAllData.js' import { usePagingTotalCount } from './usePagingTotalCount.js' @@ -94,9 +95,13 @@ function DataGridImpl>({ storageKey: effectiveStorageKey, }) - // ---- Loader state tracking ---- - const hasLoadedOnce = useRef(false) - const [loaderState, setLoaderState] = useState('initial') + // ---- Reload: a new query key refetches the page, a new refresh token recounts ---- + const [reloadCounter, setReloadCounter] = useState(0) + const { refreshTotalCount } = setup.paging + const reload = useCallback((): void => { + setReloadCounter(c => c + 1) + refreshTotalCount() + }, [refreshTotalCount]) // ---- Load data via useEntityList ---- const result = useEntityList(entity, { @@ -105,11 +110,13 @@ function DataGridImpl>({ limit: setup.paging.queryLimit, offset: setup.paging.queryOffset, selection: setup.selection, - queryKey: setup.queryKey, + queryKey: `${setup.queryKey}#reload:${reloadCounter}`, }) const items = result.$status === 'ready' ? result.items : [] const itemCount = items.length + const isRefetching = result.$status === 'ready' && result.$isRefetching + const loaderState = resolveLoaderState(result.$status, isRefetching) // ---- Total count via a standalone count query (keyed on filter only) ---- const { count: totalCount } = useEntityCount(entity, { @@ -117,32 +124,14 @@ function DataGridImpl>({ refreshToken: setup.paging.totalCountRefreshToken, }) - // Update loader state - useEffect(() => { - if (result.$status === 'ready') { - hasLoadedOnce.current = true - setLoaderState('loaded') - } else if (result.$status === 'error') { - setLoaderState('failed') - } else if (result.$status === 'loading') { - setLoaderState(hasLoadedOnce.current ? 'refreshing' : 'initial') - } - }, [result.$status]) - const countKey = useMemo(() => JSON.stringify(setup.combinedFilter ?? {}), [setup.combinedFilter]) usePagingTotalCount(setup.paging, { countedTotal: totalCount, countKey, - isPageCurrent: result.$status === 'ready' && !result.$isRefetching, + isPageCurrent: result.$status === 'ready' && !isRefetching, pageItemCount: itemCount, }) - // ---- Reload ---- - const [, setReloadCounter] = useState(0) - const reload = useCallback((): void => { - setReloadCounter(c => c + 1) - }, []) - // ---- Row highlighting ---- const [highlightIndex, setHighlightIndex] = useState(null) diff --git a/packages/bindx-dataview/src/DataViewContext.tsx b/packages/bindx-dataview/src/DataViewContext.tsx index c6517eb8..a70198ad 100644 --- a/packages/bindx-dataview/src/DataViewContext.tsx +++ b/packages/bindx-dataview/src/DataViewContext.tsx @@ -8,6 +8,12 @@ import type { FilteringState, SortingStateResult, PagingStateResult, SelectionSt import type { DataViewColumn } from './columnLeaf.js' import type { EntityAccessor, SelectionMeta } from '@contember/bindx' +/** + * - `initial`: loading, with no rows to show (first load, or a retry after a failure) + * - `loaded`: the rows answer the current query + * - `refreshing`: the rows shown answer an earlier query while the current one loads + * - `failed`: the current query failed + */ export type DataViewLoaderState = 'initial' | 'loaded' | 'refreshing' | 'failed' export type DataViewItem = EntityAccessor diff --git a/packages/bindx-dataview/src/HasManyDataGrid.tsx b/packages/bindx-dataview/src/HasManyDataGrid.tsx index 53c19830..b0771ba5 100644 --- a/packages/bindx-dataview/src/HasManyDataGrid.tsx +++ b/packages/bindx-dataview/src/HasManyDataGrid.tsx @@ -31,7 +31,7 @@ import { FIELD_REF_META } from '@contember/bindx' import { buildQueryFromSelection, EntityHandle, - setEntityData, + refreshServerData, } from '@contember/bindx' import type { SchemaRegistry } from '@contember/bindx' import type { StateStorageOrName } from './stateStorage.js' @@ -39,7 +39,8 @@ import { useBindxContext, } from '@contember/bindx-react' import { useDataViewKey } from './DataViewKeyProvider.js' -import { DataViewProvider, type DataViewContextValue, type DataViewFetchAllData, type DataViewLoaderState } from './DataViewContext.js' +import { DataViewProvider, type DataViewContextValue, type DataViewFetchAllData } from './DataViewContext.js' +import { resolveLoaderState } from './loaderState.js' import { useDataGridSetup } from './useDataGridSetup.js' import { buildHasManyRelationQuery, extractHasManyRelationRows } from './hasManyRelationQuery.js' import { usePagingTotalCount } from './usePagingTotalCount.js' @@ -81,8 +82,12 @@ interface ListState { status: 'loading' | 'error' | 'ready' items: Array<{ id: string; data: object }> totalCount?: number + /** The relation (parent, field, selection) the items were loaded for. */ + scopeKey?: string /** The query options the items were loaded for. */ optionsKey?: string + /** The request (query options and reload) the items were loaded for. */ + requestKey?: string } const INITIAL_LIST_STATE: ListState = { status: 'loading', items: [] } @@ -138,8 +143,6 @@ function HasManyDataGridImpl({ }) // ---- State ---- - const hasLoadedOnce = useRef(false) - const [loaderState, setLoaderState] = useState('initial') const [listState, setListState] = useState(INITIAL_LIST_STATE) const optionsKey = useMemo( @@ -152,10 +155,24 @@ function HasManyDataGridImpl({ [setup.combinedFilter, setup.sorting.resolvedOrderBy, setup.paging.queryLimit, setup.paging.queryOffset], ) + // ---- Reload: the same query loads again, the relation response carries the total ---- + const [reloadCounter, setReloadCounter] = useState(0) + const reload = useCallback((): void => { + setReloadCounter(c => c + 1) + }, []) + + const scopeKey = JSON.stringify([parentEntityType, parentEntityId, fieldName, targetEntityType, setup.queryKey]) + const requestKey = JSON.stringify([scopeKey, optionsKey, reloadCounter]) + // The request key covers the selection's content; the effect reads the latest object without refetching on a new identity. + const selectionRef = useRef(setup.selection) + selectionRef.current = setup.selection + // ---- Data loading via GetQuery on parent ---- useEffect(() => { const abortController = new AbortController() - setListState(INITIAL_LIST_STATE) + const selection = selectionRef.current + // Stale-while-revalidate: the rows of the previous request stay on screen until the new ones land. + setListState(previous => previous.status === 'ready' && previous.scopeKey === scopeKey ? previous : INITIAL_LIST_STATE) const fetchData = async (): Promise => { try { @@ -174,7 +191,7 @@ function HasManyDataGridImpl({ orderBy: currentOptions.orderBy, limit: currentOptions.limit, offset: currentOptions.offset, - targetSpec: buildQueryFromSelection(setup.selection), + targetSpec: buildQueryFromSelection(selection), }) const result = await batcher.enqueue(query, { signal: abortController.signal }) @@ -182,28 +199,29 @@ function HasManyDataGridImpl({ if (abortController.signal.aborted) return if (result.type !== 'get' || !result.data) { - setListState({ status: 'error', items: [] }) + setListState({ status: 'error', items: [], scopeKey }) return } const relation = extractHasManyRelationRows(result.data, { alias, fieldName }) if (!relation) { - setListState({ status: 'ready', items: [], optionsKey }) + setListState({ status: 'ready', items: [], scopeKey, optionsKey, requestKey }) return } - store.indexServerResponse(targetEntityType, relation.rows, setup.selection, schemaRegistry) + store.indexServerResponse(targetEntityType, relation.rows, selection, schemaRegistry) const items = relation.rows.map(data => ({ id: getRowId(targetEntityType, data), data: store.resolveServerOccurrence(data) })) store.batchNotifications(() => { for (const item of items) { - dispatcher.dispatch(setEntityData(targetEntityType, item.id, item.data, true)) + // Revalidation preserves local edits while advancing the server baseline. + dispatcher.dispatch(refreshServerData(targetEntityType, item.id, item.data)) } }) - setListState({ status: 'ready', items, totalCount: relation.totalCount, optionsKey }) + setListState({ status: 'ready', items, totalCount: relation.totalCount, scopeKey, optionsKey, requestKey }) } catch (error) { if (abortController.signal.aborted) return - setListState({ status: 'error', items: [] }) + setListState({ status: 'error', items: [], scopeKey }) } } @@ -212,12 +230,15 @@ function HasManyDataGridImpl({ return () => { abortController.abort() } - }, [parentEntityType, parentEntityId, fieldName, targetEntityType, optionsKey, setup.selection, batcher, dispatcher, store, schemaRegistry]) + }, [parentEntityType, parentEntityId, fieldName, targetEntityType, scopeKey, optionsKey, requestKey, batcher, dispatcher, store, schemaRegistry]) + + // Rows loaded for another relation (a different parent) are never shown, not even as stale. + const shownState = listState.scopeKey === scopeKey ? listState : INITIAL_LIST_STATE // ---- Build items from state ---- const items = useMemo((): EntityAccessor[] => { - if (listState.status !== 'ready') return [] - return listState.items.map((item) => + if (shownState.status !== 'ready') return [] + return shownState.items.map((item) => EntityHandle.create( item.id, targetEntityType, @@ -226,39 +247,24 @@ function HasManyDataGridImpl({ schemaRegistry as SchemaRegistry>, ) as unknown as EntityAccessor, ) - }, [listState, targetEntityType, store, dispatcher, schemaRegistry]) + }, [shownState, targetEntityType, store, dispatcher, schemaRegistry]) const itemCount = items.length - // Update loader state - useEffect(() => { - if (listState.status === 'ready') { - hasLoadedOnce.current = true - setLoaderState('loaded') - } else if (listState.status === 'error') { - setLoaderState('failed') - } else if (listState.status === 'loading') { - setLoaderState(hasLoadedOnce.current ? 'refreshing' : 'initial') - } - }, [listState.status]) + // Derived in render: on the render that changes the request, the rows already answer an earlier one. + const loaderState = resolveLoaderState(shownState.status, shownState.requestKey !== requestKey) const countKey = useMemo( () => JSON.stringify([parentEntityType, parentEntityId, fieldName, setup.combinedFilter ?? {}]), [parentEntityType, parentEntityId, fieldName, setup.combinedFilter], ) usePagingTotalCount(setup.paging, { - countedTotal: listState.totalCount ?? null, + countedTotal: shownState.totalCount ?? null, countKey, - isPageCurrent: listState.status === 'ready' && listState.optionsKey === optionsKey, + isPageCurrent: shownState.status === 'ready' && shownState.optionsKey === optionsKey, pageItemCount: itemCount, }) - // ---- Reload ---- - const [, setReloadCounter] = useState(0) - const reload = useCallback((): void => { - setReloadCounter(c => c + 1) - }, []) - // ---- Row highlighting ---- const [highlightIndex, setHighlightIndex] = useState(null) diff --git a/packages/bindx-dataview/src/infiniteLoad.tsx b/packages/bindx-dataview/src/infiniteLoad.tsx index b37005f4..5b47be30 100644 --- a/packages/bindx-dataview/src/infiniteLoad.tsx +++ b/packages/bindx-dataview/src/infiniteLoad.tsx @@ -18,7 +18,7 @@ import React, { createContext, forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactElement } from 'react' import { Slot } from '@radix-ui/react-slot' import { composeEventHandlers } from '@radix-ui/primitive' -import { useDataViewContext } from './DataViewContext.js' +import { DataViewProvider, useDataViewContext, type DataViewContextValue, type DataViewItem } from './DataViewContext.js' // ============================================================================ // Context @@ -31,7 +31,14 @@ interface InfiniteLoadContextValue { } interface PageData { - readonly items: readonly { id: string; data: Record }[] + readonly offset: number + readonly items: readonly DataViewItem[] +} + +/** Pages loaded before the current one, for the query (filter, order, page size) they belong to. */ +interface StoredPages { + readonly queryKey: string + readonly pages: readonly PageData[] } const InfiniteLoadContext = createContext(null) @@ -44,6 +51,26 @@ function useInfiniteLoadContext(): InfiniteLoadContextValue { return ctx } +/** The stored pages that run from the first row up to the current offset, or none when the view jumped. */ +function pagesBefore(pages: readonly PageData[], offset: number | undefined, limit: number | undefined): readonly PageData[] { + if (offset === undefined || limit === undefined) return [] + const run = pages.filter(page => page.offset < offset) + const isContiguous = run.length * limit === offset && run.every((page, index) => page.offset === index * limit) + return isContiguous ? run : [] +} + +function joinPages(pages: readonly PageData[], currentItems: readonly DataViewItem[]): readonly DataViewItem[] { + if (pages.length === 0) return currentItems + const seen = new Set() + const items: DataViewItem[] = [] + for (const item of [...pages.flatMap(page => page.items), ...currentItems]) { + if (seen.has(item.id)) continue + seen.add(item.id) + items.push(item) + } + return items +} + // ============================================================================ // Provider // ============================================================================ @@ -52,55 +79,70 @@ export interface DataViewInfiniteLoadProviderProps { children: React.ReactNode } +/** + * Shows the pages loaded so far as one list: its children see the stored pages plus the current one + * as the data view's `items`. `loadMore` stores the current page and moves the view to the next one; + * `reload` loads the list again from the first page. Only descendants get that `reload`: one above + * the provider refetches the view's current page alone. + */ export function DataViewInfiniteLoadProvider({ children, }: DataViewInfiniteLoadProviderProps): ReactElement { - const { paging, filtering, sorting } = useDataViewContext() - const [pages, setPages] = useState([]) - const [isLoadingMore, setIsLoadingMore] = useState(false) - - const filterKey = JSON.stringify(filtering.resolvedWhere ?? null) - const sortKey = JSON.stringify(sorting.resolvedOrderBy ?? null) - - const previousParams = useRef<{ - filterKey: string - sortKey: string - limit: number | undefined - offset: number | undefined - } | null>(null) - - const currentParams = useMemo(() => ({ - filterKey, - sortKey, - limit: paging.queryLimit, - offset: paging.queryOffset, - }), [filterKey, sortKey, paging.queryLimit, paging.queryOffset]) + const dataView = useDataViewContext() + const { paging, filtering, sorting, items, loaderState, reload: reloadPage } = dataView + const { queryOffset: offset, queryLimit: limit, hasNext, next, first } = paging + const queryKey = JSON.stringify([filtering.resolvedWhere ?? null, sorting.resolvedOrderBy ?? null, limit ?? null]) + + const [stored, setStored] = useState({ queryKey, pages: [] }) + const pages = useMemo( + () => pagesBefore(stored.queryKey === queryKey ? stored.pages : [], offset, limit), + [stored, queryKey, offset, limit], + ) + // The rows on screen answer the current offset only once the view has loaded it. + const isPageLoaded = loaderState === 'loaded' + const isLoadingMore = loaderState === 'initial' || loaderState === 'refreshing' + + // Two calls before the next render must request one page. + const requestedFromRef = useRef(null) + const requestKey = `${queryKey}@${offset ?? ''}` useEffect(() => { - const prev = previousParams.current - if (!prev) { - setPages([]) - } else if ( - prev.filterKey === currentParams.filterKey - && prev.sortKey === currentParams.sortKey - && prev.limit === currentParams.limit - && prev.limit !== undefined - && prev.offset !== undefined - && currentParams.offset === prev.offset + prev.limit - ) { - setIsLoadingMore(true) - } else { - setPages([]) + requestedFromRef.current = null + }, [requestKey, loaderState]) + + const loadMore = useMemo((): (() => void) | undefined => { + if (!hasNext || offset === undefined) return undefined + return () => { + if (!isPageLoaded || requestedFromRef.current === requestKey) return + requestedFromRef.current = requestKey + const currentPage: PageData = { offset, items } + setStored({ queryKey, pages: [...pages, currentPage] }) + next() } - previousParams.current = currentParams - }, [currentParams]) + }, [hasNext, isPageLoaded, offset, requestKey, queryKey, pages, items, next]) - const hasNext = paging.hasNext + // The list shown when a reload started stays on screen until the first page lands. + const [reloadedList, setReloadedList] = useState(null) + if (reloadedList !== null && !isLoadingMore) { + setReloadedList(null) + } - const loadMore = useMemo((): (() => void) | undefined => { - if (!hasNext) return undefined - return () => paging.next() - }, [hasNext, paging]) + const joinedItems = useMemo(() => joinPages(pages, items), [pages, items]) + const shownItems = reloadedList ?? joinedItems + + // The view refetches only its current page, so a reload starts the list again from the first one. + const reload = useCallback((): void => { + // A reload during a reload keeps the list shown at the first one. + setReloadedList(current => current ?? joinedItems) + setStored({ queryKey, pages: [] }) + first() + reloadPage() + }, [joinedItems, queryKey, first, reloadPage]) + + const dataViewValue = useMemo( + (): DataViewContextValue => ({ ...dataView, items: shownItems, itemCount: shownItems.length, reload }), + [dataView, shownItems, reload], + ) const contextValue = useMemo((): InfiniteLoadContextValue => ({ pages, @@ -109,9 +151,11 @@ export function DataViewInfiniteLoadProvider({ }), [pages, loadMore, isLoadingMore]) return ( - - {children} - + + + {children} + + ) } diff --git a/packages/bindx-dataview/src/loaderComponents.tsx b/packages/bindx-dataview/src/loaderComponents.tsx index edc737f1..6ad87a80 100644 --- a/packages/bindx-dataview/src/loaderComponents.tsx +++ b/packages/bindx-dataview/src/loaderComponents.tsx @@ -60,9 +60,9 @@ export interface DataViewLoaderStateProps { children: React.ReactNode /** Render children when data is loaded */ loaded?: boolean - /** Render children when refreshing (after initial load) */ + /** Render children while rows of an earlier query are shown and the current one loads */ refreshing?: boolean - /** Render children during initial load */ + /** Render children while loading with no rows to show */ initial?: boolean /** Render children on failure */ failed?: boolean @@ -82,9 +82,9 @@ export function DataViewLoaderState({ export interface DataViewLoaderStateSwitchProps { /** Render when data is loaded */ loaded?: React.ReactNode - /** Render when refreshing (after initial load) */ + /** Render while rows of an earlier query are shown and the current one loads */ refreshing?: React.ReactNode - /** Render during initial load */ + /** Render while loading with no rows to show */ initial?: React.ReactNode /** Render on failure */ failed?: React.ReactNode @@ -136,7 +136,9 @@ export interface DataViewEmptyProps { export function DataViewEmpty({ children }: DataViewEmptyProps): ReactElement { const { loaderState, itemCount } = useDataViewContext() - if (loaderState !== 'loaded' || itemCount > 0) return <>{null} + // An empty result stays on screen while the current query loads. + const isShowingResult = loaderState === 'loaded' || loaderState === 'refreshing' + if (!isShowingResult || itemCount > 0) return <>{null} return <>{children} } @@ -146,7 +148,9 @@ export interface DataViewNonEmptyProps { export function DataViewNonEmpty({ children }: DataViewNonEmptyProps): ReactElement { const { loaderState, itemCount } = useDataViewContext() - if (loaderState !== 'loaded' || itemCount === 0) return <>{null} + // Rows of an earlier query stay mounted while the current one loads. + const isShowingResult = loaderState === 'loaded' || loaderState === 'refreshing' + if (!isShowingResult || itemCount === 0) return <>{null} return <>{children} } diff --git a/packages/bindx-dataview/src/loaderState.ts b/packages/bindx-dataview/src/loaderState.ts new file mode 100644 index 00000000..e6d49cbd --- /dev/null +++ b/packages/bindx-dataview/src/loaderState.ts @@ -0,0 +1,14 @@ +import type { DataViewLoaderState } from './DataViewContext.js' + +export type ListLoadStatus = 'loading' | 'error' | 'ready' + +/** + * Derives the loader state in render, so it always describes the rows rendered with it. + * `refreshing` means the rows on screen answer an earlier query while the current one loads; + * a load with no rows to show is `initial`. + */ +export function resolveLoaderState(status: ListLoadStatus, isRefetching: boolean): DataViewLoaderState { + if (status === 'ready') return isRefetching ? 'refreshing' : 'loaded' + if (status === 'error') return 'failed' + return 'initial' +} diff --git a/packages/bindx-dataview/src/select/SelectDataView.tsx b/packages/bindx-dataview/src/select/SelectDataView.tsx index aa2a8357..a18389f4 100644 --- a/packages/bindx-dataview/src/select/SelectDataView.tsx +++ b/packages/bindx-dataview/src/select/SelectDataView.tsx @@ -43,7 +43,8 @@ import { collectSelection, } from '@contember/bindx-react' import { useSelectOptions } from './selectContext.js' -import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from '../DataViewContext.js' +import { DataViewProvider, type DataViewContextValue } from '../DataViewContext.js' +import { resolveLoaderState } from '../loaderState.js' import { useFilteringState, useSortingState, usePagingState, useSelectionState } from '../useDataViewState.js' import { useListFetchAllData } from '../useListFetchAllData.js' import { usePagingTotalCount } from '../usePagingTotalCount.js' @@ -77,10 +78,6 @@ function SelectDataViewImpl({ const { schema: schemaRegistry } = useBindxContext() const entityType = options.$name - // ---- Loader state tracking ---- - const hasLoadedOnce = useRef(false) - const [loaderState, setLoaderState] = useState('initial') - // ---- Build selection from definer ---- // Use ref for selectionDefiner to avoid re-running on every render // (inline arrow functions create new refs each render) @@ -154,6 +151,22 @@ function SelectDataViewImpl({ if (parts.length === 1) return parts[0] return { and: parts } }, [staticFilter, filtering.resolvedWhere]) + const countKey = useMemo(() => JSON.stringify(combinedFilter ?? {}), [combinedFilter]) + + // A new search starts on the first page. Reset during render, so no query goes out at the old offset. + const [pagedFilterKey, setPagedFilterKey] = useState(countKey) + if (pagedFilterKey !== countKey) { + setPagedFilterKey(countKey) + paging.first() + } + + // ---- Reload: a new query key refetches the page, a new refresh token recounts ---- + const [reloadCounter, setReloadCounter] = useState(0) + const { refreshTotalCount } = paging + const reload = useCallback((): void => { + setReloadCounter(c => c + 1) + refreshTotalCount() + }, [refreshTotalCount]) // ---- Load data ---- const result = useEntityList(options, { @@ -162,11 +175,13 @@ function SelectDataViewImpl({ limit: paging.queryLimit, offset: paging.queryOffset, selection, - queryKey, + queryKey: `${queryKey}#reload:${reloadCounter}`, }) const items = result.$status === 'ready' ? result.items : [] const itemCount = items.length + const isRefetching = result.$status === 'ready' && result.$isRefetching + const loaderState = resolveLoaderState(result.$status, isRefetching) // ---- Total count via a standalone count query (keyed on filter only) ---- const { count: totalCount } = useEntityCount(options, { @@ -174,32 +189,13 @@ function SelectDataViewImpl({ refreshToken: paging.totalCountRefreshToken, }) - // Update loader state - useEffect(() => { - if (result.$status === 'ready') { - hasLoadedOnce.current = true - setLoaderState('loaded') - } else if (result.$status === 'error') { - setLoaderState('failed') - } else if (result.$status === 'loading') { - setLoaderState(hasLoadedOnce.current ? 'refreshing' : 'initial') - } - }, [result.$status]) - - const countKey = useMemo(() => JSON.stringify(combinedFilter ?? {}), [combinedFilter]) usePagingTotalCount(paging, { countedTotal: totalCount, countKey, - isPageCurrent: result.$status === 'ready' && !result.$isRefetching, + isPageCurrent: result.$status === 'ready' && !isRefetching, pageItemCount: itemCount, }) - // ---- Reload ---- - const [, setReloadCounter] = useState(0) - const reload = useCallback((): void => { - setReloadCounter(c => c + 1) - }, []) - // ---- Row highlighting ---- const [highlightIndex, setHighlightIndex] = useState(null) diff --git a/packages/bindx-dataview/src/usePagingTotalCount.ts b/packages/bindx-dataview/src/usePagingTotalCount.ts index b716738b..3e6777e7 100644 --- a/packages/bindx-dataview/src/usePagingTotalCount.ts +++ b/packages/bindx-dataview/src/usePagingTotalCount.ts @@ -31,8 +31,9 @@ type PageOutcome = 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. + * Feeds the paging state its total: the total a current page proves by ending the list, else the counted + * total when the data source reports one, else 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. @@ -46,15 +47,19 @@ export function usePagingTotalCount(paging: PagingStateResult, source: PagingTot if (boundsRef.current.countKey !== countKey || countedTotal !== null) { boundsRef.current = { countKey, bounds: NO_BOUNDS } } + const page = isPageCurrent && queryLimit !== undefined && queryOffset !== undefined + ? { offset: queryOffset, limit: queryLimit, itemCount: pageItemCount } + : null if (countedTotal !== null) { - setTotalCount(countedTotal) + // The count may still describe an earlier row set; a current page that ends the list does not. + setTotalCount((page && totalEndedByPage(page)) ?? countedTotal) return } - if (!isPageCurrent || queryLimit === undefined || queryOffset === undefined) { + if (!page) { return } - const outcome = inferTotalFromPage(boundsRef.current.bounds, { offset: queryOffset, limit: queryLimit, itemCount: pageItemCount }) + const outcome = inferTotalFromPage(boundsRef.current.bounds, page) if (outcome.type === 'total') { boundsRef.current = { countKey, bounds: NO_BOUNDS } setTotalCount(outcome.totalCount) @@ -67,12 +72,21 @@ export function usePagingTotalCount(paging: PagingStateResult, source: PagingTot }, [countedTotal, countKey, isPageCurrent, pageItemCount, queryLimit, queryOffset, setTotalCount, goTo]) } -function inferTotalFromPage(bounds: TotalBounds, { offset, limit, itemCount }: LoadedPage): PageOutcome { +/** The total, when the page is shorter than the limit and so ends the list. An empty page past offset 0 proves no total. */ +function totalEndedByPage({ offset, limit, itemCount }: LoadedPage): number | null { const isEmptyPastStart = itemCount === 0 && offset > 0 - if (itemCount < limit && !isEmptyPastStart) { - return { type: 'total', totalCount: offset + itemCount } + return itemCount < limit && !isEmptyPastStart ? offset + itemCount : null +} + +function inferTotalFromPage(bounds: TotalBounds, page: LoadedPage): PageOutcome { + const { offset, limit, itemCount } = page + const endedTotal = totalEndedByPage(page) + if (endedTotal !== null) { + return { type: 'total', totalCount: endedTotal } } + const isEmptyPastStart = itemCount === 0 && offset > 0 + const narrowed: TotalBounds = isEmptyPastStart ? { atLeast: bounds.atLeast, atMost: Math.min(bounds.atMost ?? offset, offset) } : { atLeast: Math.max(bounds.atLeast, offset + limit), atMost: bounds.atMost } diff --git a/tests/react/dataview/dataViewReload.test.tsx b/tests/react/dataview/dataViewReload.test.tsx new file mode 100644 index 00000000..d46942a4 --- /dev/null +++ b/tests/react/dataview/dataViewReload.test.tsx @@ -0,0 +1,326 @@ +// Issue #124: reload() from the data view context never refetched the list or its count. +// A row changed by a mutation outside the view must leave (or join) it after reload(). +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import React, { type ReactElement } from 'react' +import { + BindxProvider, + Entity, + MockAdapter, + defineSchema, + entityDef, + hasMany, + hasOne, + scalar, + useEntity, +} from '@contember/bindx-react' +import { + DataGrid, + DataGridTextColumn, + DataViewEachRow, + DataViewEmpty, + DataViewNonEmpty, + HasManyDataGrid, + Select, + SelectDataView, + useDataViewContext, + useDataViewReload, +} from '@contember/bindx-dataview' +import type { BackendAdapter, Query, QueryOptions, QueryResult } from '@contember/bindx' +import { TestPagination, TestTable, getByTestId, getRowCount, queryByTestId } from './helpers.js' + +afterEach(cleanup) + +interface Task { + id: string + title: string + finished: boolean +} + +interface Project { + id: string + tasks: Task[] + lead: Task | null +} + +const schema = defineSchema<{ Task: Task; Project: Project }>({ + entities: { + Task: { fields: { id: scalar(), title: scalar(), finished: scalar() } }, + Project: { fields: { id: scalar(), tasks: hasMany('Task'), lead: hasOne('Task') } }, + }, +}) +const taskDef = entityDef('Task') +const projectDef = entityDef('Project') + +function createAdapter(): MockAdapter { + const tasks = [1, 2, 3].map(i => ({ id: `t${i}`, title: `Task ${i}`, finished: false })) + return new MockAdapter({ + Task: Object.fromEntries(tasks.map(task => [task.id, { ...task }])), + Project: { p1: { id: 'p1', tasks, lead: null } }, + }, { delay: 20 }) +} + +/** Finishes a task behind the view's back, as a separate mutation would. */ +function finishTaskOnServer(adapter: MockAdapter, id: string): void { + const store = adapter.getStore() + const task = store['Task']?.[id] + if (!task) throw new Error(`no task ${id}`) + task['finished'] = true + const project = store['Project']?.['p1'] + const projectTasks = project?.['tasks'] + if (project && Array.isArray(projectTasks)) { + project['tasks'] = projectTasks.filter(it => it.id !== id) + } +} + +/** Fails the first list query, then answers like the inner adapter. */ +class FailFirstListAdapter implements BackendAdapter { + private hasFailed = false + + constructor(private readonly inner: MockAdapter) {} + + async query(queries: readonly Query[], options?: QueryOptions): Promise { + if (!this.hasFailed && queries.some(query => query.type === 'list')) { + this.hasFailed = true + throw new Error('list query failed') + } + return this.inner.query(queries, options) + } + + persist(...args: Parameters): ReturnType { + return this.inner.persist(...args) + } +} + +function ReloadProbe(): ReactElement { + const reload = useDataViewReload() + const { loaderState } = useDataViewContext() + return ( + <> + + {loaderState} + + ) +} + +async function clickReload(container: HTMLElement): Promise { + await act(async () => { + fireEvent.click(getByTestId(container, 'reload')) + }) +} + +const openTasks = { finished: { eq: false } } + +describe('data view reload()', () => { + test('DataGrid refetches the rows and the total, keeping the rows while it loads', async () => { + const adapter = createAdapter() + const { container } = render( + + + {it => ( + <> + + + + + + )} + + , + ) + await waitFor(() => expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('3 total')) + expect(getRowCount(container)).toBe(3) + + finishTaskOnServer(adapter, 't1') + await clickReload(container) + + expect(getByTestId(container, 'loader-state').textContent).toBe('refreshing') + expect(getRowCount(container)).toBe(3) + await waitFor(() => expect(getRowCount(container)).toBe(2)) + await waitFor(() => expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('2 total')) + expect(getByTestId(container, 'loader-state').textContent).toBe('loaded') + }) + + test('HasManyDataGrid refetches the relation rows and the total, keeping the rows while it loads', async () => { + const adapter = createAdapter() + const { container } = render( + + + {project => ( + + {it => ( + <> + + + + + + )} + + )} + + , + ) + await waitFor(() => expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('3 total')) + expect(getRowCount(container)).toBe(3) + + finishTaskOnServer(adapter, 't1') + await clickReload(container) + + expect(getByTestId(container, 'loader-state').textContent).toBe('refreshing') + expect(getRowCount(container)).toBe(3) + await waitFor(() => expect(getRowCount(container)).toBe(2)) + await waitFor(() => expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('2 total')) + expect(getByTestId(container, 'loader-state').textContent).toBe('loaded') + }) + + test('SelectDataView refetches the options and the total', async () => { + const adapter = createAdapter() + + function Picker(): ReactElement { + const project = useEntity(projectDef, { by: { id: 'p1' } }, e => e.id().lead(t => t.id().title())) + if (project.$status !== 'ready') return + return ( + + ) + } + + const { container } = render( + + + , + ) + const optionCount = (): number => container.querySelectorAll('[data-testid="option"]').length + await waitFor(() => expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('3 total')) + expect(optionCount()).toBe(3) + + finishTaskOnServer(adapter, 't1') + await clickReload(container) + + expect(getByTestId(container, 'loader-state').textContent).toBe('refreshing') + expect(optionCount()).toBe(3) + await waitFor(() => expect(optionCount()).toBe(2)) + await waitFor(() => expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('2 total')) + }) + + test('rows inside DataViewNonEmpty stay mounted while the grid refetches', async () => { + const adapter = createAdapter() + const { container } = render( + + + {it => ( + <> + + + {item => } + + + + )} + + , + ) + const rowCount = (): number => container.querySelectorAll('[data-testid="row"]').length + await waitFor(() => expect(rowCount()).toBe(3)) + + finishTaskOnServer(adapter, 't1') + await clickReload(container) + + expect(getByTestId(container, 'loader-state').textContent).toBe('refreshing') + expect(rowCount()).toBe(3) + await waitFor(() => expect(rowCount()).toBe(2)) + }) + + test('an empty result stays on screen while the grid refetches', async () => { + const adapter = createAdapter() + const { container } = render( + + + {it => ( + <> + + + + + )} + + , + ) + await waitFor(() => expect(queryByTestId(container, 'no-results')).not.toBeNull()) + + await clickReload(container) + + expect(getByTestId(container, 'loader-state').textContent).toBe('refreshing') + expect(queryByTestId(container, 'no-results')).not.toBeNull() + await waitFor(() => expect(getByTestId(container, 'loader-state').textContent).toBe('loaded')) + expect(queryByTestId(container, 'no-results')).not.toBeNull() + }) + + test('HasManyDataGrid keeps the previous page on screen while the next one loads', async () => { + const adapter = createAdapter() + const { container } = render( + + + {project => ( + + {it => ( + <> + + + {item => } + + + + + )} + + )} + + , + ) + const rowIds = (): (string | null)[] => Array.from(container.querySelectorAll('[data-testid="row"]')).map(el => el.getAttribute('data-id')) + await waitFor(() => expect(rowIds()).toEqual(['t1', 't2'])) + + await act(async () => { + fireEvent.click(getByTestId(container, 'datagrid-pagination-next')) + }) + + expect(getByTestId(container, 'loader-state').textContent).toBe('refreshing') + expect(rowIds()).toEqual(['t1', 't2']) + await waitFor(() => expect(rowIds()).toEqual(['t3'])) + expect(getByTestId(container, 'loader-state').textContent).toBe('loaded') + }) + + test('a retry after a failure loads as initial, with no rows to keep', async () => { + const adapter = new FailFirstListAdapter(createAdapter()) + const { container } = render( + + + {it => ( + <> + + + + + )} + + , + ) + await waitFor(() => expect(getByTestId(container, 'loader-state').textContent).toBe('failed')) + + await clickReload(container) + + expect(getByTestId(container, 'loader-state').textContent).toBe('initial') + await waitFor(() => expect(getRowCount(container)).toBe(3)) + expect(getByTestId(container, 'loader-state').textContent).toBe('loaded') + }) +}) diff --git a/tests/react/dataview/hasManyDataGridStaleRows.test.tsx b/tests/react/dataview/hasManyDataGridStaleRows.test.tsx new file mode 100644 index 00000000..3a2eaa77 --- /dev/null +++ b/tests/react/dataview/hasManyDataGridStaleRows.test.tsx @@ -0,0 +1,195 @@ +// HasManyDataGrid keeps the previous rows on screen while a refetch loads (stale-while-revalidate). +// Its loader state must describe the rows rendered with it on every render, rows of another +// parent must never show, and a refetch must not overwrite edits made to the rows meanwhile. +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import React, { useState, type ReactElement } from 'react' +import { + BindxProvider, + Entity, + MockAdapter, + defineSchema, + entityDef, + hasMany, + scalar, + useEntity, +} from '@contember/bindx-react' +import type { DataViewLoaderStateType } from '@contember/bindx-dataview' +import { + DataGridTextColumn, + DataViewEachRow, + HasManyDataGrid, + useDataViewContext, + useDataViewReload, +} from '@contember/bindx-dataview' +import { TestPagination, getByTestId } from './helpers.js' + +afterEach(cleanup) + +interface Task { + id: string + title: string +} + +interface Project { + id: string + tasks: Task[] +} + +const schema = defineSchema<{ Task: Task; Project: Project }>({ + entities: { + Task: { fields: { id: scalar(), title: scalar() } }, + Project: { fields: { id: scalar(), tasks: hasMany('Task') } }, + }, +}) +const taskDef = entityDef('Task') +const projectDef = entityDef('Project') + +const PROJECT_TASKS: Record = { + p1: ['t1', 't2', 't3'], + p2: ['t4'], +} + +function createAdapter(): MockAdapter { + const task = (id: string): Record => ({ id, title: `Task ${id}` }) + return new MockAdapter({ + Task: Object.fromEntries(['t1', 't2', 't3', 't4'].map(id => [id, task(id)])), + Project: Object.fromEntries(Object.entries(PROJECT_TASKS).map(([id, tasks]) => [id, { id, tasks: tasks.map(task) }])), + }, { delay: 20 }) +} + +interface RenderRecord { + readonly projectId: string + readonly pageIndex: number + readonly loaderState: DataViewLoaderStateType + readonly rowIds: readonly string[] +} + +/** Records what every render of the grid's children shows. */ +function Recorder({ projectId, records }: { projectId: string; records: RenderRecord[] }): ReactElement { + const { loaderState, items, paging } = useDataViewContext() + const reload = useDataViewReload() + records.push({ projectId, pageIndex: paging.state.pageIndex, loaderState, rowIds: items.map(item => item.id) }) + return ( + <> + {loaderState} + + + ) +} + +function ProjectTasks({ projectId, records, itemsPerPage }: { projectId: string; records: RenderRecord[]; itemsPerPage?: number }): ReactElement { + return ( + + {project => ( + + {it => ( + <> + + {item => } + + + + )} + + )} + + ) +} + +const rowIds = (container: HTMLElement): (string | null)[] => + Array.from(container.querySelectorAll('[data-testid="row"]')).map(el => el.getAttribute('data-id')) + +async function click(container: HTMLElement, testId: string): Promise { + await act(async () => { + fireEvent.click(getByTestId(container, testId)) + }) +} + +describe('HasManyDataGrid stale rows', () => { + test('every render that reads loaded shows the rows of the current page', async () => { + const records: RenderRecord[] = [] + const { container } = render( + + + , + ) + await waitFor(() => expect(rowIds(container)).toEqual(['t1', 't2'])) + + await click(container, 'datagrid-pagination-next') + await waitFor(() => expect(rowIds(container)).toEqual(['t3'])) + + const pages = [['t1', 't2'], ['t3']] + const loaded = records.filter(record => record.loaderState === 'loaded') + expect(loaded.length).toBeGreaterThan(0) + for (const record of loaded) { + expect(record.rowIds).toEqual(pages[record.pageIndex] ?? []) + } + expect(records.some(record => record.loaderState === 'refreshing' && record.pageIndex === 1 && record.rowIds.join() === 't1,t2')).toBe(true) + }) + + test('rows of another parent are never shown', async () => { + const records: RenderRecord[] = [] + + function Switcher(): ReactElement { + const [projectId, setProjectId] = useState('p1') + return ( + <> + + + + + ) + } + + const { container } = render( + + + , + ) + await waitFor(() => expect(rowIds(container)).toEqual(['t1', 't2', 't3'])) + await click(container, 'p2') + await waitFor(() => expect(rowIds(container)).toEqual(['t4'])) + await click(container, 'p1') + await waitFor(() => expect(rowIds(container)).toEqual(['t1', 't2', 't3'])) + await waitFor(() => expect(getByTestId(container, 'loader-state').textContent).toBe('loaded')) + + for (const record of records) { + const ownTasks = PROJECT_TASKS[record.projectId] ?? [] + for (const id of record.rowIds) { + expect(ownTasks).toContain(id) + } + } + }) + + test('a refetch keeps edits made to the rows', async () => { + const records: RenderRecord[] = [] + + function TitleEditor(): ReactElement { + const task = useEntity(taskDef, { by: { id: 't1' } }, e => e.title()) + if (task.$status !== 'ready') return + return ( + <> + {task.title.value} + + + ) + } + + const { container } = render( + + + + , + ) + await waitFor(() => expect(rowIds(container)).toEqual(['t1', 't2', 't3'])) + await waitFor(() => expect(getByTestId(container, 'title').textContent).toBe('Task t1')) + + await click(container, 'edit') + await click(container, 'reload') + await waitFor(() => expect(getByTestId(container, 'loader-state').textContent).toBe('loaded')) + + expect(getByTestId(container, 'title').textContent).toBe('Edited') + }) +}) diff --git a/tests/react/dataview/infiniteLoad.test.tsx b/tests/react/dataview/infiniteLoad.test.tsx new file mode 100644 index 00000000..3f429234 --- /dev/null +++ b/tests/react/dataview/infiniteLoad.test.tsx @@ -0,0 +1,196 @@ +// Issue #146: DataViewInfiniteLoadProvider replaced the rows with each page instead of accumulating them, +// and never cleared its loading flag, so no page past the second could be loaded. +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import React, { type ReactElement } from 'react' +import { BindxProvider, MockAdapter, defineSchema, entityDef, hasOne, scalar, useEntity } from '@contember/bindx-react' +import { + DataViewEachRow, + DataViewInfiniteLoadProvider, + DataViewInfiniteLoadTrigger, + QUERY_FILTER_NAME, + Select, + SelectDataView, + useDataViewContext, + useDataViewReload, +} from '@contember/bindx-dataview' +import { getByTestId, queryByTestId } from './helpers.js' + +afterEach(cleanup) + +interface Tag { + id: string + name: string +} + +interface Article { + id: string + tag: Tag | null +} + +const schema = defineSchema<{ Tag: Tag; Article: Article }>({ + entities: { + Tag: { fields: { id: scalar(), name: scalar() } }, + Article: { fields: { id: scalar(), tag: hasOne('Tag') } }, + }, +}) +const tagDef = entityDef('Tag') +const articleDef = entityDef
('Article') + +const TAG_COUNT = 106 + +function tagId(i: number): string { + return `t${String(i).padStart(3, '0')}` +} + +// 106 tags; every tenth one also matches "ten". +function createAdapter(): MockAdapter { + const Tag: Record> = {} + for (let i = 1; i <= TAG_COUNT; i++) { + Tag[tagId(i)] = { id: tagId(i), name: `Tag ${String(i).padStart(3, '0')}${i % 10 === 0 ? ' ten' : ''}` } + } + return new MockAdapter({ Tag, Article: { a1: { id: 'a1', tag: null } } }, { delay: 10 }) +} + +function SearchButton(): ReactElement { + const { filtering } = useDataViewContext() + return ( + + ) +} + +function ReloadButton(): ReactElement { + const reload = useDataViewReload() + return +} + +function renderPicker(adapter: MockAdapter): HTMLElement { + function Picker(): ReactElement { + const article = useEntity(articleDef, { by: { id: 'a1' } }, e => e.id().tag(t => t.id().name())) + if (article.$status !== 'ready') return + return ( + + ) + } + const { container } = render( + + + , + ) + return container +} + +function optionIds(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('[data-testid="option"]')).map(el => el.getAttribute('data-id') ?? '') +} + +function loadMoreButton(container: HTMLElement): HTMLButtonElement | null { + const button = queryByTestId(container, 'load-more') + return button instanceof HTMLButtonElement ? button : null +} + +async function loadMore(container: HTMLElement): Promise { + await waitFor(() => expect(loadMoreButton(container)?.disabled).toBe(false)) + await act(async () => { + fireEvent.click(getByTestId(container, 'load-more')) + }) +} + +const expectedIds = (count: number): string[] => Array.from({ length: count }, (_, i) => tagId(i + 1)) + +describe('DataViewInfiniteLoadProvider', () => { + test('each loaded page is appended, page after page, until the list ends', async () => { + const container = renderPicker(createAdapter()) + await waitFor(() => expect(optionIds(container)).toHaveLength(50)) + + await loadMore(container) + await waitFor(() => expect(optionIds(container)).toHaveLength(100)) + expect(optionIds(container)).toEqual(expectedIds(100)) + + await loadMore(container) + await waitFor(() => expect(optionIds(container)).toHaveLength(TAG_COUNT)) + expect(optionIds(container)).toEqual(expectedIds(TAG_COUNT)) + const indices = Array.from(container.querySelectorAll('[data-testid="option"]')).map(el => el.getAttribute('data-index')) + expect(indices).toEqual(Array.from({ length: TAG_COUNT }, (_, i) => String(i))) + + await waitFor(() => expect(loadMoreButton(container)).toBeNull()) + }) + + test('two calls before the next render load one page', async () => { + const container = renderPicker(createAdapter()) + await waitFor(() => expect(optionIds(container)).toHaveLength(50)) + await waitFor(() => expect(loadMoreButton(container)?.disabled).toBe(false)) + + await act(async () => { + fireEvent.click(getByTestId(container, 'load-more')) + fireEvent.click(getByTestId(container, 'load-more')) + }) + await waitFor(() => expect(optionIds(container)).toHaveLength(100)) + expect(optionIds(container)).toEqual(expectedIds(100)) + }) + + test('a new search drops the pages of the previous one', async () => { + const container = renderPicker(createAdapter()) + await waitFor(() => expect(optionIds(container)).toHaveLength(50)) + await loadMore(container) + await waitFor(() => expect(optionIds(container)).toHaveLength(100)) + + await act(async () => { + fireEvent.click(getByTestId(container, 'search-ten')) + }) + await waitFor(() => expect(optionIds(container)).toEqual(Array.from({ length: 10 }, (_, i) => tagId((i + 1) * 10)))) + }) + + test('reload() loads the list again from the first page', async () => { + const adapter = createAdapter() + const container = renderPicker(adapter) + await waitFor(() => expect(optionIds(container)).toHaveLength(50)) + await loadMore(container) + await waitFor(() => expect(optionIds(container)).toHaveLength(100)) + + delete adapter.getStore()['Tag']?.[tagId(1)] + await act(async () => { + fireEvent.click(getByTestId(container, 'reload')) + }) + + // The list stays as it was until the first page lands. + expect(optionIds(container)).toEqual(expectedIds(100)) + await waitFor(() => expect(optionIds(container)).toEqual(expectedIds(51).slice(1))) + }) + + test('a reload during a reload keeps the list shown at the first one', async () => { + const container = renderPicker(createAdapter()) + await waitFor(() => expect(optionIds(container)).toHaveLength(50)) + await loadMore(container) + await waitFor(() => expect(optionIds(container)).toHaveLength(100)) + + await act(async () => { + fireEvent.click(getByTestId(container, 'reload')) + }) + await act(async () => { + fireEvent.click(getByTestId(container, 'reload')) + }) + + expect(optionIds(container)).toEqual(expectedIds(100)) + await waitFor(() => expect(optionIds(container)).toEqual(expectedIds(50))) + }) +}) diff --git a/tests/react/dataview/selectDataViewPaging.test.tsx b/tests/react/dataview/selectDataViewPaging.test.tsx new file mode 100644 index 00000000..d07dd629 --- /dev/null +++ b/tests/react/dataview/selectDataViewPaging.test.tsx @@ -0,0 +1,156 @@ +// Issue #145: a narrowed search in SelectDataView kept the page index of the previous search, +// and judged `hasNext` by a total that did not belong to the rows on screen. +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import React, { type ReactElement } from 'react' +import { BindxProvider, MockAdapter, defineSchema, entityDef, hasOne, scalar, useEntity } from '@contember/bindx-react' +import type { BackendAdapter, Query, QueryOptions, QueryResult } from '@contember/bindx' +import { + DataViewEachRow, + QUERY_FILTER_NAME, + Select, + SelectDataView, + useDataViewContext, +} from '@contember/bindx-dataview' +import { getByTestId, queryByTestId } from './helpers.js' + +afterEach(cleanup) + +interface Tag { + id: string + name: string +} + +interface Article { + id: string + tag: Tag | null +} + +const schema = defineSchema<{ Tag: Tag; Article: Article }>({ + entities: { + Tag: { fields: { id: scalar(), name: scalar() } }, + Article: { fields: { id: scalar(), tag: hasOne('Tag') } }, + }, +}) +const tagDef = entityDef('Tag') +const articleDef = entityDef
('Article') + +// 120 tags named "Tag 001" … "Tag 120"; three of them also match "special". +function createStore(): Record>> { + const Tag: Record> = {} + for (let i = 1; i <= 120; i++) { + const id = `t${String(i).padStart(3, '0')}` + const special = i % 40 === 0 ? ' special' : '' + Tag[id] = { id, name: `Tag ${String(i).padStart(3, '0')}${special}` } + } + return { Tag, Article: { a1: { id: 'a1', tag: null } } } +} + +interface ListRequest { + readonly filter: string + readonly offset: number | undefined +} + +/** Records every list request; can answer count queries with the unfiltered total, as a count that lags behind would. */ +class SpyAdapter implements BackendAdapter { + readonly listRequests: ListRequest[] = [] + + constructor(private readonly inner: MockAdapter, private readonly countIgnoresFilter = false) {} + + async query(queries: readonly Query[], options?: QueryOptions): Promise { + for (const query of queries) { + if (query.type === 'list' && query.entityType === 'Tag') { + this.listRequests.push({ filter: JSON.stringify(query.filter ?? {}), offset: query.offset }) + } + } + const forwarded = this.countIgnoresFilter + ? queries.map(query => query.type === 'count' ? { ...query, filter: undefined } : query) + : queries + return this.inner.query(forwarded, options) + } + + persist(...args: Parameters): ReturnType { + return this.inner.persist(...args) + } +} + +function Controls(): ReactElement { + const { filtering, paging, items, loaderState } = useDataViewContext() + return ( + <> + + + {paging.state.pageIndex} + {String(paging.hasNext)} + {items.length} + {loaderState} + + ) +} + +function renderPicker(adapter: BackendAdapter): HTMLElement { + function Picker(): ReactElement { + const article = useEntity(articleDef, { by: { id: 'a1' } }, e => e.id().tag(t => t.id().name())) + if (article.$status !== 'ready') return + return ( + + ) + } + const { container } = render( + + + , + ) + return container +} + +const text = (container: HTMLElement, testId: string): string | null => getByTestId(container, testId).textContent + +async function click(container: HTMLElement, testId: string): Promise { + await act(async () => { + fireEvent.click(getByTestId(container, testId)) + }) +} + +describe('SelectDataView paging', () => { + test('a new search starts on the first page', async () => { + const adapter = new SpyAdapter(new MockAdapter(createStore(), { delay: 0 })) + const container = renderPicker(adapter) + await waitFor(() => expect(queryByTestId(container, 'item-count')?.textContent).toBe('50')) + + await click(container, 'next') + await waitFor(() => expect(text(container, 'page')).toBe('1')) + await waitFor(() => expect(text(container, 'loader-state')).toBe('loaded')) + + await click(container, 'search-special') + await waitFor(() => expect(text(container, 'item-count')).toBe('3')) + + expect(text(container, 'page')).toBe('0') + const searchRequests = adapter.listRequests.filter(request => request.filter.includes('special')) + expect(searchRequests.map(request => request.offset)).toEqual([0]) + }) + + test('a short page has no next page, whatever the counted total says', async () => { + const adapter = new SpyAdapter(new MockAdapter(createStore(), { delay: 0 }), true) + const container = renderPicker(adapter) + await waitFor(() => expect(queryByTestId(container, 'item-count')?.textContent).toBe('50')) + expect(text(container, 'has-next')).toBe('true') + + await click(container, 'search-special') + await waitFor(() => expect(text(container, 'item-count')).toBe('3')) + await waitFor(() => expect(text(container, 'loader-state')).toBe('loaded')) + + expect(text(container, 'has-next')).toBe('false') + }) +})