diff --git a/packages/bindx-dataview/src/DataGrid.tsx b/packages/bindx-dataview/src/DataGrid.tsx index 7989ba84..4ec88392 100644 --- a/packages/bindx-dataview/src/DataGrid.tsx +++ b/packages/bindx-dataview/src/DataGrid.tsx @@ -23,6 +23,7 @@ import { 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' export { QUERY_FILTER_NAME } @@ -152,6 +153,14 @@ function DataGridImpl>({ setHighlightIndex(null) }, [items]) + // ---- Unpaged fetch of the same list the grid loads ---- + const fetchAllData = useListFetchAllData({ + entityType, + filter: setup.combinedFilter, + orderBy: setup.sorting.resolvedOrderBy, + selection: setup.selection, + }) + const contextValue = useMemo((): DataViewContextValue => ({ filtering: setup.filtering, sorting: setup.sorting, @@ -166,10 +175,11 @@ function DataGridImpl>({ highlightIndex, setHighlightIndex, selectionMeta: setup.selection, + fetchAllData, toolbarContent: setup.toolbarContent, layoutRenders: setup.layoutRenders, layoutElements: setup.layoutElements, - }), [setup.filtering, setup.sorting, setup.paging, setup.selectionState, setup.columns, entityType, items, itemCount, loaderState, reload, highlightIndex, setup.selection, setup.toolbarContent, setup.layoutRenders, setup.layoutElements]) + }), [setup.filtering, setup.sorting, setup.paging, setup.selectionState, setup.columns, entityType, items, itemCount, loaderState, reload, highlightIndex, setup.selection, fetchAllData, setup.toolbarContent, setup.layoutRenders, setup.layoutElements]) return ( diff --git a/packages/bindx-dataview/src/DataViewContext.tsx b/packages/bindx-dataview/src/DataViewContext.tsx index 4fc5e147..71906483 100644 --- a/packages/bindx-dataview/src/DataViewContext.tsx +++ b/packages/bindx-dataview/src/DataViewContext.tsx @@ -18,6 +18,9 @@ export interface DataViewElementData { readonly fallback?: boolean } +/** Fetches every row in the view's scope, unpaged. Resolves to null when the query returned an unexpected result. */ +export type DataViewFetchAllData = () => Promise[] | null> + export interface DataViewContextValue { readonly filtering: FilteringState readonly sorting: SortingStateResult @@ -32,6 +35,8 @@ export interface DataViewContextValue { readonly highlightIndex: number | null readonly setHighlightIndex: (index: number | null) => void readonly selectionMeta: SelectionMeta + /** Owned by each view, so the query keeps the scope the view loads with (static filter, parent relation). */ + readonly fetchAllData: DataViewFetchAllData readonly toolbarContent?: React.ReactNode /** Named layout render callbacks — analyzed during collection, called per item at runtime */ readonly layoutRenders: ReadonlyMap React.ReactNode> diff --git a/packages/bindx-dataview/src/HasManyDataGrid.tsx b/packages/bindx-dataview/src/HasManyDataGrid.tsx index a245eb09..2c6188e7 100644 --- a/packages/bindx-dataview/src/HasManyDataGrid.tsx +++ b/packages/bindx-dataview/src/HasManyDataGrid.tsx @@ -30,7 +30,6 @@ import type { import { FIELD_REF_META } from '@contember/bindx' import { buildQueryFromSelection, - generateHasManyAlias, EntityHandle, setEntityData, } from '@contember/bindx' @@ -40,8 +39,9 @@ import { useBindxContext, } from '@contember/bindx-react' import { useDataViewKey } from './DataViewKeyProvider.js' -import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from './DataViewContext.js' +import { DataViewProvider, type DataViewContextValue, type DataViewFetchAllData, type DataViewLoaderState } from './DataViewContext.js' import { useDataGridSetup } from './useDataGridSetup.js' +import { buildHasManyRelationQuery, extractHasManyRelationRows } from './hasManyRelationQuery.js' // ============================================================================ // Props @@ -133,17 +133,6 @@ function HasManyDataGridImpl({ const [loaderState, setLoaderState] = useState('initial') const [listState, setListState] = useState(INITIAL_LIST_STATE) - // ---- Build relation query spec ---- - const alias = useMemo( - () => generateHasManyAlias(fieldName, { - filter: setup.combinedFilter, - orderBy: setup.sorting.resolvedOrderBy as unknown[], - limit: setup.paging.queryLimit, - offset: setup.paging.queryOffset, - }), - [fieldName, setup.combinedFilter, setup.sorting.resolvedOrderBy, setup.paging.queryLimit, setup.paging.queryOffset], - ) - const optionsKey = useMemo( () => JSON.stringify({ filter: setup.combinedFilter ?? {}, @@ -161,7 +150,6 @@ function HasManyDataGridImpl({ const fetchData = async (): Promise => { try { - const targetSpec = buildQueryFromSelection(setup.selection) const currentOptions = JSON.parse(optionsKey) as { filter: Record orderBy: readonly Record[] @@ -169,32 +157,18 @@ function HasManyDataGridImpl({ offset?: number } - const parentSpec = { - fields: [ - { name: 'id', sourcePath: ['id'] }, - { - name: alias, - sourcePath: [fieldName], - isArray: true as const, - totalCount: true, - filter: Object.keys(currentOptions.filter).length > 0 ? currentOptions.filter : undefined, - orderBy: currentOptions.orderBy.length > 0 ? currentOptions.orderBy : undefined, - limit: currentOptions.limit, - offset: currentOptions.offset, - nested: targetSpec, - }, - ], - } + const { alias, query } = buildHasManyRelationQuery({ + parentEntityType, + parentEntityId, + fieldName, + filter: currentOptions.filter, + orderBy: currentOptions.orderBy, + limit: currentOptions.limit, + offset: currentOptions.offset, + targetSpec: buildQueryFromSelection(setup.selection), + }) - const result = await batcher.enqueue( - { - type: 'get', - entityType: parentEntityType, - by: { id: parentEntityId }, - spec: parentSpec, - }, - { signal: abortController.signal }, - ) + const result = await batcher.enqueue(query, { signal: abortController.signal }) if (abortController.signal.aborted) return @@ -203,17 +177,13 @@ function HasManyDataGridImpl({ return } - const relationData = (result.data[alias] ?? result.data[fieldName]) as Array> | undefined - const totalCount = Array.isArray(relationData) && 'totalCount' in relationData - ? (relationData as Array> & { totalCount: number }).totalCount - : undefined - - if (!Array.isArray(relationData)) { - setListState({ status: 'ready', items: [], totalCount }) + const relation = extractHasManyRelationRows(result.data, { alias, fieldName }) + if (!relation) { + setListState({ status: 'ready', items: [] }) return } - const items = relationData.map((data: Record) => { + const items = relation.rows.map((data: Record) => { const id = data['id'] as string dispatcher.dispatch( setEntityData(targetEntityType, id, data, true), @@ -221,7 +191,7 @@ function HasManyDataGridImpl({ return { id, data: data as object } }) - setListState({ status: 'ready', items, totalCount }) + setListState({ status: 'ready', items, totalCount: relation.totalCount }) } catch (error) { if (abortController.signal.aborted) return setListState({ status: 'error', items: [] }) @@ -233,7 +203,7 @@ function HasManyDataGridImpl({ return () => { abortController.abort() } - }, [parentEntityType, parentEntityId, fieldName, targetEntityType, alias, optionsKey, setup.selection, batcher, dispatcher, store]) + }, [parentEntityType, parentEntityId, fieldName, targetEntityType, optionsKey, setup.selection, batcher, dispatcher, store]) // ---- Build items from state ---- const items = useMemo((): EntityAccessor[] => { @@ -285,6 +255,23 @@ function HasManyDataGridImpl({ setHighlightIndex(null) }, [items]) + // ---- Unpaged read of the same parent-scoped relation ---- + const fetchAllData = useCallback(async () => { + const { alias, query } = buildHasManyRelationQuery({ + parentEntityType, + parentEntityId, + fieldName, + filter: setup.combinedFilter, + orderBy: setup.sorting.resolvedOrderBy, + targetSpec: buildQueryFromSelection(setup.selection), + }) + + const result = await batcher.enqueue(query) + if (result.type !== 'get' || !result.data) return null + + return extractHasManyRelationRows(result.data, { alias, fieldName })?.rows ?? null + }, [parentEntityType, parentEntityId, fieldName, setup.combinedFilter, setup.sorting.resolvedOrderBy, setup.selection, batcher]) + const contextValue = useMemo((): DataViewContextValue => ({ filtering: setup.filtering, sorting: setup.sorting, @@ -299,10 +286,11 @@ function HasManyDataGridImpl({ highlightIndex, setHighlightIndex, selectionMeta: setup.selection, + fetchAllData, toolbarContent: setup.toolbarContent, layoutRenders: setup.layoutRenders, layoutElements: setup.layoutElements, - }), [setup.filtering, setup.sorting, setup.paging, setup.selectionState, setup.columns, targetEntityType, items, itemCount, loaderState, reload, highlightIndex, setup.selection, setup.toolbarContent, setup.layoutRenders, setup.layoutElements]) + }), [setup.filtering, setup.sorting, setup.paging, setup.selectionState, setup.columns, targetEntityType, items, itemCount, loaderState, reload, highlightIndex, setup.selection, fetchAllData, setup.toolbarContent, setup.layoutRenders, setup.layoutElements]) return ( diff --git a/packages/bindx-dataview/src/export.tsx b/packages/bindx-dataview/src/export.tsx index 765acb3d..fa61768f 100644 --- a/packages/bindx-dataview/src/export.tsx +++ b/packages/bindx-dataview/src/export.tsx @@ -15,9 +15,6 @@ import React, { forwardRef, type ReactElement, useCallback, useState } from 'rea import { Slot } from '@radix-ui/react-slot' import { composeEventHandlers } from '@radix-ui/primitive' import { useDataViewContext } from './DataViewContext.js' -import { useBindxContext } from '@contember/bindx-react' -import { buildQueryFromSelection } from '@contember/bindx' -import type { ListQuery, ListQueryResult } from '@contember/bindx' // ============================================================================ // Export Factory Interface @@ -117,12 +114,11 @@ export interface DataViewExportTriggerProps { export const DataViewExportTrigger = forwardRef( ({ baseName, exportFactory = defaultExportFactory, onlyVisible = false, ...props }, ref) => { - const { columns, entityType, filtering, selection, selectionMeta } = useDataViewContext() - const { adapter } = useBindxContext() + const { columns, entityType, fetchAllData, selection } = useDataViewContext() const [isExporting, setIsExporting] = useState(false) const handleExport = useCallback(async (): Promise => { - if (!adapter || isExporting) return + if (isExporting) return setIsExporting(true) try { @@ -130,19 +126,8 @@ export const DataViewExportTrigger = forwardRef selection.isVisible(c.fieldName ?? `col-${i}`)) : columns - const listQuery: ListQuery = { - type: 'list', - entityType, - filter: filtering.resolvedWhere, - orderBy: undefined, - limit: undefined, - offset: undefined, - spec: buildQueryFromSelection(selectionMeta), - } - - const results = await adapter.query([listQuery]) - const result = results[0] - if (!result || result.type !== 'list') { + const data = await fetchAllData() + if (!data) { console.error('Export failed: unexpected result') return } @@ -152,7 +137,7 @@ export const DataViewExportTrigger = forwardRef ({ name: String(c.header ?? c.fieldName ?? ''), fieldName: c.fieldName })) const { blob, extension } = exportFactory.create({ - data: (result as ListQueryResult).data, + data, columns: exportColumns, }) @@ -169,7 +154,7 @@ export const DataViewExportTrigger = forwardRef @@ -195,25 +180,10 @@ export interface FetchAllDataResult { } export function useDataViewFetchAllData(): () => Promise { - const { entityType, filtering, selectionMeta } = useDataViewContext() - const { adapter } = useBindxContext() + const { fetchAllData } = useDataViewContext() return useCallback(async (): Promise => { - if (!adapter) return null - - const listQuery: ListQuery = { - type: 'list', - entityType, - filter: filtering.resolvedWhere, - orderBy: undefined, - limit: undefined, - offset: undefined, - spec: buildQueryFromSelection(selectionMeta), - } - - const results = await adapter.query([listQuery]) - const result = results[0] - if (!result || result.type !== 'list') return null - return { data: (result as ListQueryResult).data } - }, [adapter, entityType, filtering.resolvedWhere, selectionMeta]) + const data = await fetchAllData() + return data ? { data } : null + }, [fetchAllData]) } diff --git a/packages/bindx-dataview/src/hasManyRelationQuery.ts b/packages/bindx-dataview/src/hasManyRelationQuery.ts new file mode 100644 index 00000000..c2dd4cf8 --- /dev/null +++ b/packages/bindx-dataview/src/hasManyRelationQuery.ts @@ -0,0 +1,92 @@ +/** + * Parent-scoped relation query used by HasManyDataGrid. + * + * The grid reads its rows through the parent record, so every read of the + * relation — paged load and unpaged export alike — has to go through it. + */ + +import { generateHasManyAlias } from '@contember/bindx' +import type { GetQuery, QuerySpec } from '@contember/bindx' + +export interface HasManyRelationQueryOptions { + readonly parentEntityType: string + readonly parentEntityId: string + readonly fieldName: string + readonly filter: Record | undefined + readonly orderBy: readonly Record[] | undefined + readonly limit?: number + readonly offset?: number + readonly targetSpec: QuerySpec +} + +export interface HasManyRelationQuery { + /** Alias the relation rows are returned under */ + readonly alias: string + readonly query: GetQuery +} + +export interface HasManyRelationRows { + readonly rows: readonly Record[] + readonly totalCount: number | undefined +} + +export function buildHasManyRelationQuery({ + parentEntityType, + parentEntityId, + fieldName, + filter, + orderBy, + limit, + offset, + targetSpec, +}: HasManyRelationQueryOptions): HasManyRelationQuery { + const relationFilter = filter && Object.keys(filter).length > 0 ? filter : undefined + const relationOrderBy = orderBy && orderBy.length > 0 ? orderBy : undefined + const alias = generateHasManyAlias(fieldName, { + filter: relationFilter, + orderBy: relationOrderBy, + limit, + offset, + }) + + const spec: QuerySpec = { + fields: [ + { name: 'id', sourcePath: ['id'] }, + { + name: alias, + sourcePath: [fieldName], + isArray: true, + totalCount: true, + filter: relationFilter, + orderBy: relationOrderBy, + limit, + offset, + nested: targetSpec, + }, + ], + } + + return { + alias, + query: { + type: 'get', + entityType: parentEntityType, + by: { id: parentEntityId }, + spec, + }, + } +} + +export function extractHasManyRelationRows( + data: Record, + { alias, fieldName }: { alias: string; fieldName: string }, +): HasManyRelationRows | null { + const value = data[alias] ?? data[fieldName] + if (!isRecordArray(value)) return null + const totalCount = 'totalCount' in value && typeof value.totalCount === 'number' ? value.totalCount : undefined + return { rows: value, totalCount } +} + +function isRecordArray(value: unknown): value is readonly Record[] { + return Array.isArray(value) && value.every(item => typeof item === 'object' && item !== null) +} diff --git a/packages/bindx-dataview/src/index.ts b/packages/bindx-dataview/src/index.ts index ea3faad7..68f9b730 100644 --- a/packages/bindx-dataview/src/index.ts +++ b/packages/bindx-dataview/src/index.ts @@ -127,6 +127,7 @@ export { useOptionalDataViewContext, DataViewProvider, type DataViewContextValue, + type DataViewFetchAllData, type DataViewItem, type DataViewLoaderState as DataViewLoaderStateType, type DataViewElementData, diff --git a/packages/bindx-dataview/src/select/SelectDataView.tsx b/packages/bindx-dataview/src/select/SelectDataView.tsx index 2f10cdaf..d81c4019 100644 --- a/packages/bindx-dataview/src/select/SelectDataView.tsx +++ b/packages/bindx-dataview/src/select/SelectDataView.tsx @@ -45,6 +45,7 @@ import { 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' export interface SelectDataViewProps { /** Children rendered inside the DataView context */ @@ -212,6 +213,13 @@ function SelectDataViewImpl({ const emptyMap = useMemo(() => new Map(), []) + const fetchAllData = useListFetchAllData({ + entityType, + filter: combinedFilter, + orderBy: sorting.resolvedOrderBy, + selection, + }) + const contextValue = useMemo((): DataViewContextValue => ({ filtering, sorting, @@ -226,10 +234,11 @@ function SelectDataViewImpl({ highlightIndex, setHighlightIndex, selectionMeta: selection, + fetchAllData, toolbarContent: undefined, layoutRenders: emptyMap, layoutElements: emptyMap, - }), [filtering, sorting, paging, selectionState, entityType, items, itemCount, loaderState, reload, highlightIndex, selection, emptyMap]) + }), [filtering, sorting, paging, selectionState, entityType, items, itemCount, loaderState, reload, highlightIndex, selection, fetchAllData, emptyMap]) return ( diff --git a/packages/bindx-dataview/src/useListFetchAllData.ts b/packages/bindx-dataview/src/useListFetchAllData.ts new file mode 100644 index 00000000..0c2938a6 --- /dev/null +++ b/packages/bindx-dataview/src/useListFetchAllData.ts @@ -0,0 +1,38 @@ +/** + * Unpaged fetch-all for views that load through a root list query. + */ + +import { useCallback } from 'react' +import { buildQueryFromSelection } from '@contember/bindx' +import type { ListQuery, SelectionMeta } from '@contember/bindx' +import { useBindxContext } from '@contember/bindx-react' +import type { DataViewFetchAllData } from './DataViewContext.js' + +export interface ListFetchAllDataOptions { + /** Entity the view lists */ + readonly entityType: string + /** Full scope of the view — static filter combined with the user filters */ + readonly filter: Record | undefined + readonly orderBy: readonly Record[] | undefined + readonly selection: SelectionMeta +} + +export function useListFetchAllData({ entityType, filter, orderBy, selection }: ListFetchAllDataOptions): DataViewFetchAllData { + const { adapter } = useBindxContext() + + return useCallback(async (): Promise[] | null> => { + const listQuery: ListQuery = { + type: 'list', + entityType, + filter, + orderBy, + limit: undefined, + offset: undefined, + spec: buildQueryFromSelection(selection), + } + + const results = await adapter.query([listQuery]) + const result = results[0] + return result?.type === 'list' ? result.data : null + }, [adapter, entityType, filter, orderBy, selection]) +} diff --git a/packages/bindx-ui/src/datagrid/grid.tsx b/packages/bindx-ui/src/datagrid/grid.tsx index efe26eb7..b1525c39 100644 --- a/packages/bindx-ui/src/datagrid/grid.tsx +++ b/packages/bindx-ui/src/datagrid/grid.tsx @@ -6,6 +6,8 @@ export { DefaultDataGrid, type DefaultDataGridProps, DefaultHasManyDataGrid, type DefaultHasManyDataGridProps } from '#bindx-ui/datagrid/datagrid' export { DefaultDataGridLayout, type DefaultDataGridLayoutProps } from '#bindx-ui/datagrid/layout' export { DataGridToolbarUI, type DataGridToolbarUIProps } from '#bindx-ui/datagrid/toolbar' +// Custom toolbars need this to keep the mobile filters toggle working +export { DataGridShowFiltersContext } from '#bindx-ui/datagrid/filters/mobile' export { DataGridPaginationUI, type DataGridPaginationUIProps, DataGridPerPageSelector } from '#bindx-ui/datagrid/pagination' export { DataGridColumnHeaderUI, type DataGridColumnHeaderUIProps } from '#bindx-ui/datagrid/column-header' export { diff --git a/packages/bindx-ui/src/datagrid/toolbar.tsx b/packages/bindx-ui/src/datagrid/toolbar.tsx index bcea3760..72cf9190 100644 --- a/packages/bindx-ui/src/datagrid/toolbar.tsx +++ b/packages/bindx-ui/src/datagrid/toolbar.tsx @@ -27,9 +27,11 @@ export interface DataGridToolbarUIProps { children?: ReactNode className?: string sticky?: boolean + /** Export control. Default: ``. Pass null to hide it. */ + exportControl?: ReactNode | null } -export function DataGridToolbarUI({ children, className, sticky }: DataGridToolbarUIProps): ReactElement { +export function DataGridToolbarUI({ children, className, sticky, exportControl }: DataGridToolbarUIProps): ReactElement { const [showFilters, setShowFilters] = useState(false) return ( @@ -61,7 +63,7 @@ export function DataGridToolbarUI({ children, className, sticky }: DataGridToolb - + {exportControl === undefined ? : exportControl} + + + + )} + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-loading')).toBeNull() + }) + + // Sanity: the grid's own data load IS scoped by the static filter. + const dataQuery = adapter.listQueries.find(q => q.limit !== undefined) + expect(JSON.stringify(dataQuery?.filter ?? {})).toContain('published') + + const before = adapter.listQueries.length + await act(async () => { + fireEvent.click(getByTestId('export-trigger')) + }) + await waitFor(() => { + expect(adapter.listQueries.length).toBeGreaterThan(before) + }) + + const exportQuery = adapter.listQueries[adapter.listQueries.length - 1]! + // The export must not reach rows the grid never shows — its query has + // to carry the same static scope as the data load above. + expect(JSON.stringify(exportQuery.filter ?? {})).toContain('published') + } finally { + URL.createObjectURL = originalCreateObjectURL + URL.revokeObjectURL = originalRevokeObjectURL + } + }) +}) + +describe('DataViewExportTrigger in a HasManyDataGrid', () => { + test('should export through the parent record instead of the whole target table', async () => { + const adapter = new QuerySpyAdapter(mockData, { delay: 0 }) + const exportFactory = new CapturingExportFactory() + + const originalCreateObjectURL = URL.createObjectURL + const originalRevokeObjectURL = URL.revokeObjectURL + URL.createObjectURL = () => 'blob:test' + URL.revokeObjectURL = () => {} + + try { + const { container, getByTestId } = render( + + + {author => ( + + {it => ( + <> + + + + + + + )} + + )} + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'datagrid-loading')).toBeNull() + }) + + await act(async () => { + fireEvent.click(getByTestId('export-trigger')) + }) + await waitFor(() => { + expect(exportFactory.exports.length).toBe(1) + }) + + // A root list query would reach every article in the table, including + // the ones belonging to another author. + expect(adapter.listQueries).toHaveLength(0) + + const exportQuery = adapter.getQueries[adapter.getQueries.length - 1]! + expect(exportQuery.entityType).toBe('Author') + expect(exportQuery.by).toEqual({ id: 'author-1' }) + + const relationField = exportQuery.spec.fields.find(f => f.sourcePath[0] === 'articles')! + expect(relationField.limit).toBeUndefined() + expect(relationField.offset).toBeUndefined() + expect(JSON.stringify(relationField.filter ?? {})).toContain('published') + + const exportedTitles = exportFactory.exports[0]!.data.map(row => row['title']) + expect(exportedTitles).toEqual(['Published article']) + } finally { + URL.createObjectURL = originalCreateObjectURL + URL.revokeObjectURL = originalRevokeObjectURL + } + }) +})