React Admin Advanced Recipes - Adding Related Records Inline With Custom Forms

Gildas Garcia
Gildas GarciaAugust 27, 2018
#react-admin#react#tutorial#popular

This is the second article in a new 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 add a button on a show or edit page to create a new resource related to the one displayed. Our use case was to create a new comment from the post show or edit pages. The New Comment button redirected to the comments create page with default values parsed from the url.

In this article, I'll explain how to have a create, show or edit view of a referenced resource inside a modal or a sliding side panel. This will allow end users to create a new post from the comment create page, or to preview the selected post. I will work on a fork of the codesandbox that was built in the last article.

Let's start with quick post creation from the comment create page.

Quick Create In a Modal

The comment create page currently looks like the following:

const CommentCreate = props => {
  // Read the post_id from the location which is injected by React Router and passed to our component by react-admin automatically
  const { post_id: post_id_string } = parse(props.location.search);

  // ra-data-fakerest uses integers as identifiers, we need to parse the querystring
  // We also must ensure we can still create a new comment without having a post_id
  // from the url by returning an empty string if post_id isn't specified
  const post_id = post_id_string ? parseInt(post_id_string, 10) : "";

  const redirect = post_id ? `/posts/${post_id}/show/comments` : false;

  return (
    <Create {...props}>
      <SimpleForm
        defaultValue={{ created_at: today, post_id }}
        redirect={redirect}
      >
        <ReferenceInput
          source="post_id"
          reference="posts"
          allowEmpty
          validate={required()}
        >
          <SelectInput optionText="title" />
        </ReferenceInput>
        <DateInput source="created_at" />
        <LongTextInput source="body" />
      </SimpleForm>
    </Create>
  );
};

I'd like to add a New button next to the posts ReferenceInput. Let's first figure out how we are going to retrieve the id of a newly created post, and pass it to our ReferenceInput.

I'll need a custom ReferenceInput, that I'll name PostReferenceInput. Initially, I was tempted to add a handlePostCreated method to it, which would be called by whatever component responsible for the post creation, and set the new post's id in the state. It would then apply the new post's id as the value of the ReferenceInput. However, the react-admin input components do not accept a value prop. This is because react-admin takes care of binding them to the redux-form and passing them the appropriate props. Passing a value won't work as it will be overridden by redux-form Field component.

Fortunately, redux-form provides many redux action creators to manipulate our form, among which is change, which sets the value of a specific field, and submit, which submits the form. This also allows me to extract those manipulations in a custom PostQuickCreateButton component which can handle the modal state too.

// in PostQuickCreateButton.js
import React, { Component, Fragment } from "react";
import { connect } from "react-redux";
import { change, submit, isSubmitting } from "redux-form";
import {
  fetchEnd,
  fetchStart,
  required,
  showNotification,
  Button,
  SaveButton,
  SimpleForm,
  TextInput,
  LongTextInput,
  CREATE,
  REDUX_FORM_NAME,
} from "react-admin";
import IconContentAdd from "@material-ui/icons/Add";
import IconCancel from "@material-ui/icons/Cancel";
import Dialog from "@material-ui/core/Dialog";
import DialogTitle from "@material-ui/core/DialogTitle";
import DialogContent from "@material-ui/core/DialogContent";
import DialogActions from "@material-ui/core/DialogActions";

import dataProvider from "../dataProvider";

class PostQuickCreateButton extends Component {
  state = {
    error: false,
    showDialog: false,
  };

  handleClick = () => {
    this.setState({ showDialog: true });
  };

  handleCloseClick = () => {
    this.setState({ showDialog: false });
  };

  handleSaveClick = () => {
    const { submit } = this.props;

    // Trigger a submit of our custom quick create form
    // This is needed because our modal action buttons are oustide the form
    submit("post-quick-create");
  };

  handleSubmit = values => {
    const { change, fetchStart, fetchEnd, showNotification } = this.props;

    // Dispatch an action letting react-admin know a API call is ongoing
    fetchStart();

    // As we want to know when the new post has been created in order to close the modal, we use the
    // dataProvider directly
    dataProvider(CREATE, "posts", { data: values })
      .then(({ data }) => {
        // Update the main react-admin form (in this case, the comments creation form)
        change(REDUX_FORM_NAME, "post_id", data.id);
        this.setState({ showDialog: false });
      })
      .catch(error => {
        showNotification(error.message, "error");
      })
      .finally(() => {
        // Dispatch an action letting react-admin know a API call has ended
        fetchEnd();
      });
  };

  render() {
    const { showDialog } = this.state;
    const { isSubmitting } = this.props;

    return (
      <Fragment>
        <Button onClick={this.handleClick} label="ra.action.create">
          <IconContentAdd />
        </Button>
        <Dialog
          fullWidth
          open={showDialog}
          onClose={this.handleCloseClick}
          aria-label="Create post"
        >
          <DialogTitle>Create post</DialogTitle>
          <DialogContent>
            <SimpleForm
              // We override the redux-form name to avoid collision with the react-admin main form
              form="post-quick-create"
              resource="posts"
              // We override the redux-form onSubmit prop to handle the submission ourselves
              onSubmit={this.handleSubmit}
              // We want no toolbar at all as we have our modal actions
              toolbar={null}
            >
              <TextInput source="title" validate={required()} />
              <LongTextInput source="teaser" validate={required()} />
            </SimpleForm>
          </DialogContent>
          <DialogActions>
            <SaveButton saving={isSubmitting} onClick={this.handleSaveClick} />
            <Button label="ra.action.cancel" onClick={this.handleCloseClick}>
              <IconCancel />
            </Button>
          </DialogActions>
        </Dialog>
      </Fragment>
    );
  }
}

