Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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/controller/list/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './ListIterator';
export * from './ListPaginationContext';
export * from './ListSortContext';
export * from './queryReducer';
export * from '../../export/useBulkExport';
export * from './useExpanded';
export * from './useInfiniteListController';
export * from './useInfinitePaginationContext';
Expand Down
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