Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions packages/bindx-dataview/src/DataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -108,6 +109,12 @@ function DataGridImpl<TRoleMap extends Record<string, object>>({
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') {
Expand All @@ -120,12 +127,17 @@ function DataGridImpl<TRoleMap extends Record<string, object>>({
}
}, [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)
Expand Down
16 changes: 13 additions & 3 deletions packages/bindx-dataview/src/select/SelectDataView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type { FieldRef } from '@contember/bindx'
import {
useBindxContext,
useEntityList,
useEntityCount,
createCollectorProxy,
mergeSelections,
collectSelection,
Expand Down Expand Up @@ -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') {
Expand All @@ -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)
Expand Down
17 changes: 15 additions & 2 deletions packages/bindx-dataview/src/useDataView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion packages/bindx-dataview/src/useDataViewState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -345,6 +352,7 @@ export function usePagingState(options: UsePagingOptions = {}): PagingStateResul
setItemsPerPage,
setTotalCount,
refreshTotalCount,
totalCountRefreshToken: refreshCounter,
queryLimit: itemsPerPage ?? undefined,
queryOffset: itemsPerPage === null ? undefined : pageIndex * itemsPerPage,
}
Expand Down
6 changes: 6 additions & 0 deletions packages/bindx-react/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ export {
type UseEntityListResult,
} from './useEntityList.js'

export {
useEntityCount,
type UseEntityCountOptions,
type UseEntityCountResult,
} from './useEntityCount.js'

export {
ContemberBindxProvider,
schemaNamesToDef,
Expand Down
96 changes: 96 additions & 0 deletions packages/bindx-react/src/hooks/useEntityCount.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
/**
* 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<Entity>.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<any>,
options: UseEntityCountOptions = {},
): UseEntityCountResult {
const entityType = entity.$name
const { batcher } = useBindxContext()

const [count, setCount] = useState<number | null>(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<void> => {
try {
const filter = JSON.parse(filterKey) as Record<string, unknown>
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 }
}
4 changes: 4 additions & 0 deletions packages/bindx-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ export type {
ErrorEntityListResult,
ReadyEntityListResult,
UseEntityListResult,
// Count hook types
UseEntityCountOptions,
UseEntityCountResult,
// Persistence hook types
PersistApi,
EntityPersistApi,
Expand Down Expand Up @@ -290,6 +293,7 @@ export {
// Standalone hooks
useEntity,
useEntityList,
useEntityCount,
// Persistence
usePersist,
usePersistEntity,
Expand Down
24 changes: 23 additions & 1 deletion packages/bindx/src/adapter/ContemberAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}
Expand All @@ -74,6 +76,9 @@ export class ContemberAdapter implements BackendAdapter {
if (q.type === 'get') {
const unwrapped = data ? unwrapPaginateFields(data as Record<string, unknown>, 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<string, unknown>[]
return { type: 'list' as const, data: items.map(item => unwrapPaginateFields(item, q.spec)) }
Expand Down Expand Up @@ -114,6 +119,23 @@ export class ContemberAdapter implements BackendAdapter {
)
}

private buildCountQuery(query: CountQuery): ContentQuery<unknown> {
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,
Expand Down
19 changes: 18 additions & 1 deletion packages/bindx/src/adapter/MockAdapter.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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<string, unknown>)
}

this.log('executeCount result', entities.length)
return { type: 'count', count: entities.length }
}

async persist(
entityType: string,
id: string,
Expand Down
2 changes: 2 additions & 0 deletions packages/bindx/src/adapter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ export type {
Query,
GetQuery,
ListQuery,
CountQuery,
QueryResult,
GetQueryResult,
ListQueryResult,
CountQueryResult,
PersistResult,
CreateResult,
DeleteResult,
Expand Down
Loading