React Admin Advanced Recipes: Creating and Editing a Record From the List Page

Gildas Garcia
Gildas GarciaFebruary 07, 2019
#react-admin#react#tutorial

This is the third article in the series of advanced tutorials for React-admin. It assumes you already understand the basics of react-admin and have at least completed the official tutorial.

In the last article, we saw how to have a create, show or edit view of a referenced resource inside a modal or a sliding side panel.

In this article, I'll explain how to have a create, show or edit view inside a modal or a side panel from the List page.

Why would we do that? Let's assume this is the administration of a blog which has posts on which the users assign tags and that there are not many tags. The creation and edition forms for tags are really small and probably don't need a full page. Besides, it would be useful to keep the list of current tags visible while creating and editing. Indeed, the users would then immediately see why they have a duplicated tag error when they try to enter a new tag with an already used name.

This is the expected result:

Edit and create forms inside a drawer with animations

I will work on a fork of the codesandbox that was built in the last article.

Preparation

First, I create simple components for the list and create pages of the tags resource, and export them both:

// in tags/TagList.js
import React from 'react';
import { Datagrid, List, TextField } from 'react-admin';

const TagList = ({ classes, ...props }) => (
    <List {...props} sort={{ field: 'name', order: 'ASC' }}>
        <Datagrid>
            <TextField source="name" />
        </Datagrid>
    </List>
);

export default TagList;

// in tags/TagCreate.js
import React from 'react';
import { Create, TextInput, SimpleForm, required } from 'react-admin';

const TagCreate = props => (
    <Create {...props}>
        <SimpleForm>
            <TextInput source="name" validate={required()} />
        </SimpleForm>
    </Create>
);

export default TagCreate;

// in tags/index.js
import TagList from './TagList';
import TagCreate from './TagCreate';

export default {
    list: TagList,
    create: TagCreate
};

Then, I add theses components to the tags <Resource>:

// in index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Admin, Resource } from 'react-admin';

import dataProvider from './dataProvider';
import posts from './posts';
import comments from './comments';
+import tags from './tags';

import './styles.css';

const App = () => (
    <Admin dataProvider={dataProvider}>
        <Resource name="posts" {...posts} />
        <Resource name="comments" {...comments} />
-       <Resource name="tags" />
+       <Resource name="tags" {...tags} />
    </Admin>
);

const rootElement = document.getElementById('root');
ReactDOM.render(<App />, rootElement);

Using the object spread operator on {...tags} is just like writing <Resource name="tags" list={TagList} create={TagCreate} />, except it's shorter.

I now have a classic react-admin flow to view and create my tags.

Classic React-admin

Placing the Create Form Inside The List

Let's move the create form to the List page!

// in tags/TagList.js
import React from 'react';
import { Datagrid, List, TextField } from 'react-admin';
+import { Route } from 'react-router';
+import TagCreate from './TagCreate';

const TagList = props => (
+   <React.Fragment>
        <List {...props} sort={{ field: 'name', order: 'ASC' }}>
            <Datagrid>
                <TextField source="name" />
            </Datagrid>
        </List>
+       <Route
+           path="/tags/create"
+           render={() => (
+               <TagCreate {...props} />
+           )}
+       />
+   </React.Fragment>
);

export default TagList;

This won't work immediately because there are now two competing components for the same /tags/create route: the one I just created in <TagList>, and the one in <Resource>. When I added the create prop to the <Resource name="tags"> component earlier, react-admin created a <Route path="/tags/create"> under the hood. So I must prevent the Resource component to handle the create route:

// in tags/index.js
import TagList from './TagList';
-import TagCreate from './TagCreate';

export default {
    list: TagList,
-   create: TagCreate
};

It kinda works already as I can see the creation form below the list when I enter the /#/tags/create url. However, as react-admin is not aware of the create component anymore, it does not include the CreateButton on the list. Let's fix that:

import React from 'react';
-import { Datagrid, List, TextField } from 'react-admin';
+import { Datagrid, List, TextField, CardActions, CreateButton } from 'react-admin';
import { Route } from 'react-router';
import TagCreate from './TagCreate';

+const TagListActions = ({ basePath }) => (
+   <CardActions>
+       <CreateButton basePath={basePath} />
+   </CardActions>
+);

