diff --git a/docs/DataProviderWriting.md b/docs/DataProviderWriting.md index 0e65b25e8dc..19fd1bb64c2 100644 --- a/docs/DataProviderWriting.md +++ b/docs/DataProviderWriting.md @@ -55,7 +55,7 @@ interface GetListParams { pagination: { page: number, perPage: number }; sort: { field: string, order: 'ASC' | 'DESC' }; filter: any; - meta?: any; + meta?: any; // request metadata signal?: AbortSignal; } interface GetListResult { @@ -66,6 +66,7 @@ interface GetListResult { hasNextPage?: boolean; hasPreviousPage?: boolean; }; + meta?: any; // response metadata } function getList(resource: string, params: GetListParams): Promise ``` @@ -88,7 +89,13 @@ dataProvider.getList('posts', { // { id: 123, title: "hello, world", author_id: 12 }, // { id: 125, title: "howdy partner", author_id: 12 }, // ], -// total: 27 +// total: 27, +// meta: { +// facets: [ +// { name: "published", count: 12 }, +// { name: "draft", count: 15 }, +// ], +// }, // } ``` @@ -167,7 +174,7 @@ interface GetManyReferenceParams { pagination: { page: number, perPage: number }; sort: { field: string, order: 'ASC' | 'DESC' }; filter: any; - meta?: any; + meta?: any; // request metadata signal?: AbortSignal; } interface GetManyReferenceResult { @@ -178,6 +185,7 @@ interface GetManyReferenceResult { hasNextPage?: boolean; hasPreviousPage?: boolean; }; + meta?: any; // response metadata } function getManyReference(resource: string, params: GetManyReferenceParams): Promise ``` diff --git a/docs/List.md b/docs/List.md index e1b2203eb39..8d0bdf4a980 100644 --- a/docs/List.md +++ b/docs/List.md @@ -1112,6 +1112,45 @@ const ProductList = () => ( ) ``` +## Accessing Extra Response Data + +If `dataProvider.getList()` returns additional metadata in the response under the `meta` key, you can access it in the list view using the `meta` property of the `ListContext`. + +This is often used by APIs to return statistics or other metadata about the list of records. + +```tsx +// dataProvider.getLists('posts') returns response like +// { +// data: [ ... ], +// total: 293, +// meta: { +// facets: [ +// { value: 'Novels', count: 245 }, +// { value: 'Essays', count: 23 }, +// { value: 'Short stories', count: 25 }, +// ], +// }, +// } +const Facets = () => { + const { isLoading, error, meta } = useListContext(); + if (isLoading || error) return null; + const facets = meta.facets; + return ( + + {facets.map(facet => ( + + + + ))} + + ); +}; +``` + ## Controlled Mode `` deduces the resource and the list parameters from the URL. This is fine for a page showing a single list of records, but if you need to display more than one list in a page, you probably want to define the list parameters yourself. diff --git a/docs/WithListContext.md b/docs/WithListContext.md index 6243d3a3109..d9158eee50c 100644 --- a/docs/WithListContext.md +++ b/docs/WithListContext.md @@ -101,7 +101,8 @@ As a reminder, the [`ListContext`](./useListContext.md) is an object with the fo { In this example, the `useGetList` hook fetches all the posts, and displays a list of the 10 most recent posts in a ``. The `` component allows the user to navigate through the list. Users can also sort the list by clicking on the column headers. +## Passing Additional Arguments + +If you need to pass additional arguments to the data provider, you can pass them in the `meta` argument. + +For example, if you want to embed related records in the response, and your data provider supports the `embed` meta parameter, you can pass it like this: + +```jsx +const { data, total, isPending, error } = useGetList( + 'posts', + { + pagination: { page: 1, perPage: 10 }, + sort: { field: 'published_at', order: 'DESC' }, + // Pass extra parameters using the meta argument + meta: { embed: ['author', 'category'] } + } +); +``` + +**Tip**: Don't mix the `meta` parameter with the `meta` property of the response (see below). Although they share the same name, they are not related. + +## Accessing Response Metadata + +If your backend returns additional metadata along with the records, you can access it in the `meta` property of the result. + +```jsx +const { + data, + total, + isPending, + error, + // access the extra response details in the meta property + meta +} = useGetList('posts', { pagination: { page: 1, perPage: 10 }}); +``` + +**Tip**: Don't mix the `meta` property of the response with the `meta` parameter (see above). Although they share the same name, they are not related. + ## Partial Pagination If your data provider doesn't return the `total` number of records (see [Partial Pagination](./DataProviderWriting.md#partial-pagination)), you can use the `pageInfo` field to determine if there are more records to fetch. diff --git a/docs/useGetManyReference.md b/docs/useGetManyReference.md index 1d1e824c49a..b76d7246385 100644 --- a/docs/useGetManyReference.md +++ b/docs/useGetManyReference.md @@ -10,7 +10,7 @@ This hook calls `dataProvider.getManyReference()` when the component mounts. It ## Syntax ```jsx -const { data, total, isPending, error, refetch } = useGetManyReference( +const { data, total, isPending, error, refetch, meta } = useGetManyReference( resource, { target, id, pagination, sort, filter, meta }, options diff --git a/docs/useInfiniteGetList.md b/docs/useInfiniteGetList.md index 67e2140cd84..3b1a88f9ea5 100644 --- a/docs/useInfiniteGetList.md +++ b/docs/useInfiniteGetList.md @@ -24,6 +24,7 @@ It is based on react-query's [`useInfiniteQuery`](https://tanstack.com/query/v5/ const { data: { pages, pageParams }, total, + meta, pageInfo, isPending, error, diff --git a/docs/useListContext.md b/docs/useListContext.md index 6b738d12b5c..fa43d743886 100644 --- a/docs/useListContext.md +++ b/docs/useListContext.md @@ -63,6 +63,7 @@ const { // Data data, // Array of the list records, e.g. [{ id: 123, title: 'hello world' }, { ... } total, // Total number of results for the current filters, excluding pagination. Useful to build the pagination controls, e.g. 23 + meta, // Additional information about the list, like facets & statistics isPending, // Boolean, true until the data is available isFetching, // Boolean, true while the data is being fetched, false once the data is fetched isLoading, // Boolean, true until the data is fetched for the first time diff --git a/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx b/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx index 2104545f1d7..64afc5570a4 100644 --- a/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx +++ b/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx @@ -364,4 +364,52 @@ describe('useReferenceManyFieldController', () => { ]); }); }); + + describe('response metadata', () => { + it('should return response metadata as meta', async () => { + const dataProvider = testDataProvider({ + getManyReference: () => + Promise.resolve({ + data: [ + { id: 1, title: 'hello' }, + { id: 2, title: 'world' }, + ], + total: 2, + meta: { + facets: [ + { foo: 'bar', count: 1 }, + { foo: 'baz', count: 2 }, + ], + }, + }) as any, + }); + + const ListMetadataInspector = () => { + const listContext = useReferenceManyFieldController({ + resource: 'authors', + source: 'id', + record: { id: 123, name: 'James Joyce' }, + reference: 'books', + target: 'author_id', + }); + + return ( + <> + Response metadata:{' '} +
{JSON.stringify(listContext.meta, null)}
+ + ); + }; + + render( + + + + ); + + await screen.findByText( + '{"facets":[{"foo":"bar","count":1},{"foo":"baz","count":2}]}' + ); + }); + }); }); diff --git a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts index d64f0b587f4..3f38e3c21d8 100644 --- a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts +++ b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts @@ -159,6 +159,7 @@ export const useReferenceManyFieldController = < const { data, total, + meta: responseMeta, pageInfo, error, isFetching, @@ -202,6 +203,7 @@ export const useReferenceManyFieldController = < return { sort, data, + meta: responseMeta, defaultTitle: undefined, displayedFilters, error, diff --git a/packages/ra-core/src/controller/list/ListBase.stories.tsx b/packages/ra-core/src/controller/list/ListBase.stories.tsx index 6c943401f94..9bc87898720 100644 --- a/packages/ra-core/src/controller/list/ListBase.stories.tsx +++ b/packages/ra-core/src/controller/list/ListBase.stories.tsx @@ -44,7 +44,8 @@ const dataProvider = fakeRestProvider(data, true, 300); const BookListView = () => { const { data, - isLoading, + error, + isPending, sort, filterValues, page, @@ -61,9 +62,12 @@ const BookListView = () => { sort, filterValues, }); - if (isLoading) { + if (isPending) { return
Loading...
; } + if (error) { + return
Error...
; + } const handleClick = () => { const value = JSON.parse(inputRef.current!.value); @@ -114,3 +118,38 @@ export const SetParams = () => ( ); + +const ListMetadataInspector = () => { + const listContext = useListContext(); + return ( + <> + Response metadata:{' '} +
{JSON.stringify(listContext.meta, null, 2)}
+ + ); +}; + +export const WithResponseMetadata = () => ( + { + const result = await dataProvider.getList(resource, params); + return { + ...result, + meta: { + facets: [ + { value: 'bar', count: 2 }, + { value: 'baz', count: 1 }, + ], + }, + }; + }, + }} + > + + + + + +); diff --git a/packages/ra-core/src/controller/list/useListController.ts b/packages/ra-core/src/controller/list/useListController.ts index 2c9a1294bb9..38321e36579 100644 --- a/packages/ra-core/src/controller/list/useListController.ts +++ b/packages/ra-core/src/controller/list/useListController.ts @@ -88,6 +88,7 @@ export const useListController = ( data, pageInfo, total, + meta: responseMeta, error, isLoading, isFetching, @@ -157,6 +158,7 @@ export const useListController = ( return { sort: currentSort, data, + meta: responseMeta, defaultTitle, displayedFilters: query.displayedFilters, error, @@ -486,6 +488,7 @@ export interface ListControllerLoadingResult extends ListControllerBaseResult { data: undefined; total: undefined; + meta: undefined; error: null; isPending: true; } @@ -495,6 +498,7 @@ export interface ListControllerErrorResult< > extends ListControllerBaseResult { data: undefined; total: undefined; + meta: undefined; error: TError; isPending: false; } @@ -504,6 +508,7 @@ export interface ListControllerRefetchErrorResult< > extends ListControllerBaseResult { data: RecordType[]; total: number; + meta?: any; error: TError; isPending: false; } @@ -511,6 +516,7 @@ export interface ListControllerSuccessResult extends ListControllerBaseResult { data: RecordType[]; total: number; + meta?: any; error: null; isPending: false; } diff --git a/packages/ra-core/src/dataProvider/useGetList.stories.tsx b/packages/ra-core/src/dataProvider/useGetList.stories.tsx index 22372b41724..72010783276 100644 --- a/packages/ra-core/src/dataProvider/useGetList.stories.tsx +++ b/packages/ra-core/src/dataProvider/useGetList.stories.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { CoreAdminContext } from '../core'; +import { testDataProvider } from './testDataProvider'; import { useGetList } from './useGetList'; export default { @@ -11,15 +12,32 @@ const UseGetList = () => { const hookValue = useGetList('posts'); return
{JSON.stringify(hookValue, undefined, '  ')}
; }; + export const NoArgs = () => { return ( ({ + data: [{ id: 1, title: 'foo' } as any], + total: 1, + }), + })} + > + + + ); +}; + +export const WithResponseMetadata = () => { + return ( + ({ - data: [{ id: 1, title: 'foo' }], + data: [{ id: 1, title: 'foo' } as any], total: 1, + meta: { facets: { tags: [{ value: 'bar', count: 2 }] } }, }), - }} + })} > diff --git a/packages/ra-core/src/dataProvider/useGetList.ts b/packages/ra-core/src/dataProvider/useGetList.ts index bd2ff2fe6e5..b30bf6e72f7 100644 --- a/packages/ra-core/src/dataProvider/useGetList.ts +++ b/packages/ra-core/src/dataProvider/useGetList.ts @@ -95,10 +95,11 @@ export const useGetList = ( ? queryParams.signal : undefined, }) - .then(({ data, total, pageInfo }) => ({ + .then(({ data, total, pageInfo, meta }) => ({ data, total, pageInfo, + meta, })), ...queryOptions, }); @@ -168,9 +169,7 @@ export const useGetList = ( result.data ? { ...result, - data: result.data?.data, - total: result.data?.total, - pageInfo: result.data?.pageInfo, + ...result.data, } : result, [result] @@ -180,6 +179,7 @@ export const useGetList = ( hasNextPage?: boolean; hasPreviousPage?: boolean; }; + meta?: any; }; }; @@ -204,4 +204,5 @@ export type UseGetListHookValue = hasNextPage?: boolean; hasPreviousPage?: boolean; }; + meta?: any; }; diff --git a/packages/ra-core/src/dataProvider/useGetManyReference.ts b/packages/ra-core/src/dataProvider/useGetManyReference.ts index 4a38f8a7444..b7660fe87ed 100644 --- a/packages/ra-core/src/dataProvider/useGetManyReference.ts +++ b/packages/ra-core/src/dataProvider/useGetManyReference.ts @@ -114,10 +114,11 @@ export const useGetManyReference = ( ? queryParams.signal : undefined, }) - .then(({ data, total, pageInfo }) => ({ + .then(({ data, total, pageInfo, meta }) => ({ data, total, pageInfo, + meta, })); }, ...queryOptions, @@ -151,9 +152,7 @@ export const useGetManyReference = ( result.data ? { ...result, - data: result.data?.data, - total: result.data?.total, - pageInfo: result.data?.pageInfo, + ...result.data, } : result, [result] @@ -163,6 +162,7 @@ export const useGetManyReference = ( hasNextPage?: boolean; hasPreviousPage?: boolean; }; + meta?: any; }; }; @@ -186,6 +186,7 @@ export type UseGetManyReferenceHookValue = hasNextPage?: boolean; hasPreviousPage?: boolean; }; + meta?: any; }; const noop = () => undefined; diff --git a/packages/ra-core/src/dataProvider/useInfiniteGetList.ts b/packages/ra-core/src/dataProvider/useInfiniteGetList.ts index 30e73af5c10..b8a74485db6 100644 --- a/packages/ra-core/src/dataProvider/useInfiniteGetList.ts +++ b/packages/ra-core/src/dataProvider/useInfiniteGetList.ts @@ -117,11 +117,12 @@ export const useInfiniteGetList = ( ? queryParams.signal : undefined, }) - .then(({ data, pageInfo, total }) => ({ + .then(({ data, pageInfo, total, meta }) => ({ data, total, pageParam, pageInfo, + meta, })); }, initialPageParam: pagination.page, @@ -222,6 +223,7 @@ export const useInfiniteGetList = ( ...result, data: result.data, total: result.data?.pages[0]?.total ?? undefined, + meta: result.data?.pages[0]?.meta, } : result ) as UseInfiniteQueryResult< @@ -229,6 +231,7 @@ export const useInfiniteGetList = ( Error > & { total?: number; + meta?: any; }; }; diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index 1b43118b589..0a098b34838 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -157,6 +157,7 @@ export interface GetListResult { hasNextPage?: boolean; hasPreviousPage?: boolean; }; + meta?: any; } export interface GetInfiniteListResult @@ -197,6 +198,7 @@ export interface GetManyReferenceResult { hasNextPage?: boolean; hasPreviousPage?: boolean; }; + meta?: any; } export interface UpdateParams { diff --git a/packages/ra-ui-materialui/src/list/List.stories.tsx b/packages/ra-ui-materialui/src/list/List.stories.tsx index 0f8fd5e259e..2e2255366f9 100644 --- a/packages/ra-ui-materialui/src/list/List.stories.tsx +++ b/packages/ra-ui-materialui/src/list/List.stories.tsx @@ -7,7 +7,15 @@ import { TestMemoryRouter, } from 'ra-core'; import fakeRestDataProvider from 'ra-data-fakerest'; -import { Box, Card, Stack, Typography, Button } from '@mui/material'; +import { + Box, + Card, + Stack, + Typography, + Button, + Badge, + Chip, +} from '@mui/material'; import { List } from './List'; import { ListActions } from './ListActions'; @@ -554,3 +562,54 @@ export const ErrorInFetch = () => ( ); + +const Facets = () => { + const { isLoading, error, meta } = useListContext(); + if (isLoading || error) return null; + const facets = meta.facets; + return ( + + {facets.map(facet => ( + + + + ))} + + ); +}; +export const ResponseMetadata = () => ( + + { + const result = await dataProvider.getList(resource, params); + return { + ...result, + meta: { + facets: [ + { value: 'Novels', count: 13 }, + { value: 'Essays', count: 0 }, + { value: 'Short stories', count: 0 }, + ], + }, + }; + }, + }} + > + + + +
+ } + /> + + +);