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 = {};