Skip to content

Commit 2781033

Browse files
authored
Merge pull request #10908 from marmelab/use-bulk-export
Use bulk export
2 parents 13639a4 + 93d6c97 commit 2781033

File tree

5 files changed

+247
-34
lines changed

5 files changed

+247
-34
lines changed

packages/ra-core/src/export/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './defaultExporter';
22
export * from './downloadCSV';
33
export * from './ExporterContext';
44
export * from './fetchRelatedRecords';
5+
export * from './useBulkExport';
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import * as React from 'react';
2+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
import { Basic, HookLevelExporter } from './useBulkExport.stories';
4+
5+
describe('useBulkExport', () => {
6+
it('should export selected records using the exporter from the list context', async () => {
7+
let exportedData: any[];
8+
let exportedResource: string;
9+
const exporter = jest.fn(
10+
(data, fetchRelatedRecords, dataProvider, resource) => {
11+
exportedData = data;
12+
exportedResource = resource;
13+
}
14+
);
15+
render(<Basic exporter={exporter} />);
16+
fireEvent.click(await screen.findByText('War and Peace'));
17+
fireEvent.click(await screen.findByText('The Lord of the Rings'));
18+
fireEvent.click(await screen.findByText('Export'));
19+
await waitFor(() => expect(exporter).toHaveBeenCalled());
20+
expect(exportedData!).toEqual([
21+
{ id: 1, title: 'War and Peace' },
22+
{ id: 5, title: 'The Lord of the Rings' },
23+
]);
24+
expect(exportedResource!).toEqual('books');
25+
});
26+
it('should export selected records using the exporter from the hook options', async () => {
27+
const exporter = jest.fn();
28+
let exportedData: any[];
29+
let exportedResource: string;
30+
const hookExporter = jest.fn(
31+
(data, fetchRelatedRecords, dataProvider, resource) => {
32+
exportedData = data;
33+
exportedResource = resource;
34+
}
35+
);
36+
37+
render(
38+
<HookLevelExporter
39+
exporter={exporter}
40+
hookExporter={hookExporter}
41+
/>
42+
);
43+
fireEvent.click(await screen.findByText('War and Peace'));
44+
fireEvent.click(await screen.findByText('The Lord of the Rings'));
45+
fireEvent.click(await screen.findByText('Export'));
46+
await waitFor(() => expect(hookExporter).toHaveBeenCalled());
47+
expect(exportedData!).toEqual([
48+
{ id: 1, title: 'War and Peace' },
49+
{ id: 5, title: 'The Lord of the Rings' },
50+
]);
51+
expect(exportedResource!).toEqual('books');
52+
expect(exporter).toHaveBeenCalledTimes(0);
53+
});
54+
});
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import * as React from 'react';
2+
import fakeRestProvider from 'ra-data-fakerest';
3+
import {
4+
CoreAdminContext,
5+
Exporter,
6+
ListBase,
7+
useBulkExport,
8+
UseBulkExportOptions,
9+
useListContext,
10+
} from '..';
11+
12+
export default {
13+
title: 'ra-core/export/useBulkExport',
14+
};
15+
16+
const data = {
17+
books: [
18+
{ id: 1, title: 'War and Peace' },
19+
{ id: 2, title: 'The Little Prince' },
20+
{ id: 3, title: "Swann's Way" },
21+
{ id: 4, title: 'A Tale of Two Cities' },
22+
{ id: 5, title: 'The Lord of the Rings' },
23+
{ id: 6, title: 'And Then There Were None' },
24+
{ id: 7, title: 'Dream of the Red Chamber' },
25+
{ id: 8, title: 'The Hobbit' },
26+
{ id: 9, title: 'She: A History of Adventure' },
27+
{ id: 10, title: 'The Lion, the Witch and the Wardrobe' },
28+
{ id: 11, title: 'The Chronicles of Narnia' },
29+
{ id: 12, title: 'Pride and Prejudice' },
30+
{ id: 13, title: 'Ulysses' },
31+
{ id: 14, title: 'The Catcher in the Rye' },
32+
{ id: 15, title: 'The Little Mermaid' },
33+
{ id: 16, title: 'The Secret Garden' },
34+
{ id: 17, title: 'The Wind in the Willows' },
35+
{ id: 18, title: 'The Wizard of Oz' },
36+
{ id: 19, title: 'Madam Bovary' },
37+
{ id: 20, title: 'The Little House' },
38+
{ id: 21, title: 'The Phantom of the Opera' },
39+
{ id: 22, title: 'The Adventures of Tom Sawyer' },
40+
{ id: 23, title: 'The Adventures of Huckleberry Finn' },
41+
{ id: 24, title: 'The Time Machine' },
42+
{ id: 25, title: 'The War of the Worlds' },
43+
],
44+
};
45+
46+
const dataProvider = fakeRestProvider(
47+
data,
48+
process.env.NODE_ENV !== 'test',
49+
300
50+
);
51+
52+
export const Basic = ({
53+
exporter = (data, fetchRelatedRecords, dataProvider, resource) => {
54+
alert(
55+
`Exporting ${data.length} items (${data.map(record => record.id).join(', ')}) from ${resource}`
56+
);
57+
},
58+
}: {
59+
exporter?: Exporter;
60+
}) => (
61+
<CoreAdminContext dataProvider={dataProvider}>
62+
<ListBase resource="books" perPage={5} exporter={exporter}>
63+
<ListView />
64+
<BulkExportButton />
65+
</ListBase>
66+
</CoreAdminContext>
67+
);
68+
69+
const ListView = () => {
70+
const { data, error, isPending, selectedIds, onToggleItem } =
71+
useListContext();
72+
73+
if (isPending) {
74+
return <div>Loading...</div>;
75+
}
76+
if (error) {
77+
return <div>Error...</div>;
78+
}
79+
80+
return (
81+
<div>
82+
<ul>
83+
{data.map((record: any) => (
84+
<li key={record.id}>
85+
<label>
86+
<input
87+
type="checkbox"
88+
style={{ marginRight: '8px' }}
89+
checked={selectedIds.includes(record.id)}
90+
onChange={() => onToggleItem(record.id)}
91+
/>
92+
{record.title}
93+
</label>
94+
</li>
95+
))}
96+
</ul>
97+
</div>
98+
);
99+
};
100+
101+
export const HookLevelExporter = ({
102+
exporter = (data, fetchRelatedRecords, dataProvider, resource) => {
103+
alert(
104+
`Exporting ${data.length} items (${data.map(record => record.id).join(', ')}) from ${resource} at the list level`
105+
);
106+
},
107+
hookExporter = (data, fetchRelatedRecords, dataProvider, resource) => {
108+
alert(
109+
`Exporting ${data.length} items (${data.map(record => record.id).join(', ')}) from ${resource} at the hook level`
110+
);
111+
},
112+
}: {
113+
exporter?: Exporter;
114+
hookExporter?: Exporter;
115+
}) => (
116+
<CoreAdminContext dataProvider={dataProvider}>
117+
<ListBase resource="books" perPage={5} exporter={exporter}>
118+
<ListView />
119+
<BulkExportButton exporter={hookExporter} />
120+
</ListBase>
121+
</CoreAdminContext>
122+
);
123+
124+
const BulkExportButton = ({ exporter }: UseBulkExportOptions) => {
125+
const bulkExport = useBulkExport({ exporter });
126+
return <button onClick={bulkExport}>Export</button>;
127+
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useCallback } from 'react';
2+
import { Exporter, RaRecord } from '../types';
3+
import { useResourceContext } from '../core/useResourceContext';
4+
import { useListContext } from '../controller/list/useListContext';
5+
import { useDataProvider } from '../dataProvider/useDataProvider';
6+
import { useNotify } from '../notification/useNotify';
7+
import { fetchRelatedRecords } from './fetchRelatedRecords';
8+
9+
/**
10+
* A hook that provides a callback to export the selected records from the nearest ListContext and call the exporter function for them.
11+
*/
12+
export function useBulkExport<RecordType extends RaRecord = any>(
13+
options: UseBulkExportOptions<RecordType> = {}
14+
): UseBulkExportResult {
15+
const { exporter: customExporter, meta } = options;
16+
17+
const resource = useResourceContext(options);
18+
const { exporter: exporterFromContext, selectedIds } =
19+
useListContext<RecordType>();
20+
const exporter = customExporter || exporterFromContext;
21+
const dataProvider = useDataProvider();
22+
const notify = useNotify();
23+
24+
return useCallback(() => {
25+
if (exporter && resource) {
26+
dataProvider
27+
.getMany(resource, { ids: selectedIds, meta })
28+
.then(({ data }) =>
29+
exporter(
30+
data,
31+
fetchRelatedRecords(dataProvider),
32+
dataProvider,
33+
resource
34+
)
35+
)
36+
.catch(error => {
37+
console.error(error);
38+
notify('ra.notification.http_error', {
39+
type: 'error',
40+
});
41+
});
42+
}
43+
}, [dataProvider, exporter, notify, resource, selectedIds, meta]);
44+
}
45+
46+
export type UseBulkExportResult = () => void;
47+
48+
export interface UseBulkExportOptions<RecordType extends RaRecord = any> {
49+
exporter?: Exporter<RecordType>;
50+
meta?: any;
51+
resource?: string;
52+
}

