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
39 changes: 14 additions & 25 deletions packages/bindx-dataview/src/DataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -94,9 +95,13 @@ function DataGridImpl<TRoleMap extends Record<string, object>>({
storageKey: effectiveStorageKey,
})

// ---- Loader state tracking ----
const hasLoadedOnce = useRef(false)
const [loaderState, setLoaderState] = useState<DataViewLoaderState>('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, {
Expand All @@ -105,44 +110,28 @@ function DataGridImpl<TRoleMap extends Record<string, object>>({
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, {
filter: setup.combinedFilter,
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<number | null>(null)

Expand Down
6 changes: 6 additions & 0 deletions packages/bindx-dataview/src/DataViewContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>
Expand Down
76 changes: 41 additions & 35 deletions packages/bindx-dataview/src/HasManyDataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,16 @@ 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'
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'
Expand Down Expand Up @@ -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: [] }
Expand Down Expand Up @@ -138,8 +143,6 @@ function HasManyDataGridImpl<TEntity extends object>({
})

// ---- State ----
const hasLoadedOnce = useRef(false)
const [loaderState, setLoaderState] = useState<DataViewLoaderState>('initial')
const [listState, setListState] = useState<ListState>(INITIAL_LIST_STATE)

const optionsKey = useMemo(
Expand All @@ -152,10 +155,24 @@ function HasManyDataGridImpl<TEntity extends object>({
[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<void> => {
try {
Expand All @@ -174,36 +191,37 @@ function HasManyDataGridImpl<TEntity extends object>({
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 })

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 })
}
}

Expand All @@ -212,12 +230,15 @@ function HasManyDataGridImpl<TEntity extends object>({
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<TEntity>[] => {
if (listState.status !== 'ready') return []
return listState.items.map((item) =>
if (shownState.status !== 'ready') return []
return shownState.items.map((item) =>
EntityHandle.create<TEntity>(
item.id,
targetEntityType,
Expand All @@ -226,39 +247,24 @@ function HasManyDataGridImpl<TEntity extends object>({
schemaRegistry as SchemaRegistry<Record<string, object>>,
) as unknown as EntityAccessor<TEntity>,
)
}, [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<number | null>(null)

Expand Down
Loading
Loading