Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support Component and Element for List props #10539

Open
wants to merge 4 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 28 additions & 27 deletions docs/List.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,29 +52,29 @@ You can find more advanced examples of `<List>` usage in the [demos](./Demos.md)

## Props

| Prop | Required | Type | Default | Description |
|---------------------------|----------|----------------|----------------|----------------------------------------------------------------------------------------------|
| `children` | Required | `ReactNode` | - | The components rendering the list of records. |
| `actions` | Optional | `ReactElement` | - | The actions to display in the toolbar. |
| `aside` | Optional | `ReactElement` | - | The component to display on the side of the list. |
| `component` | Optional | `Component` | `Card` | The component to render as the root element. |
| `debounce` | Optional | `number` | `500` | The debounce delay in milliseconds to apply when users change the sort or filter parameters. |
| `disable Authentication` | Optional | `boolean` | `false` | Set to `true` to disable the authentication check. |
| `disable SyncWithLocation`| Optional | `boolean` | `false` | Set to `true` to disable the synchronization of the list parameters with the URL. |
| `empty` | Optional | `ReactElement` | - | The component to display when the list is empty. |
| `empty WhileLoading` | Optional | `boolean` | `false` | Set to `true` to return `null` while the list is loading. |
| `exporter` | Optional | `function` | - | The function to call to export the list. |
| `filters` | Optional | `ReactElement` | - | The filters to display in the toolbar. |
| `filter` | Optional | `object` | - | The permanent filter values. |
| `filter DefaultValues` | Optional | `object` | - | The default filter values. |
| `pagination` | Optional | `ReactElement` | `<Pagination>` | The pagination component to use. |
| `perPage` | Optional | `number` | `10` | The number of records to fetch per page. |
| `queryOptions` | Optional | `object` | - | The options to pass to the `useQuery` hook. |
| `resource` | Optional | `string` | - | The resource name, e.g. `posts`. |
| `sort` | Optional | `object` | - | The initial sort parameters. |
| `storeKey` | Optional | `string | false` | - | The key to use to store the current filter & sort. Pass `false` to disable store synchronization |
| `title` | Optional | `string | ReactElement | false` | - | The title to display in the App Bar. |
| `sx` | Optional | `object` | - | The CSS styles to apply to the component. |
| Prop | Required | Type | Default | Description |
|---------------------------|----------|----------------------------------------|----------------|-------------------------------------------------------------------------------------------------|
| `children` | Required | `ReactNode` | - | The components rendering the list of records. |
| `actions` | Optional | `ReactElement | ComponentType | false` | - | The actions to display in the toolbar. |
| `aside` | Optional | `ReactElement | ComponentType` | - | The component to display on the side of the list. |
| `component` | Optional | `Component` | `Card` | The component to render as the root element. |
| `debounce` | Optional | `number` | `500` | The debounce delay in milliseconds to apply when users change the sort or filter parameters. |
| `disable Authentication` | Optional | `boolean` | `false` | Set to `true` to disable the authentication check. |
| `disable SyncWithLocation`| Optional | `boolean` | `false` | Set to `true` to disable the synchronization of the list parameters with the URL. |
| `empty` | Optional | `ReactElement | ComponentType | false` | - | The component to display when the list is empty. |
| `empty WhileLoading` | Optional | `boolean` | `false` | Set to `true` to return `null` while the list is loading. |
| `exporter` | Optional | `function` | - | The function to call to export the list. |
| `filters` | Optional | `ReactElement` | - | The filters to display in the toolbar. |
| `filter` | Optional | `object` | - | The permanent filter values. |
| `filter DefaultValues` | Optional | `object` | - | The default filter values. |
| `pagination` | Optional | `ReactElement | ComponentType | false` | `<Pagination>` | The pagination component to use. |
| `perPage` | Optional | `number` | `10` | The number of records to fetch per page. |
| `queryOptions` | Optional | `object` | - | The options to pass to the `useQuery` hook. |
| `resource` | Optional | `string` | - | The resource name, e.g. `posts`. |
| `sort` | Optional | `object` | - | The initial sort parameters. |
| `storeKey` | Optional | `string | false` | - | The key to use to store the current filter & sort. Pass `false` to disable store synchronization |
| `title` | Optional | `string | ReactElement | ComponentType | false` | - | The title to display in the App Bar. |
| `sx` | Optional | `object` | - | The CSS styles to apply to the component. |

