Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/ra-core/src/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './defaultExporter';
export * from './downloadCSV';
export * from './ExporterContext';
export * from './fetchRelatedRecords';
export * from './useBulkExport';
54 changes: 54 additions & 0 deletions packages/ra-core/src/export/useBulkExport.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<Basic exporter={exporter} />);
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(
<HookLevelExporter
exporter={exporter}
hookExporter={hookExporter}
/>
);
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);
});
});
127 changes: 127 additions & 0 deletions packages/ra-core/src/export/useBulkExport.stories.tsx
Original file line number Diff line number Diff line change
@@ -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;
}) => (
<CoreAdminContext dataProvider={dataProvider}>
<ListBase resource="books" perPage={5} exporter={exporter}>
<ListView />
<BulkExportButton />
</ListBase>
</CoreAdminContext>
);

const ListView = () => {
const { data, error, isPending, selectedIds, onToggleItem } =
useListContext();

if (isPending) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error...</div>;
}

return (
<div>
<ul>
{data.map((record: any) => (
<li key={record.id}>
<label>
<input
type="checkbox"
style={{ marginRight: '8px' }}
checked={selectedIds.includes(record.id)}
onChange={() => onToggleItem(record.id)}
/>
{record.title}
</label>
</li>
))}
</ul>
</div>
);
};

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;
}) => (
<CoreAdminContext dataProvider={dataProvider}>
<ListBase resource="books" perPage={5} exporter={exporter}>
<ListView />
<BulkExportButton exporter={hookExporter} />
</ListBase>
</CoreAdminContext>
);

const BulkExportButton = ({ exporter }: UseBulkExportOptions) => {
const bulkExport = useBulkExport({ exporter });
return <button onClick={bulkExport}>Export</button>;
};
52 changes: 52 additions & 0 deletions packages/ra-core/src/export/useBulkExport.ts
Original file line number Diff line number Diff line change
@@ -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<RecordType extends RaRecord = any>(
options: UseBulkExportOptions<RecordType> = {}
): UseBulkExportResult {
const { exporter: customExporter, meta } = options;

const resource = useResourceContext(options);
const { exporter: exporterFromContext, selectedIds } =
useListContext<RecordType>();
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<RecordType extends RaRecord = any> {
exporter?: Exporter<RecordType>;
meta?: any;
resource?: string;
}
47 changes: 13 additions & 34 deletions packages/ra-ui-materialui/src/button/BulkExportButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand All @@ -104,17 +86,14 @@ const sanitizeRestProps = ({
...rest
}: Omit<BulkExportButtonProps, 'exporter' | 'label' | 'meta'>) => 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, {
Expand Down
Loading