const mapStateToProps = state => ({
  isSubmitting: isSubmitting("post-quick-create")(state),
});

const mapDispatchToProps = {
  change,
  fetchEnd,
  fetchStart,
  showNotification,
  submit,
};

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(PostQuickCreateButton);

Now that I have a way to create the new post, let's see how to use it:

// in PostReferenceInput.js
import React, { Fragment } from "react";
import { ReferenceInput, SelectInput } from "react-admin";
import PostQuickCreateButton from "./PostQuickCreateButton";

const PostReferenceInput = () => (
  <Fragment>
    <ReferenceInput {...this.props}>
      <SelectInput optionText="title" />
    </ReferenceInput>
    <PostQuickCreateButton />
  </Fragment>
);

Voila!

Or not... If you followed along, there is still one remaining issue. After you create a new post, it does appear as the value of the SelectInput. However, if you select another post afterwards, the new one disappears! This is because the ReferenceInput has no clue about this new post. I must instruct it to refresh its data after the post creation:

// in PostQuickCreateButton.js
// ...
import {
  // ...
  crudGetMatching,
} from "react-admin";
// ...

class PostQuickCreateButton extends Component {
  // ...
  handleSubmit = values => {
    const {
      change,
      crudGetMatching,
      fetchStart,
      fetchEnd,
      showNotification,
    } = this.props;

    // Dispatch an action letting react-admin know a API call is ongoing
    fetchStart();

    // As we want to know when the new post has been created in order to close the modal, we use the
    // dataProvider directly
    dataProvider(CREATE, "posts", { data: values })
      .then(({ data }) => {
        // Refresh the choices of the ReferenceInput to ensure our newly created post
        // always appear, even after selecting another post
        crudGetMatching(
          "posts",
          "comments@post_id",
          { page: 1, perPage: 25 },
          { field: "id", order: "DESC" },
          {}
        );

        // Update the main react-admin form (in this case, the comments creation form)
        change(REDUX_FORM_NAME, "post_id", data.id);
        this.setState({ showDialog: false });
      })
      .catch(error => {
        showNotification(error.message, "error");
      })
      .finally(() => {
        // Dispatch an action letting react-admin know a API call has ended
        fetchEnd();
      });
  };
  // ...
}

// ...

const mapDispatchToProps = {
  // ...
  crudGetMatching,
};

// ...

Quick Preview In a Sidepanel

Now, what about adding a quick preview of the selected post? Let's add a PostQuickPreviewButton component showing more details about the selected post inside a Drawer.

// in PostQuickPreviewButton.js
import React, { Component, Fragment } from "react";
import { connect } from "react-redux";
import Drawer from "@material-ui/core/Drawer";
import { withStyles } from "@material-ui/core/styles";

import IconImageEye from "@material-ui/icons/RemoveRedEye";
import IconKeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight";
import { Button, SimpleShowLayout, TextField } from "react-admin";

const styles = theme => ({
  field: {
    // These styles will ensure our drawer don't fully cover our
    // application when teaser or title are very long
    "& span": {
      display: "inline-block",
      maxWidth: "20em",
    },
  },
});

const PostPreviewView = ({ classes, ...props }) => (
  <SimpleShowLayout {...props}>
    <TextField source="id" />
    <TextField source="title" className={classes.field} />
    <TextField source="teaser" className={classes.field} />
  </SimpleShowLayout>
);

const mapStateToProps = (state, props) => ({
  // Get the record by its id from the react-admin state.
  record: state.admin.resources[props.resource]
    ? state.admin.resources[props.resource].data[props.id]
    : null,
  version: state.admin.ui.viewVersion,
});

const PostPreview = connect(
  mapStateToProps,
  {}
)(withStyles(styles)(PostPreviewView));

class PostQuickPreviewButton extends Component {
  state = { showPanel: false };

  handleClick = () => {
    this.setState({ showPanel: true });
  };

  handleCloseClick = () => {
    this.setState({ showPanel: false });
  };

  render() {
    const { showPanel } = this.state;
    const { id } = this.props;
    return (
      <Fragment>
        <Button onClick={this.handleClick} label="ra.action.show">
          <IconImageEye />
        </Button>
        <Drawer anchor="right" open={showPanel} onClose={this.handleCloseClick}>
          <div>
            <Button label="Close" onClick={this.handleCloseClick}>
              <IconKeyboardArrowRight />
            </Button>
          </div>
          <PostPreview id={id} basePath="/posts" resource="posts" />
        </Drawer>
      </Fragment>
    );
  }
}

export default connect()(PostQuickPreviewButton);

And this is how to use it inside our PostReferenceInput component:

import React, { Fragment } from "react";
import { Field } from "redux-form";
import { ReferenceInput, SelectInput } from "react-admin";
import PostQuickCreateButton from "./PostQuickCreateButton";
import PostQuickPreviewButton from "./PostQuickPreviewButton";

const PostReferenceInput = props => (
  <Fragment>
    <ReferenceInput {...props}>
      <SelectInput optionText="title" />
    </ReferenceInput>
    <PostQuickCreateButton />
    // We use Field to get the current value of the `post_id` field
    <Field
      name="post_id"
      component={({ input }) =>
        input.value && <PostQuickPreviewButton id={input.value} />
      }
    />
  </Fragment>
);

export default PostReferenceInput;

Voila!

Conclusion

I've shown in this article that you're not constrained to use react-admin components such as Create or Show. Sometimes, a mix of classic React and some carefully picked react-admin components will allow you to create features not already baked-in. As we said in the previous article, It's just Reactâ„¢, It's just JavaScriptâ„¢ !

You can explore the full code for this article in this codesandbox

Did you like this article? Share it!