const TagList = props => (
    <React.Fragment>
-       <List {...props} sort={{ field: 'name', order: 'ASC' }}>
+       <List {...props} sort={{ field: 'name', order: 'ASC' }} actions={<TagListActions />}>
            <Datagrid>
                <TextField source="name" />
            </Datagrid>
        </List>
        <Route
            path="/tags/create"
            render={() => (
                <TagCreate {...props} />
            )}
        />
    </React.Fragment>
);

export default TagList;

Create form below the Datagrid

Placing the Create Form Inside a Drawer

Now I have to wrap the creation form inside a Drawer. I chose a drawer here because a dialog would appear on top of the list page and hide at least some of the tags.

I just need to wrap the <TagCreate> component with a material-ui <Drawer> component:

+import { Drawer } from '@material-ui/core';

const TagList = props => (
    <Fragment>
        <List
            {...props}
            sort={{ field: 'name', order: 'ASC' }}
            actions={<TagListActions />}
        >
            <Datagrid>
                <TextField source="name" />
            </Datagrid>
        </List>
        <Route
            path="/tags/create"
            render={() => (
+               <Drawer open>
                    <TagCreate {...props} />
+               </Drawer>
            )}
        />
    </Fragment>
);

It works, and if I enter a tag name then save it, it actually closes the drawer! That's because the <Create> component redirects to the list page by default.

Create form inside a drawer

Handling Cancellation

Currently, I can't close the drawer by myself if I want to cancel. Let's fix that!

First, I have to make the <TagList> a class component and add a handleClose method. I'll then use this method to handle the onClose event of the Drawer component. This will allow me to close it using the Escape key or by clicking the background overlay.

The handleClose method will dispatch the react-router-redux push action to redirect to the list page:

// in tags/TagList.js
import React, { Fragment } from "react";
import { connect } from "react-redux";
import { push } from "react-router-redux";

import {
  Datagrid,
  List,
  TextField,
  CardActions,
  CreateButton,
} from "react-admin";
import { Route } from "react-router";
import { Drawer } from "@material-ui/core";
import TagCreate from "./TagCreate";

const TagListActions = ({ basePath }) => (
  <CardActions>
    <CreateButton basePath={basePath} />
  </CardActions>
);

class TagList extends React.Component {
  render() {
    const props = this.props;
    return (
      <Fragment>
        <List
          {...props}
          sort={{ field: "name", order: "ASC" }}
          actions={<TagListActions />}
        >
          <Datagrid>
            <TextField source="name" />
          </Datagrid>
        </List>
        <Route
          path="/tags/create"
          render={() => (
            <Drawer open onClose={this.handleClose}>
              <TagCreate {...props} />
            </Drawer>
          )}
        />
      </Fragment>
    );
  }

  handleClose = () => {
    this.props.push("/tags");
  };
}

export default connect(
  undefined,
  { push }
)(TagList);

That's nice but not very discoverable. It would be better to also include a cancel button. To do that, I override the <TagCreate> component toolbar:

import React from 'react';
import { Create, TextInput, SimpleForm, required, SaveButton, Toolbar, translate } from 'react-admin';
+import Button from '@material-ui/core/Button';

+const TagCreateToolbar = translate(({ onCancel, translate, ...props }) => (
+   <Toolbar {...props}>
+       <SaveButton />
+       <Button onClick={onCancel}>{translate('ra.action.cancel')}</Button>
+   </Toolbar>
+));

