diff --git a/packages/ra-core/src/export/index.ts b/packages/ra-core/src/export/index.ts
index 6a666bfd9ba..66339bbb6fb 100644
--- a/packages/ra-core/src/export/index.ts
+++ b/packages/ra-core/src/export/index.ts
@@ -2,3 +2,4 @@ export * from './defaultExporter';
export * from './downloadCSV';
export * from './ExporterContext';
export * from './fetchRelatedRecords';
+export * from './useBulkExport';
diff --git a/packages/ra-core/src/export/useBulkExport.spec.tsx b/packages/ra-core/src/export/useBulkExport.spec.tsx
new file mode 100644
index 00000000000..b9b5666b132
--- /dev/null
+++ b/packages/ra-core/src/export/useBulkExport.spec.tsx
@@ -0,0 +1,54 @@
+import * as React from 'react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { Basic, HookLevelExporter } from './useBulkExport.stories';
+
+describe('useBulkExport', () => {
+ it('should export selected records using the exporter from the list context', async () => {
+ let exportedData: any[];
+ let exportedResource: string;
+ const exporter = jest.fn(
+ (data, fetchRelatedRecords, dataProvider, resource) => {
+ exportedData = data;
+ exportedResource = resource;
+ }
+ );
+ render();
+ fireEvent.click(await screen.findByText('War and Peace'));
+ fireEvent.click(await screen.findByText('The Lord of the Rings'));
+ fireEvent.click(await screen.findByText('Export'));
+ await waitFor(() => expect(exporter).toHaveBeenCalled());
+ expect(exportedData!).toEqual([
+ { id: 1, title: 'War and Peace' },
+ { id: 5, title: 'The Lord of the Rings' },
+ ]);
+ expect(exportedResource!).toEqual('books');
+ });
+ it('should export selected records using the exporter from the hook options', async () => {
+ const exporter = jest.fn();
+ let exportedData: any[];
+ let exportedResource: string;
+ const hookExporter = jest.fn(
+ (data, fetchRelatedRecords, dataProvider, resource) => {
+ exportedData = data;
+ exportedResource = resource;
+ }
+ );
+
+ render(
+
+ );
+ fireEvent.click(await screen.findByText('War and Peace'));
+ fireEvent.click(await screen.findByText('The Lord of the Rings'));
+ fireEvent.click(await screen.findByText('Export'));
+ await waitFor(() => expect(hookExporter).toHaveBeenCalled());
+ expect(exportedData!).toEqual([
+ { id: 1, title: 'War and Peace' },
+ { id: 5, title: 'The Lord of the Rings' },
+ ]);
+ expect(exportedResource!).toEqual('books');
+ expect(exporter).toHaveBeenCalledTimes(0);
+ });
+});
diff --git a/packages/ra-core/src/export/useBulkExport.stories.tsx b/packages/ra-core/src/export/useBulkExport.stories.tsx
new file mode 100644
index 00000000000..ff972a5b84b
--- /dev/null
+++ b/packages/ra-core/src/export/useBulkExport.stories.tsx
@@ -0,0 +1,127 @@
+import * as React from 'react';
+import fakeRestProvider from 'ra-data-fakerest';
+import {
+ CoreAdminContext,
+ Exporter,
+ ListBase,
+ useBulkExport,
+ UseBulkExportOptions,
+ useListContext,
+} from '..';
+
+export default {
+ title: 'ra-core/export/useBulkExport',
+};
+
+const data = {
+ books: [
+ { id: 1, title: 'War and Peace' },
+ { id: 2, title: 'The Little Prince' },
+ { id: 3, title: "Swann's Way" },
+ { id: 4, title: 'A Tale of Two Cities' },
+ { id: 5, title: 'The Lord of the Rings' },
+ { id: 6, title: 'And Then There Were None' },
+ { id: 7, title: 'Dream of the Red Chamber' },
+ { id: 8, title: 'The Hobbit' },
+ { id: 9, title: 'She: A History of Adventure' },
+ { id: 10, title: 'The Lion, the Witch and the Wardrobe' },
+ { id: 11, title: 'The Chronicles of Narnia' },
+ { id: 12, title: 'Pride and Prejudice' },
+ { id: 13, title: 'Ulysses' },
+ { id: 14, title: 'The Catcher in the Rye' },
+ { id: 15, title: 'The Little Mermaid' },
+ { id: 16, title: 'The Secret Garden' },
+ { id: 17, title: 'The Wind in the Willows' },
+ { id: 18, title: 'The Wizard of Oz' },
+ { id: 19, title: 'Madam Bovary' },
+ { id: 20, title: 'The Little House' },
+ { id: 21, title: 'The Phantom of the Opera' },
+ { id: 22, title: 'The Adventures of Tom Sawyer' },
+ { id: 23, title: 'The Adventures of Huckleberry Finn' },
+ { id: 24, title: 'The Time Machine' },
+ { id: 25, title: 'The War of the Worlds' },
+ ],
+};
+
+const dataProvider = fakeRestProvider(
+ data,
+ process.env.NODE_ENV !== 'test',
+ 300
+);
+
+export const Basic = ({
+ exporter = (data, fetchRelatedRecords, dataProvider, resource) => {
+ alert(
+ `Exporting ${data.length} items (${data.map(record => record.id).join(', ')}) from ${resource}`
+ );
+ },
+}: {
+ exporter?: Exporter;
+}) => (
+
+
+
+
+
+
+);
+
+const ListView = () => {
+ const { data, error, isPending, selectedIds, onToggleItem } =
+ useListContext();
+
+ if (isPending) {
+ return
Loading...
;
+ }
+ if (error) {
+ return Error...
;
+ }
+
+ return (
+
+ );
+};
+
+export const HookLevelExporter = ({
+ exporter = (data, fetchRelatedRecords, dataProvider, resource) => {
+ alert(
+ `Exporting ${data.length} items (${data.map(record => record.id).join(', ')}) from ${resource} at the list level`
+ );
+ },
+ hookExporter = (data, fetchRelatedRecords, dataProvider, resource) => {
+ alert(
+ `Exporting ${data.length} items (${data.map(record => record.id).join(', ')}) from ${resource} at the hook level`
+ );
+ },
+}: {
+ exporter?: Exporter;
+ hookExporter?: Exporter;
+}) => (
+
+
+
+
+
+
+);
+
+const BulkExportButton = ({ exporter }: UseBulkExportOptions) => {
+ const bulkExport = useBulkExport({ exporter });
+ return ;
+};
diff --git a/packages/ra-core/src/export/useBulkExport.ts b/packages/ra-core/src/export/useBulkExport.ts
new file mode 100644
index 00000000000..c8c99a11596
--- /dev/null
+++ b/packages/ra-core/src/export/useBulkExport.ts
@@ -0,0 +1,52 @@
+import { useCallback } from 'react';
+import { Exporter, RaRecord } from '../types';
+import { useResourceContext } from '../core/useResourceContext';
+import { useListContext } from '../controller/list/useListContext';
+import { useDataProvider } from '../dataProvider/useDataProvider';
+import { useNotify } from '../notification/useNotify';
+import { fetchRelatedRecords } from './fetchRelatedRecords';
+
+/**
+ * A hook that provides a callback to export the selected records from the nearest ListContext and call the exporter function for them.
+ */
+export function useBulkExport(
+ options: UseBulkExportOptions = {}
+): UseBulkExportResult {
+ const { exporter: customExporter, meta } = options;
+
+ const resource = useResourceContext(options);
+ const { exporter: exporterFromContext, selectedIds } =
+ useListContext();
+ const exporter = customExporter || exporterFromContext;
+ const dataProvider = useDataProvider();
+ const notify = useNotify();
+
+ return useCallback(() => {
+ if (exporter && resource) {
+ dataProvider
+ .getMany(resource, { ids: selectedIds, meta })
+ .then(({ data }) =>
+ exporter(
+ data,
+ fetchRelatedRecords(dataProvider),
+ dataProvider,
+ resource
+ )
+ )
+ .catch(error => {
+ console.error(error);
+ notify('ra.notification.http_error', {
+ type: 'error',
+ });
+ });
+ }
+ }, [dataProvider, exporter, notify, resource, selectedIds, meta]);
+}
+
+export type UseBulkExportResult = () => void;
+
+export interface UseBulkExportOptions {
+ exporter?: Exporter;
+ meta?: any;
+ resource?: string;
+}
diff --git a/packages/ra-ui-materialui/src/button/BulkExportButton.tsx b/packages/ra-ui-materialui/src/button/BulkExportButton.tsx
index 58a353d324a..685b3b21e24 100644
--- a/packages/ra-ui-materialui/src/button/BulkExportButton.tsx
+++ b/packages/ra-ui-materialui/src/button/BulkExportButton.tsx
@@ -2,12 +2,9 @@ import * as React from 'react';
import { useCallback } from 'react';
import DownloadIcon from '@mui/icons-material/GetApp';
import {
- fetchRelatedRecords,
- useDataProvider,
- useNotify,
- Exporter,
- useListContext,
+ useBulkExport,
useResourceContext,
+ UseBulkExportOptions,
} from 'ra-core';
import {
ComponentsOverrides,
@@ -55,35 +52,20 @@ export const BulkExportButton = (inProps: BulkExportButtonProps) => {
...rest
} = props;
const resource = useResourceContext(props);
- const { exporter: exporterFromContext, selectedIds } = useListContext();
- const exporter = customExporter || exporterFromContext;
- const dataProvider = useDataProvider();
- const notify = useNotify();
+ const bulkExport = useBulkExport({
+ exporter: customExporter,
+ resource,
+ meta,
+ });
const handleClick = useCallback(
event => {
- if (exporter && resource) {
- dataProvider
- .getMany(resource, { ids: selectedIds, meta })
- .then(({ data }) =>
- exporter(
- data,
- fetchRelatedRecords(dataProvider),
- dataProvider,
- resource
- )
- )
- .catch(error => {
- console.error(error);
- notify('ra.notification.http_error', {
- type: 'error',
- });
- });
- }
+ bulkExport();
+
if (typeof onClick === 'function') {
onClick(event);
}
},
- [dataProvider, exporter, notify, onClick, resource, selectedIds, meta]
+ [bulkExport, onClick]
);
return (
@@ -104,17 +86,14 @@ const sanitizeRestProps = ({
...rest
}: Omit) => rest;
-interface Props {
- exporter?: Exporter;
+export interface BulkExportButtonProps
+ extends ButtonProps,
+ UseBulkExportOptions {
icon?: React.ReactNode;
- label?: string;
- onClick?: (e: Event) => void;
resource?: string;
meta?: any;
}
-export type BulkExportButtonProps = Props & ButtonProps;
-
const PREFIX = 'RaBulkExportButton';
const StyledButton = styled(Button, {