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 ( +
+
    + {data.map((record: any) => ( +
  • + +
  • + ))} +
+
+ ); +}; + +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, {