Skip to content

Commit

Permalink
Merge pull request #2985 from marmelab/next
Browse files Browse the repository at this point in the history
Prepare 2.8.0
  • Loading branch information
fzaninotto authored Mar 12, 2019
2 parents dda897f + a4dc65a commit 5d2a88d
Show file tree
Hide file tree
Showing 35 changed files with 1,781 additions and 597 deletions.
462 changes: 285 additions & 177 deletions docs/Actions.md

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions docs/CreateEdit.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Here are all the props accepted by the `<Create>` and `<Edit>` components:
* [`title`](#page-title)
* [`actions`](#actions)
* [`aside`](#aside-component)
* [`undoable`](#undoable) (`<Edit>` only)

Here is the minimal code necessary to display a form to create and edit comments:

Expand Down Expand Up @@ -176,6 +177,53 @@ const Aside = ({ record }) => (
**Tip**: Always test that the `record` is defined before using it, as react-admin starts rendering the UI before the API call is over.
### Undoable
By default, the Save and Delete actions are undoable, i.e. react-admin only sends the related request to the data provider after a short delay, during which the user can cancel the action. This is part of the "optimistic rendering" strategy of react-admin ; it makes the user interactions more reactive.
You can disable this behavior by setting `undoable={false}`. With that setting, clicking on the Delete button displays a confirmation dialog. Both the Save and the Delete actions become blocking, and delay the refresh of the screen until the data provider responds.
```jsx
const PostEdit = props => (
<Edit undoable={false} {...props}>
...
</Edit>
```
**Tip**: If you want a confirmation dialog for the Delete button but don't mind undoable Edits, then pass a [custom toolbar](#toolbar) to the form, as follows:
```jsx
import {
Toolbar,
SaveButton,
DeleteButton,
Edit,
SimpleForm,
} from 'react-admin';
import { withStyles } from '@material-ui/core';

const toolbarStyles = {
toolbar: {
display: 'flex',
justifyContent: 'space-between',
},
};

const CustomToolbar = withStyles(toolbarStyles)(props => (
<Toolbar {...props}>
<SaveButton />
<DeleteButton undoable={false} />
</Toolbar>
));

const PostEdit = props => (
<Edit {...props}>
<SimpleForm toolbar={<CustomToolbar />}>
...
</SimpleForm>
</Edit>
```
## Prefilling a `<Create>` Record
You may need to prepopulate a record based on another one. For that use case, use the `<CloneButton>` component. It expects a `record` and a `basePath` (usually injected to children of `<Datagrid>`, `<SimpleForm>`, `<SimpleShowLayout>`, etc.), so it's as simple to use as a regular field or input.
Expand Down
4 changes: 2 additions & 2 deletions docs/Inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -993,10 +993,10 @@ const FullNameField = ({ record }) => <span>{record.first_name} {record.last_nam
<SelectInput source="gender" choices={choices} optionText={<FullNameField />}/>
```
Enabling the `allowEmpty` props adds an empty choice (with `null` value) on top of the options, and makes the value nullable:
Enabling the `allowEmpty` props adds an empty choice (with a default `null` value, which you can overwrite with the `emptyValue` prop) on top of the options, and makes the value nullable:
```jsx
<SelectInput source="category" allowEmpty choices={[
<SelectInput source="category" allowEmpty emptyValue="" choices={[
{ id: 'programming', name: 'Programming' },
{ id: 'lifestyle', name: 'Lifestyle' },
{ id: 'photography', name: 'Photography' },
Expand Down
24 changes: 18 additions & 6 deletions docs/_layouts/default.html
Original file line number Diff line number Diff line change
Expand Up @@ -709,31 +709,43 @@

<li class="chapter {% if page.path == 'Actions.md' %}active{% endif %}">
<a href="./Actions.html">
<b>14.</b> Writing Actions
<b>14.</b> Querying the API
</a>
<ul class="articles" {% if page.path !='Actions.md' %}style="display:none" {% endif %}>
<li class="chapter">
<a href="#the-simple-way">The Simple Way</a>
<a href="#the-basic-way-using-fetch">The Basic Way: Using <code>fetch</code></a>
</li>
<li class="chapter">
<a href="#using-a-data-provider-instead-of-fetch">Using a Data Provider</a>
<a href="#using-the-data-provider-instead-of-fetch">Using the <code>dataProvider</code></a>
</li>
<li class="chapter">
<a href="#using-a-custom-action-creator">Using a Custom Action Creator</a>
<a href="#using-the-withdataprovider-decorator">Using <code>withDataProvider</code></a>
</li>
<li class="chapter">
<a href="#handling-side-effects">Handling Side Effects</a>
</li>
<li class="chapter">
<a href="#success-and-failure-side-effects">Success and Failure Side Effects</a>
<a href="#optimistic-rendering-and-undo">Optimistic Rendering and Undo</a>
</li>
<li class="chapter">
<a href="#optimistic-rendering-and-undo">Optimistic Rendering and Undo</a>
<a href="#query-and-mutation-components"><code>&lt;Query&gt;</code> and <code>&lt;Mutation&gt;</code></a>
</li>
<li class="chapter">
<a href="#using-a-custom-action-creator">Using a Custom Action Creator</a>
</li>
<li class="chapter">
<a href="#adding-side-effects-to-actions">Adding Side Effects to Actions</a>
</li>
<li class="chapter">
<a href="#making-an-action-undoable">Undoable Action</a>
</li>
<li class="chapter">
<a href="#altering-the-form-values-before-submitting">Altering the Form Values before
Submitting</a>
</li>
<li class="chapter">
<a href="#custom-side-effects">Custom Side Effects</a>
</li>
<li class="chapter">
<a href="#custom-sagas">Custom Sagas</a>
</li>
Expand Down
224 changes: 115 additions & 109 deletions examples/demo/src/dashboard/Dashboard.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import React, { Component } from 'react';
import { GET_LIST, GET_MANY, Responsive } from 'react-admin';
import { GET_LIST, GET_MANY, Responsive, withDataProvider } from 'react-admin';
import compose from 'recompose/compose';
import { connect } from 'react-redux';

import Welcome from './Welcome';
import MonthlyRevenue from './MonthlyRevenue';
import NbNewOrders from './NbNewOrders';
import PendingOrders from './PendingOrders';
import PendingReviews from './PendingReviews';
import NewCustomers from './NewCustomers';
import dataProviderFactory from '../dataProvider';

const styles = {
flex: { display: 'flex' },
Expand All @@ -21,118 +22,116 @@ class Dashboard extends Component {
state = {};

componentDidMount() {
this.fetchData();
}

componentDidUpdate(prevProps) {
// handle refresh
if (this.props.version !== prevProps.version) {
this.fetchData();
}
}

fetchData() {
this.fetchOrders();
this.fetchReviews();
this.fetchCustomers();
}

async fetchOrders() {
const { dataProvider } = this.props;
const aMonthAgo = new Date();
aMonthAgo.setDate(aMonthAgo.getDate() - 30);
const { data: recentOrders } = await dataProvider(
GET_LIST,
'commands',
{
filter: { date_gte: aMonthAgo.toISOString() },
sort: { field: 'date', order: 'DESC' },
pagination: { page: 1, perPage: 50 },
}
);
const aggregations = recentOrders
.filter(order => order.status !== 'cancelled')
.reduce(
(stats, order) => {
if (order.status !== 'cancelled') {
stats.revenue += order.total;
stats.nbNewOrders++;
}
if (order.status === 'ordered') {
stats.pendingOrders.push(order);
}
return stats;
},
{
revenue: 0,
nbNewOrders: 0,
pendingOrders: [],
}
);
this.setState({
revenue: aggregations.revenue.toLocaleString(undefined, {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}),
nbNewOrders: aggregations.nbNewOrders,
pendingOrders: aggregations.pendingOrders,
});
const { data: customers } = await dataProvider(GET_MANY, 'customers', {
ids: aggregations.pendingOrders.map(order => order.customer_id),
});
this.setState({
pendingOrdersCustomers: customers.reduce((prev, customer) => {
prev[customer.id] = customer; // eslint-disable-line no-param-reassign
return prev;
}, {}),
});
}

dataProviderFactory(process.env.REACT_APP_DATA_PROVIDER).then(
dataProvider => {
dataProvider(GET_LIST, 'commands', {
filter: { date_gte: aMonthAgo.toISOString() },
sort: { field: 'date', order: 'DESC' },
pagination: { page: 1, perPage: 50 },
})
.then(response =>
response.data
.filter(order => order.status !== 'cancelled')
.reduce(
(stats, order) => {
if (order.status !== 'cancelled') {
stats.revenue += order.total;
stats.nbNewOrders++;
}
if (order.status === 'ordered') {
stats.pendingOrders.push(order);
}
return stats;
},
{
revenue: 0,
nbNewOrders: 0,
pendingOrders: [],
}
)
)
.then(({ revenue, nbNewOrders, pendingOrders }) => {
this.setState({
revenue: revenue.toLocaleString(undefined, {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}),
nbNewOrders,
pendingOrders,
});
return pendingOrders;
})
.then(pendingOrders =>
pendingOrders.map(order => order.customer_id)
)
.then(customerIds =>
dataProvider(GET_MANY, 'customers', {
ids: customerIds,
})
)
.then(response => response.data)
.then(customers =>
customers.reduce((prev, customer) => {
prev[customer.id] = customer; // eslint-disable-line no-param-reassign
return prev;
}, {})
)
.then(customers =>
this.setState({ pendingOrdersCustomers: customers })
);

dataProvider(GET_LIST, 'reviews', {
filter: { status: 'pending' },
sort: { field: 'date', order: 'DESC' },
pagination: { page: 1, perPage: 100 },
})
.then(response => response.data)
.then(reviews => {
const nbPendingReviews = reviews.reduce(nb => ++nb, 0);
const pendingReviews = reviews.slice(
0,
Math.min(10, reviews.length)
);
this.setState({ pendingReviews, nbPendingReviews });
return pendingReviews;
})
.then(reviews => reviews.map(review => review.customer_id))
.then(customerIds =>
dataProvider(GET_MANY, 'customers', {
ids: customerIds,
})
)
.then(response => response.data)
.then(customers =>
customers.reduce((prev, customer) => {
prev[customer.id] = customer; // eslint-disable-line no-param-reassign
return prev;
}, {})
)
.then(customers =>
this.setState({ pendingReviewsCustomers: customers })
);
async fetchReviews() {
const { dataProvider } = this.props;
const { data: reviews } = await dataProvider(GET_LIST, 'reviews', {
filter: { status: 'pending' },
sort: { field: 'date', order: 'DESC' },
pagination: { page: 1, perPage: 100 },
});
const nbPendingReviews = reviews.reduce(nb => ++nb, 0);
const pendingReviews = reviews.slice(0, Math.min(10, reviews.length));
this.setState({ pendingReviews, nbPendingReviews });
const { data: customers } = await dataProvider(GET_MANY, 'customers', {
ids: pendingReviews.map(review => review.customer_id),
});
this.setState({
pendingReviewsCustomers: customers.reduce((prev, customer) => {
prev[customer.id] = customer; // eslint-disable-line no-param-reassign
return prev;
}, {}),
});
}

dataProvider(GET_LIST, 'customers', {
filter: {
has_ordered: true,
first_seen_gte: aMonthAgo.toISOString(),
},
sort: { field: 'first_seen', order: 'DESC' },
pagination: { page: 1, perPage: 100 },
})
.then(response => response.data)
.then(newCustomers => {
this.setState({ newCustomers });
this.setState({
nbNewCustomers: newCustomers.reduce(nb => ++nb, 0),
});
});
async fetchCustomers() {
const { dataProvider } = this.props;
const aMonthAgo = new Date();
aMonthAgo.setDate(aMonthAgo.getDate() - 30);
const { data: newCustomers } = await dataProvider(
GET_LIST,
'customers',
{
filter: {
has_ordered: true,
first_seen_gte: aMonthAgo.toISOString(),
},
sort: { field: 'first_seen', order: 'DESC' },
pagination: { page: 1, perPage: 100 },
}
);
this.setState({
newCustomers,
nbNewCustomers: newCustomers.reduce(nb => ++nb, 0),
});
}

render() {
Expand Down Expand Up @@ -222,4 +221,11 @@ class Dashboard extends Component {
}
}

export default Dashboard;
const mapStateToProps = state => ({
version: state.admin.ui.viewVersion,
});

export default compose(
connect(mapStateToProps),
withDataProvider
)(Dashboard);
Loading

0 comments on commit 5d2a88d

Please sign in to comment.