Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFR] Add Export feature #2034

Merged
merged 18 commits into from Jul 19, 2018
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
106 changes: 103 additions & 3 deletions docs/List.md
Expand Up @@ -19,6 +19,7 @@ Here are all the props accepted by the `<List>` component:

* [`title`](#page-title)
* [`actions`](#actions)
* [`exporter`](#exporter)
* [`bulkActions`](#bulk-actions)
* [`filters`](#filters) (a React element used to display the filter form)
* [`perPage`](#records-per-page)
Expand Down Expand Up @@ -85,9 +86,18 @@ You can replace the list of default actions by your own element using the `actio

```jsx
import Button from '@material-ui/core/Button';
import { CardActions, CreateButton, RefreshButton } from 'react-admin';

const PostActions = ({ resource, filters, displayedFilters, filterValues, basePath, showFilter }) => (
import { CardActions, CreateButton, ExportButton, RefreshButton } from 'react-admin';

const PostActions = ({
basePath,
currentSort,
displayedFilters,
exporter,
filters,
filterValues,
resource,
showFilter
}) => (
<CardActions>
{filters && React.cloneElement(filters, {
resource,
Expand All @@ -97,6 +107,12 @@ const PostActions = ({ resource, filters, displayedFilters, filterValues, basePa
context: 'button',
}) }
<CreateButton basePath={basePath} />
<ExportButton
resource={resource}
sort={currentSort}
filter={filterValues}
exporter={exporter}
/>
<RefreshButton />
{/* Add your custom actions */}
<Button primary onClick={customAction}>Custom Action</Button>
Expand All @@ -110,6 +126,90 @@ export const PostList = (props) => (
);
```

You can also use such a custom `ListActions` prop to omit or reorder buttons based on permissions. Just pass the `permisisons` down from the `List` component:

```jsx
export const PostList = ({ permissions, ...props }) => (
<List {...props} actions={<PostActions permissions={permissions} />}>
...
</List>
);
```

### Exporter

Among the default list actions, react-admin includes an `<ExportButton>`. By default, clicking this button will:

1. Call the `dataProvider` with the current sort and filter (but without pagination),
2. Transform the result into a CSV string,
3. Download the CSV file.

The columns of the CSV file match all the fields of the records in the `dataProvider` response. That means that the export doesn't take into account the selection and ordering of fields in your `<List>` via `Field` components. If you want to customize the result, pass a custom `exporter` function to the `<List>`. This function will receive the data from the `dataProvider` (after step 1), and replace steps 2-3 (i.e. it's in charge of transforming, converting, and downloading the file).

**Tip**: For CSV conversion, you can import [Papaparse](https://www.papaparse.com/), a CSV parser and stringifier which is already a react-admin dependency. And for CSV download, take advantage of react-admin's `downloadCSV` function.

Here is an example for a Posts exporter, omitting, adding, and reordering fields:

```jsx
// in PostList.js
import { List, downloadCSV } from 'react-admin';
import { unparse as convertToCSV } from 'papaparse/papaparse.min';

const exporter = (data) => data.map(post => {
const { postForExport, backlinks, author } = post; // omit backlinks and author
postForExport.author_name = post.author.name; // add a field
const csv = convertToCSV({
data: postForExport,
fields: ['id', 'title', 'author_name', 'body'] // order fields in the export
});
downloadCSV(csv, 'posts'); // download as 'posts.csv` file
})

const PostList = props => (
<List {...props} exporter={exporter}>
...
</List>
)
```

In many cases, you'll need more than simple object manipulation. You'll need to *augment* your objects based on relationships. For instance, the export for comments should include the title of the related post - but the export only exposes a `post_id` by default. For that purpose, the exporter receives a `fetchRelatedRecords` function as second parameter. It fetches related records using your `dataProvider` and Redux, and returns a promise.

Here is an example for a Comments exporter, fetching related Posts:

```jsx
// in CommentList.js
import { List, downloadCSV } from 'react-admin';
import { unparse as convertToCSV } from 'papaparse/papaparse.min';

const exporter = (records, fetchRelatedRecords) => {
fetchRelatedRecords(records, 'post_id', 'posts').then(posts => {
const data = posts.map(record => ({
...record,
post_title: posts[record.post_id].title,
}));
const csv = convertToCSV({
data,
fields: ['id', 'post_id', 'post_title', 'body'],
});
downloadCSV(csv, 'comments');
});
};

const CommentList = props => (
<List {...props} exporter={exporter}>
...
</List>
)
```

Under the hood, `fetchRelatedRecords()` uses react-admin's sagas, which trigger the loading spinner while loading. As a bonus, all the records fetched during an export are kepts in the main Redux store, so further browsing the admin will be accelerated.

**Tip**: If you need to call another `dataProvider` verb in the exporter, take advantage of the third parameter passed to the function: `dispatch()`. It allows you to call any Redux action. Combine it with [the `callback` side effect](./Actions.html#custom-sagas) to grab the result in a callback.

**Tip**: The `<ExportButton>` limits the main request to the `dataProvider` to 1,000 records. If you want to increase or decrease this limit, pass a `maxResults` prop to the `<ExportButton>` in a custom `<ListActions>` component, as explained in the previous section.

**Tip**: For complex (or large) exports, fetching all the related records and assembling them client-side can be slow. In that case, create the CSV on the server side, and replace the `<ExportButton>` component by a custom one, fetching the CSV route.

### Bulk Actions

Bulk actions are actions that affect several records at once, like mass deletion for instance. In the `<Datagrid>` component, bulk actions are triggered by ticking the checkboxes in the first column of the table, then choosing an action from the bulk action menu. By default, all list views have a single bulk action, the bulk delete action. You can add other bulk actions by passing a custom element as the `bulkActions` prop of the `<List>` component:
Expand Down
25 changes: 24 additions & 1 deletion examples/simple/src/comments/CommentList.js
@@ -1,3 +1,4 @@
import React from 'react';
import ChevronLeft from '@material-ui/icons/ChevronLeft';
import ChevronRight from '@material-ui/icons/ChevronRight';
import PersonIcon from '@material-ui/icons/Person';
Expand All @@ -10,7 +11,7 @@ import CardHeader from '@material-ui/core/CardHeader';
import Grid from '@material-ui/core/Grid';
import Toolbar from '@material-ui/core/Toolbar';
import { withStyles } from '@material-ui/core/styles';
import React from 'react';
import { unparse as convertToCSV } from 'papaparse/papaparse.min';
import {
DateField,
EditButton,
Expand All @@ -23,6 +24,7 @@ import {
ShowButton,
SimpleList,
TextField,
downloadCSV,
translate,
} from 'react-admin'; // eslint-disable-line import/no-unresolved

Expand All @@ -34,6 +36,26 @@ const CommentFilter = props => (
</Filter>
);

const exporter = (records, fetchRelatedRecords) => {
fetchRelatedRecords(records, 'post_id', 'posts').then(posts => {
const data = records.map(record => {
const { author, ...recordForExport } = record; // omit author
recordForExport.author_name = author.name;
recordForExport.post_title = posts[record.post_id].title;
return recordForExport;
});
const fields = [
'id',
'author_name',
'post_id',
'post_title',
'created_at',
'body',
];
downloadCSV(convertToCSV({ data, fields }), 'comments');
});
};

const CommentPagination = translate(
({ page, perPage, total, setPage, translate }) => {
const nbPages = Math.ceil(total / perPage) || 1;
Expand Down Expand Up @@ -162,6 +184,7 @@ const CommentList = props => (
<List
{...props}
perPage={6}
exporter={exporter}
filters={<CommentFilter />}
pagination={<CommentPagination />}
>
Expand Down
23 changes: 23 additions & 0 deletions packages/ra-core/src/actions/dataActions.js
Expand Up @@ -30,6 +30,29 @@ export const crudGetList = (resource, pagination, sort, filter) => ({
},
});

export const CRUD_GET_ALL = 'RA/CRUD_GET_ALL';
export const CRUD_GET_ALL_LOADING = 'RA/CRUD_GET_ALL_LOADING';
export const CRUD_GET_ALL_FAILURE = 'RA/CRUD_GET_ALL_FAILURE';
export const CRUD_GET_ALL_SUCCESS = 'RA/CRUD_GET_ALL_SUCCESS';

export const crudGetAll = (resource, sort, filter, maxResults, callback) => ({
type: CRUD_GET_ALL,
payload: { sort, filter, pagination: { page: 1, perPage: maxResults } },
meta: {
resource,
fetch: GET_LIST,
onSuccess: {
callback,
},
onFailure: {
notification: {
body: 'ra.notification.http_error',
level: 'warning',
},
},
},
});

export const CRUD_GET_ONE = 'RA/CRUD_GET_ONE';
export const CRUD_GET_ONE_LOADING = 'RA/CRUD_GET_ONE_LOADING';
export const CRUD_GET_ONE_FAILURE = 'RA/CRUD_GET_ONE_FAILURE';
Expand Down
1 change: 1 addition & 0 deletions packages/ra-language-english/index.js
Expand Up @@ -10,6 +10,7 @@ module.exports = {
create: 'Create',
delete: 'Delete',
edit: 'Edit',
export: 'Export',
list: 'List',
refresh: 'Refresh',
remove_filter: 'Remove this filter',
Expand Down
1 change: 1 addition & 0 deletions packages/ra-language-french/index.js
Expand Up @@ -11,6 +11,7 @@ module.exports = {
create: 'Créer',
delete: 'Supprimer',
edit: 'Éditer',
export: 'Exporter',
list: 'Liste',
refresh: 'Actualiser',
remove_filter: 'Supprimer ce filtre',
Expand Down
1 change: 1 addition & 0 deletions packages/ra-ui-materialui/package.json
Expand Up @@ -36,6 +36,7 @@
"classnames": "~2.2.5",
"inflection": "~1.12.0",
"lodash": "~4.17.5",
"papaparse": "^4.1.4",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ spotted

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on purpose

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh ?

"prop-types": "~15.6.1",
"ra-core": "^2.1.0",
"react-autosuggest": "^9.3.2",
Expand Down
124 changes: 124 additions & 0 deletions packages/ra-ui-materialui/src/button/ExportButton.js
@@ -0,0 +1,124 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import GetApp from '@material-ui/icons/GetApp';
import { crudGetAll, CRUD_GET_MANY, GET_MANY } from 'ra-core';
import { unparse as convertToCSV } from 'papaparse/papaparse.min';

import Button from './Button';
import downloadCSV from '../util/downloadCSV';

const sanitizeRestProps = ({
basePath,
crudGetAll,
dispatch,
exporter,
filter,
maxResults,
resource,
sort,
...rest
}) => rest;

/**
* Helper function for calling the data provider with GET_MANY
* via redux and saga, and getting a Promise in return
*
* @example
* fetchRelatedRecords(records, 'post_id', 'posts').then(posts =>
* posts.map(record => ({
* ...record,
* post_title: posts[record.post_id].title,
* }));
*/
const fetchRelatedRecords = dispatch => (data, field, resource) =>
new Promise((resolve, reject) => {
// find unique keys
const ids = [...new Set(data.map(record => record[field]))];
dispatch({
type: CRUD_GET_MANY,
payload: { ids },
meta: {
resource,
fetch: GET_MANY,
onSuccess: {
callback: ({ payload: { data } }) => {
resolve(
data.reduce((acc, post) => {
acc[post.id] = post;
return acc;
}, {})
);
},
},
onFailure: {
notification: {
body: 'ra.notification.http_error',
level: 'warning',
},
callback: ({ error }) => reject(error),
},
},
});
});

class ExportButton extends Component {
static propTypes = {
basePath: PropTypes.string,
dispatch: PropTypes.func,
exporter: PropTypes.func,
filter: PropTypes.object,
label: PropTypes.string,
maxResults: PropTypes.number.isRequired,
resource: PropTypes.string.isRequired,
sort: PropTypes.object,
};

handleClick = () => {
const {
dispatch,
exporter,
filter,
maxResults,
sort,
resource,
} = this.props;
dispatch(
crudGetAll(
resource,
sort,
filter,
maxResults,
({ payload: { data } }) =>
exporter
? exporter(
data,
fetchRelatedRecords(dispatch),
dispatch
)
: downloadCSV(resource)(convertToCSV(data))
)
);
};

render() {
const { label, ...rest } = this.props;

return (
<Button
onClick={this.handleClick}
label={label}
{...sanitizeRestProps(rest)}
>
<GetApp />
</Button>
);
}
}

ExportButton.defaultProps = {
label: 'ra.action.export',
maxResults: 1000,
};

export default connect()(ExportButton); // inject redux dispatch
1 change: 1 addition & 0 deletions packages/ra-ui-materialui/src/button/index.js
Expand Up @@ -3,6 +3,7 @@ export CloneButton from './CloneButton';
export CreateButton from './CreateButton';
export DeleteButton from './DeleteButton';
export EditButton from './EditButton';
export ExportButton from './ExportButton';
export ListButton from './ListButton';
export SaveButton from './SaveButton';
export ShowButton from './ShowButton';
Expand Down
1 change: 1 addition & 0 deletions packages/ra-ui-materialui/src/index.js
Expand Up @@ -6,5 +6,6 @@ export * from './field';
export * from './input';
export * from './layout';
export * from './list';
export * from './util';
export Link from './Link';
export defaultTheme from './defaultTheme';