packages/ra-ui-materialui/src/button/BulkExportButton.tsx

Lines changed: 13 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,9 @@ import * as React from 'react';
22
import { useCallback } from 'react';
33
import DownloadIcon from '@mui/icons-material/GetApp';
44
import {
5-
fetchRelatedRecords,
6-
useDataProvider,
7-
useNotify,
8-
Exporter,
9-
useListContext,
5+
useBulkExport,
106
useResourceContext,
7+
UseBulkExportOptions,
118
} from 'ra-core';
129
import {
1310
ComponentsOverrides,
@@ -55,35 +52,20 @@ export const BulkExportButton = (inProps: BulkExportButtonProps) => {
5552
...rest
5653
} = props;
5754
const resource = useResourceContext(props);
58-
const { exporter: exporterFromContext, selectedIds } = useListContext();
59-
const exporter = customExporter || exporterFromContext;
60-
const dataProvider = useDataProvider();
61-
const notify = useNotify();
55+
const bulkExport = useBulkExport({
56+
exporter: customExporter,
57+
resource,
58+
meta,
59+
});
6260
const handleClick = useCallback(
6361
event => {
64-
if (exporter && resource) {
65-
dataProvider
66-
.getMany(resource, { ids: selectedIds, meta })
67-
.then(({ data }) =>
68-
exporter(
69-
data,
70-
fetchRelatedRecords(dataProvider),
71-
dataProvider,
72-
resource
73-
)
74-
)
75-
.catch(error => {
76-
console.error(error);
77-
notify('ra.notification.http_error', {
78-
type: 'error',
79-
});
80-
});
81-
}
62+
bulkExport();
63+
8264
if (typeof onClick === 'function') {
8365
onClick(event);
8466
}
8567
},
86-
[dataProvider, exporter, notify, onClick, resource, selectedIds, meta]
68+
[bulkExport, onClick]
8769
);
8870

8971
return (
@@ -104,17 +86,14 @@ const sanitizeRestProps = ({
10486
...rest
10587
}: Omit<BulkExportButtonProps, 'exporter' | 'label' | 'meta'>) => rest;
10688

107-
interface Props {
108-
exporter?: Exporter;
89+
export interface BulkExportButtonProps
90+
extends ButtonProps,
91+
UseBulkExportOptions {
10992
icon?: React.ReactNode;
110-
label?: string;
111-
onClick?: (e: Event) => void;
11293
resource?: string;
11394
meta?: any;
11495
}
11596

116-
export type BulkExportButtonProps = Props & ButtonProps;
117-
11897
const PREFIX = 'RaBulkExportButton';
11998

12099
const StyledButton = styled(Button, {

0 commit comments

Comments
 (0)