From 60b0fd28c3771efef10346278bb18a05891a11b3 Mon Sep 17 00:00:00 2001 From: Matthieu Hochlander Date: Mon, 15 Sep 2025 18:52:45 +0200 Subject: [PATCH] Add empty property to ListBase and InfiniteListBase. --- docs/ListBase.md | 1 + .../controller/list/InfiniteListBase.spec.tsx | 9 +++++ .../list/InfiniteListBase.stories.tsx | 33 +++++++++++++++++ .../src/controller/list/InfiniteListBase.tsx | 32 ++++++++++++++-- .../src/controller/list/ListBase.spec.tsx | 8 ++++ .../src/controller/list/ListBase.stories.tsx | 33 +++++++++++++++++ .../ra-core/src/controller/list/ListBase.tsx | 37 ++++++++++++++++--- .../src/list/InfiniteList.tsx | 5 ++- packages/ra-ui-materialui/src/list/List.tsx | 2 +- 9 files changed, 150 insertions(+), 10 deletions(-) diff --git a/docs/ListBase.md b/docs/ListBase.md index e361ec0f7ed..db7ff3cd737 100644 --- a/docs/ListBase.md +++ b/docs/ListBase.md @@ -84,6 +84,7 @@ The `` component accepts the following props: * [`debounce`](./List.md#debounce) * [`disableAuthentication`](./List.md#disableauthentication) * [`disableSyncWithLocation`](./List.md#disablesyncwithlocation) +* [`empty`](./List.md#empty) * [`emptyWhileLoading`](./List.md#emptywhileloading) * [`exporter`](./List.md#exporter) * [`filter`](./List.md#filter-permanent-filter) diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx index f02f440b58d..f91c9d2bfb2 100644 --- a/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx +++ b/packages/ra-core/src/controller/list/InfiniteListBase.spec.tsx @@ -11,6 +11,7 @@ import { } from './InfiniteListBase.stories'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { testDataProvider } from '../../dataProvider'; +import { Empty } from './ListBase.stories'; describe('InfiniteListBase', () => { it('should fetch a list of records on mount, put it in a ListContext, and render its children', async () => { @@ -160,6 +161,14 @@ describe('InfiniteListBase', () => { expect(screen.queryByText('Loading...')).toBeNull(); }); + it('should render a custom empty component when data is empty', async () => { + render(); + expect(screen.queryByText('Loading...')).not.toBeNull(); + expect(screen.queryByText('War and Peace')).toBeNull(); + fireEvent.click(screen.getByText('Resolve books loading')); + await screen.findByText('No books'); + }); + it('should render loading component while loading', async () => { render(); expect(screen.queryByText('Loading books...')).not.toBeNull(); diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx index d31157a6285..1a8e86db136 100644 --- a/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx +++ b/packages/ra-core/src/controller/list/InfiniteListBase.stories.tsx @@ -252,6 +252,39 @@ export const FetchError = () => { ); }; +export const Empty = () => { + let resolveGetList: (() => void) | null = null; + const dataProvider = { + ...defaultDataProvider, + getList: () => { + return new Promise(resolve => { + resolveGetList = () => resolve({ data: [], total: 0 }); + }); + }, + }; + + return ( + + + Loading...

} + empty={

No books

} + > + + +
+
+ ); +}; + const defaultI18nProvider = polyglotI18nProvider( locale => locale === 'fr' diff --git a/packages/ra-core/src/controller/list/InfiniteListBase.tsx b/packages/ra-core/src/controller/list/InfiniteListBase.tsx index 122b92a246b..b27bf722ca3 100644 --- a/packages/ra-core/src/controller/list/InfiniteListBase.tsx +++ b/packages/ra-core/src/controller/list/InfiniteListBase.tsx @@ -50,6 +50,7 @@ export const InfiniteListBase = ({ loading, offline, error, + empty, children, render, ...props @@ -71,6 +72,11 @@ export const InfiniteListBase = ({ isPending, isPlaceholderData, error: errorState, + data, + total, + hasPreviousPage, + hasNextPage, + filterValues, } = controllerProps; const showAuthLoading = @@ -95,6 +101,23 @@ export const InfiniteListBase = ({ const showError = errorState && error !== false && error !== undefined; + const showEmpty = + !errorState && + // the list is not loading data for the first time + !isPending && + // the API returned no data (using either normal or partial pagination) + (total === 0 || + (total == null && + hasPreviousPage === false && + hasNextPage === false && + // @ts-ignore FIXME total may be undefined when using partial pagination but the ListControllerResult type is wrong about it + data.length === 0)) && + // the user didn't set any filters + !Object.keys(filterValues).length && + // there is an empty page component + empty !== undefined && + empty !== false; + return ( // We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided @@ -118,9 +141,11 @@ export const InfiniteListBase = ({ ? offline : showError ? error - : render - ? render(controllerProps) - : children} + : showEmpty + ? empty + : render + ? render(controllerProps) + : children} @@ -133,6 +158,7 @@ export interface InfiniteListBaseProps loading?: ReactNode; offline?: ReactNode; error?: ReactNode; + empty?: ReactNode; children?: ReactNode; render?: (props: InfiniteListControllerResult) => ReactNode; } diff --git a/packages/ra-core/src/controller/list/ListBase.spec.tsx b/packages/ra-core/src/controller/list/ListBase.spec.tsx index f0459c30aef..bde8bef3d31 100644 --- a/packages/ra-core/src/controller/list/ListBase.spec.tsx +++ b/packages/ra-core/src/controller/list/ListBase.spec.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { AccessControl, DefaultTitle, + Empty, EmptyWhileLoading, EmptyWhileLoadingRender, FetchError, @@ -168,6 +169,13 @@ describe('ListBase', () => { await screen.findByText('War and Peace'); await screen.findByText('You are offline, the data may be outdated'); }); + it('should render a custom empty component when data is empty', async () => { + render(); + expect(screen.queryByText('Loading...')).not.toBeNull(); + expect(screen.queryByText('War and Peace')).toBeNull(); + fireEvent.click(screen.getByText('Resolve books loading')); + await screen.findByText('No books'); + }); it('should render nothing while loading if emptyWhileLoading is set to true', async () => { render(); expect(screen.queryByText('Loading...')).toBeNull(); diff --git a/packages/ra-core/src/controller/list/ListBase.stories.tsx b/packages/ra-core/src/controller/list/ListBase.stories.tsx index c3677f6dc36..7fed25f2d6a 100644 --- a/packages/ra-core/src/controller/list/ListBase.stories.tsx +++ b/packages/ra-core/src/controller/list/ListBase.stories.tsx @@ -503,6 +503,39 @@ export const FetchError = () => { ); }; +export const Empty = () => { + let resolveGetList: (() => void) | null = null; + const baseProvider = defaultDataProvider(0); + const dataProvider = { + ...baseProvider, + getList: () => { + return new Promise(resolve => { + resolveGetList = () => resolve({ data: [], total: 0 }); + }); + }, + }; + + return ( + + + Loading...

} + empty={

No books

} + > + +
+
+ ); +}; + export const EmptyWhileLoading = () => { let resolveGetList: (() => void) | null = null; const baseProvider = defaultDataProvider(0); diff --git a/packages/ra-core/src/controller/list/ListBase.tsx b/packages/ra-core/src/controller/list/ListBase.tsx index dc5daa332ca..990e65d6d3d 100644 --- a/packages/ra-core/src/controller/list/ListBase.tsx +++ b/packages/ra-core/src/controller/list/ListBase.tsx @@ -51,6 +51,7 @@ export const ListBase = ({ loading, offline, error, + empty, render, ...props }: ListBaseProps) => { @@ -71,6 +72,11 @@ export const ListBase = ({ isPending, isPlaceholderData, error: errorState, + data, + total, + hasPreviousPage, + hasNextPage, + filterValues, } = controllerProps; const showAuthLoading = @@ -95,7 +101,25 @@ export const ListBase = ({ const showError = errorState && error !== false && error !== undefined; - const showEmpty = isPending && !showOffline && emptyWhileLoading === true; + const showEmptyWhileLoading = + isPending && !showOffline && emptyWhileLoading === true; + + const showEmpty = + !errorState && + // the list is not loading data for the first time + !isPending && + // the API returned no data (using either normal or partial pagination) + (total === 0 || + (total == null && + hasPreviousPage === false && + hasNextPage === false && + // @ts-ignore FIXME total may be undefined when using partial pagination but the ListControllerResult type is wrong about it + data.length === 0)) && + // the user didn't set any filters + !Object.keys(filterValues).length && + // there is an empty page component + empty !== undefined && + empty !== false; return ( // We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided @@ -109,11 +133,13 @@ export const ListBase = ({ ? offline : showError ? error - : showEmpty + : showEmptyWhileLoading ? null - : render - ? render(controllerProps) - : children} + : showEmpty + ? empty + : render + ? render(controllerProps) + : children} ); @@ -126,6 +152,7 @@ export interface ListBaseProps loading?: ReactNode; offline?: ReactNode; error?: ReactNode; + empty?: ReactNode; children?: ReactNode; render?: (props: ListControllerResult) => ReactNode; } diff --git a/packages/ra-ui-materialui/src/list/InfiniteList.tsx b/packages/ra-ui-materialui/src/list/InfiniteList.tsx index f960728d210..23df37a782d 100644 --- a/packages/ra-ui-materialui/src/list/InfiniteList.tsx +++ b/packages/ra-ui-materialui/src/list/InfiniteList.tsx @@ -118,7 +118,10 @@ const defaultFilter = {}; const defaultAuthLoading = ; export interface InfiniteListProps - extends Omit, 'children' | 'render'>, + extends Omit< + InfiniteListBaseProps, + 'children' | 'render' | 'empty' + >, ListViewProps {} const PREFIX = 'RaInfiniteList'; diff --git a/packages/ra-ui-materialui/src/list/List.tsx b/packages/ra-ui-materialui/src/list/List.tsx index 03349a90424..d45f6a1081a 100644 --- a/packages/ra-ui-materialui/src/list/List.tsx +++ b/packages/ra-ui-materialui/src/list/List.tsx @@ -111,7 +111,7 @@ export const List = ( }; export interface ListProps - extends Omit, 'children' | 'render'>, + extends Omit, 'children' | 'render' | 'empty'>, ListViewProps {} const defaultFilter = {};