-const TagCreate = props => (
+const TagCreate = ({ onCancel, ...props }) => (
    <Create {...props}>
-       <SimpleForm>
+       <SimpleForm toolbar={<TagCreateToolbar onCancel={onCancel} />}>
            <TextInput source="name" validate={required()} />
        </SimpleForm>
    </Create>
);

export default TagCreate;

Now I can pass the handleClose to the <TagCreate> component:

<TagCreate onCancel={this.handleClose} {...props} />

And voila!

Create form inside a drawer with a cancel button

Adding Polish

Acute observers may notice two small issues though.

The first one is the page title, which can be seen behind the background overlay. It contains the concatenation of the List and Create pages titles.

It's due to the way <List> and <Create> set the page title - by appending a string to the page title portal. As both <List> and <Create> render on the same page, the resulting title doesn't make sense. I can fix it by setting the <Create> page title to an empty string:

const TagCreate = ({ onCancel, ...props }) => (
-   <Create {...props}>
+   <Create title=" " {...props}>
        <SimpleForm toolbar={<TagCreateToolbar onCancel={onCancel} />}>
            <TextInput source="name" validate={required()} />
        </SimpleForm>
    </Create>
);

export default TagCreate;

The second issue is that the Drawer open and close sequences are not animated.

What happens is that the Drawer component is only mounted when the create route matches. To fix it, I must always mount this component but only set its open prop to true when the route matches. Fortunately, react-router supports this scenario. All it takes is to move the content of the route render prop inside the children prop and check whether there is a match:

<Route path="/tags/create">
  {({ match }) => (
    <Drawer open={!!match} anchor="right" onClose={this.handleClose}>
      <TagCreate onCancel={this.handleClose} {...props} />
    </Drawer>
  )}
</Route>

This results in a beautifully animated creation process:

Create form inside a drawer with a cancel button with animations

Let's do the same for the tags edition!

Quick Edit Inside a Drawer From the List Page

I add the following route to the <TagList> component, just below the create route:

<Route path="/tags/:id">
  {({ match }) => {
    const isMatch = match && match.params && match.params.id !== "create";

    return (
      <Drawer open={isMatch} anchor="right" onClose={this.handleClose}>
        {/* To avoid any errors if the route does not match, we don't render at all the component in this case */}
        {isMatch ? (
          <TagEdit
            id={match.params.id}
            onCancel={this.handleClose}
            {...props}
          />
        ) : null}
      </Drawer>
    );
  }}
</Route>

Edit and create forms inside a drawer with animations

Almost there. You can probably see that while I do have open and close animations for the <Drawer> in creation, I only have the open animation in edition.

This is because I don't render the <TargetEdit> when there is no match, which happens when I redirect to the list page. With no content, the <Drawer> is empty and has a zero width. The easy fix for this would be to force the width of the <Drawer> content:

-import { Drawer } from '@material-ui/core';
+import { Drawer, withStyles } from '@material-ui/core';
+import compose from 'recompose/compose';

+const styles = {
+    drawerContent: {
+        width: 300
+    }
+};

class TagList extends React.Component {
    render() {
        const { push, classes, ...props } = this.props;
        return (
            <Fragment>
                <List
                    {...props}
                    sort={{ field: 'name', order: 'ASC' }}
                    actions={<TagListActions />}
                >
                    <Datagrid>
                        <TextField source="name" />
                        <EditButton />
                    </Datagrid>
                </List>
                <Route path="/tags/create">
                    {({ match }) => (
                        <Drawer
                            open={!!match}
                            anchor="right"
                            onClose={this.handleClose}
                        >
                            <TagCreate
+                               className={classes.drawerContent}
                                onCancel={this.handleClose}
                                {...props}
                            />
                        </Drawer>
                    )}
                </Route>
                <Route path="/tags/:id">
                    {({ match }) => {
                        const isMatch =
                            match &&
                            match.params &&
                            match.params.id !== 'create';

                        return (
                            <Drawer
                                open={isMatch}
                                anchor="right"
                                onClose={this.handleClose}
                            >
                                {isMatch ? (
                                    <TagEdit
+                                       className={classes.drawerContent}
                                        id={isMatch ? match.params.id : null}
                                        onCancel={this.handleClose}
                                        {...props}
                                    />
-                               ) : null}
+                               ) : (
+                                   <div className={classes.drawerContent} />
+                               )}
                            </Drawer>
                        );
                    }}
                </Route>
            </Fragment>
        );
    }
}

+export default compose(
    connect(
        undefined,
        { push }
    ),
+    withStyles(styles)
)(TagList);

This is the end result:

Edit and create forms inside a drawer with animations

NOTE: If you know react-router, you might wonder why I didn't use a <Switch> component, and instead manually checked that the route parameter id is not create. That's because it would break the animations again. Indeed, the <Switch> component will only render the route that matches.

Conclusion

I hope this tutorial has shown how easy customizing react-admin can be. The only difficulty here was actually to understand how to make react-router and material-ui work together.

Note that we could have used a Dialog component with almost the same code.

You can find the complete code in this codesandbox.

Did you like this article? Share it!