From f6fbf923459eb62856a204dfadbb7f239edbad57 Mon Sep 17 00:00:00 2001 From: Will-thom <116388885+Will-thom@users.noreply.github.com> Date: Tue, 19 May 2026 21:28:02 -0300 Subject: [PATCH] feat: Allow customizing ReferenceManyCount rendering --- docs/ReferenceManyCount.md | 13 ++++++ .../content/docs/ReferenceManyCountBase.md | 19 ++++++++ .../field/ReferenceManyCountBase.spec.tsx | 12 +++++ .../field/ReferenceManyCountBase.stories.tsx | 40 ++++++++++++++++- .../field/ReferenceManyCountBase.tsx | 45 ++++++++++++++----- .../src/field/ReferenceManyCount.spec.tsx | 15 +++++++ .../src/field/ReferenceManyCount.stories.tsx | 33 ++++++++++++++ .../src/field/ReferenceManyCount.tsx | 7 ++- 8 files changed, 171 insertions(+), 13 deletions(-) diff --git a/docs/ReferenceManyCount.md b/docs/ReferenceManyCount.md index a136a0e86a0..0d59df4f47d 100644 --- a/docs/ReferenceManyCount.md +++ b/docs/ReferenceManyCount.md @@ -66,6 +66,7 @@ export const PostList = () => ( | ----------- | -------- | ------------------------------------------ | --------------------------------- | ------------------------------------------------------------------------- | | `reference` | Required | string | - | Name of the related resource to fetch (e.g. `comments`) | | `target` | Required | string | - | Name of the field in the related resource that points to the current one. | +| `children` | Optional | `ReactNode` | - | The component used to render the count. | | `filter` | Optional | Object | - | Filter to apply to the query. | | `link` | Optional | bool | `false` | If true, the count is wrapped in a `` to the filtered list view. | | `offline` | Optional | `ReactNode` | | The component to render when there is no connectivity and the record isn't in the cache @@ -77,6 +78,18 @@ export const PostList = () => ( Additional props are passed to [the underlying Material UI `` element](https://mui.com/material-ui/api/typography/). +## `children` + +By default, `` renders the total as a raw number. You can pass a child component to customize the rendering. `` creates a `RecordContext` with a `total` field, so any Field component can read it: + +```jsx +import { NumberField, ReferenceManyCount } from 'react-admin'; + + + + +``` + ## `filter` If you want to count the number of records matching a given filter, pass it as the `filter` prop. For example, to count the number of comments already published: diff --git a/docs_headless/src/content/docs/ReferenceManyCountBase.md b/docs_headless/src/content/docs/ReferenceManyCountBase.md index 2ff527f59a2..2344863123a 100644 --- a/docs_headless/src/content/docs/ReferenceManyCountBase.md +++ b/docs_headless/src/content/docs/ReferenceManyCountBase.md @@ -60,12 +60,31 @@ export const PostList = () => ( | ----------- | -------- | ------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------- | | `reference` | Required | string | - | Name of the related resource to fetch (e.g. `comments`) | | `target` | Required | string | - | Name of the field in the related resource that points to the current one. | +| `children` | Optional | `ReactNode` | - | The component used to render the count. | | `filter` | Optional | Object | - | Filter to apply to the query. | | `resource` | Optional | string | - | Resource to count. Default to the current `ResourceContext` | | `sort` | Optional | `{ field: string, order: 'ASC' or 'DESC' }` | `{ field: 'id', order: 'DESC' }` | The sort option sent to `getManyReference` | | `timeout` | Optional | number | 1000 | Number of milliseconds to wait before displaying the loading indicator. | +## `children` + +By default, `` renders the total as a raw number. You can pass a child component to customize the rendering. `` creates a `RecordContext` with a `total` field, so descendants can read it with `useRecordContext`: + +```jsx +import { ReferenceManyCountBase, useRecordContext } from 'ra-core'; + +const Count = () => { + const record = useRecordContext(); + + return {record?.total} comments; +}; + + + + +``` + ## `filter` If you want to count the number of records matching a given filter, pass it as the `filter` prop. For example, to count the number of comments already published: diff --git a/packages/ra-core/src/controller/field/ReferenceManyCountBase.spec.tsx b/packages/ra-core/src/controller/field/ReferenceManyCountBase.spec.tsx index 1f15ae14657..fa05a206c2a 100644 --- a/packages/ra-core/src/controller/field/ReferenceManyCountBase.spec.tsx +++ b/packages/ra-core/src/controller/field/ReferenceManyCountBase.spec.tsx @@ -5,6 +5,8 @@ import { ErrorState, LoadingState, Offline, + WithChildren, + WithRenderFunction, } from './ReferenceManyCountBase.stories'; import { onlineManager } from '@tanstack/react-query'; @@ -31,6 +33,16 @@ describe('ReferenceManyCountBase', () => { await screen.findByText('3'); }); + it('should render children in a record context containing the total', async () => { + render(); + await screen.findByText('3 comments'); + }); + + it('should accept a render function as children', async () => { + render(); + await screen.findByText('3 comments'); + }); + it('should render the offline prop node when offline', async () => { render(); fireEvent.click(await screen.findByText('Simulate offline')); diff --git a/packages/ra-core/src/controller/field/ReferenceManyCountBase.stories.tsx b/packages/ra-core/src/controller/field/ReferenceManyCountBase.stories.tsx index c92bc03ec00..786d3b442dc 100644 --- a/packages/ra-core/src/controller/field/ReferenceManyCountBase.stories.tsx +++ b/packages/ra-core/src/controller/field/ReferenceManyCountBase.stories.tsx @@ -4,7 +4,7 @@ import { QueryClient, onlineManager, } from '@tanstack/react-query'; -import { RecordContextProvider } from '../record'; +import { RecordContextProvider, useRecordContext } from '../record'; import { DataProviderContext } from '../../dataProvider'; import { ResourceContextProvider, useIsOffline } from '../../core'; import { TestMemoryRouter } from '../../routing'; @@ -65,6 +65,38 @@ export const Basic = () => ( ); +export const WithChildren = () => ( + + Promise.resolve({ + data: [comments.filter(c => c.post_id === 1)[0]], + total: comments.filter(c => c.post_id === 1).length, + }), + }} + > + + + + +); + +export const WithRenderFunction = () => ( + + Promise.resolve({ + data: [comments.filter(c => c.post_id === 1)[0]], + total: comments.filter(c => c.post_id === 1).length, + }), + }} + > + + {({ total }) => {total} comments} + + +); + export const LoadingState = () => ( new Promise(() => {}) }}> { ); }; + +const Count = () => { + const record = useRecordContext(); + + return {record?.total} comments; +}; diff --git a/packages/ra-core/src/controller/field/ReferenceManyCountBase.tsx b/packages/ra-core/src/controller/field/ReferenceManyCountBase.tsx index 6591678f4be..b2ae97e381a 100644 --- a/packages/ra-core/src/controller/field/ReferenceManyCountBase.tsx +++ b/packages/ra-core/src/controller/field/ReferenceManyCountBase.tsx @@ -4,6 +4,7 @@ import { type UseReferenceManyFieldControllerParams, } from './useReferenceManyFieldController'; import { useTimeout } from '../../util/hooks'; +import { RecordContextProvider } from '../record'; /** * Fetch and render the number of records related to the current one @@ -17,7 +18,14 @@ import { useTimeout } from '../../util/hooks'; * */ export const ReferenceManyCountBase = (props: ReferenceManyCountBaseProps) => { - const { loading, error, offline, timeout = 1000, ...rest } = props; + const { + children, + loading, + error, + offline, + timeout = 1000, + ...rest + } = props; const oneSecondHasPassed = useTimeout(timeout); const { @@ -37,24 +45,41 @@ export const ReferenceManyCountBase = (props: ReferenceManyCountBaseProps) => { isPending && isPaused && offline !== undefined && offline !== false; const shouldRenderError = !isPending && fetchError && error !== undefined && error !== false; + const totalRecord = React.useMemo(() => ({ id: 'count', total }), [total]); + const renderChildren = + typeof children === 'function' ? children(totalRecord) : children; return ( <> - {shouldRenderLoading - ? oneSecondHasPassed - ? loading - : null - : shouldRenderOffline - ? offline - : shouldRenderError - ? error - : total} + {shouldRenderLoading ? ( + oneSecondHasPassed ? ( + loading + ) : null + ) : shouldRenderOffline ? ( + offline + ) : shouldRenderError ? ( + error + ) : children != null ? ( + + {renderChildren} + + ) : ( + total + )} ); }; +export interface ReferenceManyCountRecord { + id: string; + total: number | undefined; +} + export interface ReferenceManyCountBaseProps extends UseReferenceManyFieldControllerParams { + children?: + | React.ReactNode + | ((record: ReferenceManyCountRecord) => React.ReactNode); timeout?: number; loading?: React.ReactNode; error?: React.ReactNode; diff --git a/packages/ra-ui-materialui/src/field/ReferenceManyCount.spec.tsx b/packages/ra-ui-materialui/src/field/ReferenceManyCount.spec.tsx index 8c866fd0e8e..61907774643 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceManyCount.spec.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceManyCount.spec.tsx @@ -4,8 +4,10 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { Basic, ErrorState, + LinkWithChildren, Offline, Themed, + WithChildren, WithFilter, Wrapper, } from './ReferenceManyCount.stories'; @@ -20,6 +22,19 @@ describe('', () => { render(); await screen.findByText('3'); }); + + it('should render children in a record context containing the total', async () => { + render(); + await screen.findByText('30,060'); + }); + + it('should wrap children in a link when link is true', async () => { + render(); + expect( + await screen.findByRole('link', { name: '30,060' }) + ).toHaveAttribute('href', '/comments?filter={"post_id":1}'); + }); + it('should render an error icon when the request fails', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); render(); diff --git a/packages/ra-ui-materialui/src/field/ReferenceManyCount.stories.tsx b/packages/ra-ui-materialui/src/field/ReferenceManyCount.stories.tsx index 14522b8e774..0e45b501d2e 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceManyCount.stories.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceManyCount.stories.tsx @@ -16,6 +16,7 @@ import { createTheme, ThemeOptions } from '@mui/material'; import { ReferenceManyCount } from './ReferenceManyCount'; import { defaultLightTheme, ThemeProvider, ThemesContext } from '../theme'; +import { NumberField } from './NumberField'; export default { title: 'ra-ui-materialui/fields/ReferenceManyCount', @@ -84,6 +85,22 @@ export const Basic = () => ( ); +export const WithChildren = () => ( + + Promise.resolve({ + data: [comments.filter(c => c.post_id === 1)[0]], + total: 30060, + }), + }} + > + + + + +); + export const LoadingState = () => ( new Promise(() => {}) }}> @@ -144,6 +161,22 @@ export const Link = () => ( ); +export const LinkWithChildren = () => ( + + Promise.resolve({ + data: [comments.filter(c => c.post_id === 1)[0]], + total: 30060, + }), + }} + > + + + + +); + export const LinkWithFilter = () => ( ( resource, source = 'id', offline = defaultOffline, + children, ...rest } = props; const record = useRecordContext(props); @@ -66,7 +67,9 @@ export const ReferenceManyCount = ( } offline={offline} - /> + > + {children} + ); return ( ; export interface ReferenceManyCountProps extends Omit, 'source'>, Omit, - Omit { + Omit { link?: boolean; }