Additional props are passed down to the root component (a MUI `<Card>` by default).

Expand Down Expand Up @@ -139,6 +139,7 @@ export const PostList = () => (
</List>
);
```
You can use a `ReactElement` (e.g. `actions={<ListActions/>}`), a `ComponentType` (e.g. `actions={ListActions}`) or false to disable it.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: I'd be tempted to use the component syntax in the example above (since it's shorter and offers better performances), and simply mention that action also accepts the other types

Suggested change
You can use a `ReactElement` (e.g. `actions={<ListActions/>}`), a `ComponentType` (e.g. `actions={ListActions}`) or false to disable it.
**Tip:** `action` also accepts a `ReactElement` (e.g. `actions={<ListActions/>}`), or `false`.

But on the other hand, it may be odd to encourage this syntax only for the List component (since it's the only one we support for now...)

So I'm hesitating.

Same remark applies to other props below.

@fzaninotto @djhi wdyt?


**Tip**: If you are looking for an `<ImportButton>`, check out this third-party package: [benwinding/react-admin-import-csv](https://github.com/benwinding/react-admin-import-csv).

Expand Down Expand Up @@ -175,7 +176,7 @@ The default `<List>` layout lets you render the component of your choice on the

![List with aside](./img/list_aside.webp)

Pass a React element as the `aside` prop for that purpose:
Pass a React element or ComponentType as the `aside` prop for that purpose:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: I believe ComponentType designates the type of such a component, but my understanding is that you can speak of a React Component (and a React Element)

Suggested change
Pass a React element or ComponentType as the `aside` prop for that purpose:
Pass a React element or component as the `aside` prop for that purpose:

Copy link
Contributor Author

@guilbill guilbill Feb 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But it IS expected a ComponentType (as defined in the ListViewProps), otherwise we couldn't do aside={Aside}. It let's the responsibility of the instanciation of the subcomponent (aside) to the component (List).


{% raw %}
```jsx
Expand Down Expand Up @@ -483,7 +484,7 @@ When there is no result, and there is no active filter, and the resource has a c

![Empty invite](./img/list-empty.png)

You can use the `empty` prop to replace that page by a custom component:
You can use the `empty` prop to replace that page by a custom component, passing a ReactElement or ComponentType :
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
You can use the `empty` prop to replace that page by a custom component, passing a ReactElement or ComponentType :
You can use the `empty` prop to replace that page by a custom React element or component:


{% raw %}
```jsx
Expand Down Expand Up @@ -783,7 +784,7 @@ By default, the `<List>` view displays a set of pagination controls at the botto

![Pagination](./img/list-pagination.webp)

The `pagination` prop allows to replace the default pagination controls by your own.
The `pagination` prop allows to replace the default pagination controls by your own, either with a ReactElement or a ComponentType.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The `pagination` prop allows to replace the default pagination controls by your own, either with a ReactElement or a ComponentType.
The `pagination` prop allows to replace the default pagination controls by your own, either with a React element or component.


```jsx
// in src/MyPagination.js
Expand Down Expand Up @@ -990,7 +991,7 @@ export const PostList = () => (
);
```

The title can be a string, a React element, or `false` to disable the title.
The title can be a string, a React element, a ComponentType or `false` to disable the title.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The title can be a string, a React element, a ComponentType or `false` to disable the title.
The title can be a string, a React element, a React component or `false` to disable the title.


## `sx`: CSS API

Expand Down
70 changes: 70 additions & 0 deletions packages/ra-ui-materialui/src/list/List.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import {
PartialPagination,
Default,
SelectAllLimit,
TitleComponent,
ActionsElement,
ActionsComponent,
} from './List.stories';

const theme = createTheme(defaultTheme);
Expand Down Expand Up @@ -112,6 +115,25 @@ describe('<List />', () => {
expect(screen.queryAllByText('Hello')).toHaveLength(1);
});

it('should display aside component with ComponentType', () => {
const Dummy = () => <div />;
const Aside = () => <div id="aside">Hello</div>;
render(
<CoreAdminContext
dataProvider={testDataProvider({
getList: () => Promise.resolve({ data: [], total: 0 }),
})}
>
<ThemeProvider theme={theme}>
<List resource="posts" aside={Aside}>
<Dummy />
</List>
</ThemeProvider>
</CoreAdminContext>
);
expect(screen.queryAllByText('Hello')).toHaveLength(1);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
expect(screen.queryAllByText('Hello')).toHaveLength(1);
screen.getByText('Hello');

});

describe('empty', () => {
it('should render an invite when the list is empty', async () => {
const Dummy = () => {
Expand Down Expand Up @@ -197,6 +219,36 @@ describe('<List />', () => {
});
});

it('should render custom empty component when data is empty with ComponentType', async () => {
const Dummy = () => null;
const CustomEmpty = () => <div>Custom Empty</div>;

const dataProvider = {
getList: jest.fn(() =>
Promise.resolve({
data: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
},
})
),
} as any;
render(
<CoreAdminContext dataProvider={dataProvider}>
<ThemeProvider theme={theme}>
<List resource="posts" empty={CustomEmpty}>
<Dummy />
</List>
</ThemeProvider>
</CoreAdminContext>
);
await waitFor(() => {
expect(screen.queryByText('resources.posts.empty')).toBeNull();
screen.getByText('Custom Empty');
});
Comment on lines +246 to +249
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
await waitFor(() => {
expect(screen.queryByText('resources.posts.empty')).toBeNull();
screen.getByText('Custom Empty');
});
await screen.findByText('Custom Empty');
expect(screen.queryByText('resources.posts.empty')).toBeNull();

});

it('should not render an invite when a filter is active', async () => {
const Dummy = () => {
const { isPending } = useListContext();
Expand Down Expand Up @@ -336,6 +388,12 @@ describe('<List />', () => {
screen.getByText('Custom list title');
});

it('should render custom title component when defined', async () => {
render(<TitleComponent />);
await screen.findByText('War and Peace (1869)');
screen.getByText('Custom list title');
});

it('should not render default title when false', async () => {
render(<TitleFalse />);
await screen.findByText('War and Peace (1869)');
Expand Down Expand Up @@ -486,4 +544,16 @@ describe('<List />', () => {
);
});
});
describe('Custom actions', () => {
it('should render custom actions with ReactElement', async () => {
render(<ActionsElement />);
await screen.findByText('War and Peace (1869)');
screen.getByText('Actions');
});
it('should render custom actions with ComponentType', async () => {
render(<ActionsComponent />);
await screen.findByText('War and Peace (1869)');
screen.getByText('Actions');
});
});
});
92 changes: 87 additions & 5 deletions packages/ra-ui-materialui/src/list/List.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { Admin, AutocompleteInput } from 'react-admin';
import { Admin, AutocompleteInput, Pagination } from 'react-admin';

Check warning on line 2 in packages/ra-ui-materialui/src/list/List.stories.tsx

View workflow job for this annotation

GitHub Actions / typecheck

'Pagination' is defined but never used. Allowed unused vars must match /^_/u
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

linter warning at this line

import {
CustomRoutes,
Resource,
Expand All @@ -10,6 +10,8 @@
import fakeRestDataProvider from 'ra-data-fakerest';
import { Box, Card, Typography, Button, Link as MuiLink } from '@mui/material';

import { Empty as DefaultEmpty } from './Empty';
import { Pagination as DefaultPagination } from './pagination';
import { List } from './List';
import { SimpleList } from './SimpleList';
import { ListActions } from './ListActions';
Expand Down Expand Up @@ -169,7 +171,7 @@
</TestMemoryRouter>
);

export const Actions = () => (
export const ActionsElement = () => (
<TestMemoryRouter initialEntries={['/books']}>
<Admin dataProvider={defaultDataProvider}>
<Resource
Expand All @@ -190,6 +192,23 @@
</TestMemoryRouter>
);

const Actions = () => <Box sx={{ backgroundColor: 'info.main' }}>Actions</Box>;

export const ActionsComponent = () => (
<TestMemoryRouter initialEntries={['/books']}>
<Admin dataProvider={defaultDataProvider}>
<Resource
name="books"
list={() => (
<List actions={Actions}>
<BookList />
</List>
)}
/>
</Admin>
</TestMemoryRouter>
);

export const Filters = () => (
<TestMemoryRouter initialEntries={['/books']}>
<Admin dataProvider={defaultDataProvider}>
Expand Down Expand Up @@ -267,6 +286,23 @@
</TestMemoryRouter>
);

const TitleSpan = () => <span>Custom list title</span>;

export const TitleComponent = () => (
<TestMemoryRouter initialEntries={['/books']}>
<Admin dataProvider={defaultDataProvider}>
<Resource
name="books"
list={() => (
<List title={TitleSpan}>
<BookList />
</List>
)}
/>
</Admin>
</TestMemoryRouter>
);

export const TitleFalse = () => (
<TestMemoryRouter initialEntries={['/books']}>
<Admin dataProvider={defaultDataProvider}>
Expand Down Expand Up @@ -297,15 +333,30 @@
</TestMemoryRouter>
);

const AsideComponent = () => <Card sx={{ padding: 2 }}>Aside</Card>;
const AsideBlock = () => <Card sx={{ padding: 2 }}>Aside</Card>;

export const AsideElement = () => (
<TestMemoryRouter initialEntries={['/books']}>
<Admin dataProvider={defaultDataProvider}>
<Resource
name="books"
list={() => (
<List aside={<AsideBlock />}>
<BookList />
</List>
)}
/>
</Admin>
</TestMemoryRouter>
);

export const Aside = () => (
export const AsideComponent = () => (
<TestMemoryRouter initialEntries={['/books']}>
<Admin dataProvider={defaultDataProvider}>
<Resource
name="books"
list={() => (
<List aside={<AsideComponent />}>
<List aside={AsideBlock}>
<BookList />
</List>
)}
Expand Down Expand Up @@ -387,6 +438,22 @@
</TestMemoryRouter>
);

export const EmptyComponent = () => (
<TestMemoryRouter initialEntries={['/authors']}>
<Admin dataProvider={defaultDataProvider}>
<Resource
name="authors"
list={() => (
<List empty={DefaultEmpty}>
<span />
</List>
)}
create={() => <span />}
/>
</Admin>
</TestMemoryRouter>
);

export const EmptyPartialPagination = () => (
<TestMemoryRouter initialEntries={['/authors']}>
<Admin
Expand Down Expand Up @@ -415,6 +482,21 @@
</TestMemoryRouter>
);

export const PaginationComponent = () => (
<TestMemoryRouter initialEntries={['/books']}>
<Admin dataProvider={defaultDataProvider}>
<Resource
name="books"
list={() => (
<List pagination={DefaultPagination}>
<BookList />
</List>
)}
/>
</Admin>
</TestMemoryRouter>
);

export const SX = () => (
<TestMemoryRouter initialEntries={['/books']}>
<Admin dataProvider={defaultDataProvider}>
Expand Down
Loading
Loading