Back
Chapters

Adding a new task using create action

Search icon
Search Book
⌘K

Up until now we have been using the Rails console to create a task. In this chapter we are going to learn how we can achieve the same using our application's UI.

Features

These are the basic requirements of this feature:

  • A form component which contains a title field and a submit button.

The picture below depicts how this feature will appear after it is implemented. Note that in the picture, create form also has a field to select the assigned user. We will only be adding the title field for now because we haven't yet created a user.

We will add the select field once we are through with user creation.

Adding a task using create action feature

Technical design

To implement this feature, we need to introduce the following changes:

On the backend

  • Add a create action in TasksController which will handle the POST request from frontend side with task params. Create action will contain the logic to create a task and respond with a valid JSON message.

  • Add a create route for RESTful tasks resources which will route POST request to the create action in the TasksController.

On the frontend

  • Add a POST request API in the tasks API collection for task creation.

  • Add a Form component inside Tasks folder which will contain Input and Button components for title field and submit button respectively.

  • Add a Create component which will contain the Form and logic to submit the form once submit button inside the Form is clicked.

  • Create a handleSubmit function inside Create which will be called when the form is submitted. This function will pass the task params as API payload and make a POST request using the POST request API.

  • Add a route in App component to render the Create component.

Implementing create action

We will now add create action in the TasksController.

Here, we need to implement the creation of a task in a way that if the record creation is successful a 200 response code with a relevant notice is returned, and if the record creation fails, a 422 response code with the error message is returned.

Open /app/controllers/tasks_controller.rb and modify the code as shown below:

1class TasksController < ApplicationController
2
3  def index
4   tasks = Task.all
5   render status: :ok, json: { tasks: tasks }
6  end
7
8  def create
9    task = Task.new(task_params)
10    task.save!
11    render status: :ok, json: { notice: 'Task was successfully created' }
12  end
13
14  private
15
16    def task_params
17      params.require(:task).permit(:title)
18    end
19end

We have used the bang version, that is the save! method, which will raise an exception in case an error is encountered while saving the task record. This could be due to a couple of reasons such as validation failure or a missing parameter. We should handle exceptions accordingly in order to catch the errors and send an appropriate response.

Inside the create action, we are only responding with a success response because any errors that occur will be handled and dealt with accordingly during the exception handling. Hence we do not need to worry about sending a response in such case within the create action.

The ideal place to handle exceptions occurring inside controllers is the ApplicationController class because all the controller classes in our application will inherit from it. So the methods that rescue and handle the exception will be available to rest of the controller classes.

While we are at it, we should also create some helper methods to help us render a response.

Update the application_controller.rb file like so:

1class ApplicationController < ActionController::Base
2  rescue_from ActiveRecord::RecordNotFound, with: :handle_record_not_found
3  rescue_from ActiveRecord::RecordInvalid, with: :handle_validation_error
4  rescue_from ActiveRecord::RecordNotUnique, with: :handle_record_not_unique
5  rescue_from ActionController::ParameterMissing, with: :handle_api_error
6
7  private
8
9    def handle_validation_error(exception)
10      respond_with_error(exception)
11    end
12
13    def handle_record_not_found(exception)
14      respond_with_error(exception.message, :not_found)
15    end
16
17    def handle_record_not_unique(exception)
18      respond_with_error(exception)
19    end
20
21    def handle_api_error(exception)
22      respond_with_error(exception, :internal_server_error)
23    end
24
25    def respond_with_error(message, status = :unprocessable_entity, context = {})
26      is_exception = message.kind_of?(StandardError)
27      error_message = is_exception ? message.record&.errors.full_messages.to_sentence : message
28      render status: status, json: { error: error_message }.merge(context)
29    end
30
31    def respond_with_success(message, status = :ok, context = {})
32      render status: status, json: { notice: message }.merge(context)
33    end
34
35    def respond_with_json(json = {}, status = :ok)
36      render status: status, json: json
37    end
38end

When an exception occurs, it will be caught by one of the rescue_from callbacks based on the exception class and then the method passed to the with option of the rescue_from callback will be called with the exception object.

Inside the exception handling callback, we are calling the respond_with_error method with the exception object and the response status code. respond_with_error method can accept an exception object as well as an error string.

Inside this method, we are checking if the error message is an exception or a string. Since all exception classes in Rails inherit from the StandardError class, the exception object will be a kind of StandardError object and message.kind_of?(StandardError) will return true if the message is an exception.

If the message is an exception object, the active record object on which this error occurs is derived by calling the record helper method on the exception object like message.record.

Whenever such an error occurs, it can be accessed like any other attribute of the object. For example, if the error occurred on a task object then it can be accessed by calling task.errors.

Similarly calling message.record&.errors will return an error object which is an instance of ActiveRecord::Errors. Thus we can make use of the public instance methods which come as part of this class to format the errors in our desired manner.

There is a method called full_messages, which will provide us the full error messages in an array and finally the to_sentence method converts the array of errors returned to a comma-separated sentence where the last element is joined by the connector word, which by default is and.

Calling errors.full_messages.to_sentence on an active record object to get a formatted error sentence seems very redundant as we have to chain three methods. Let us add a helper method which will return a formatted error string message upon getting invoked on an active record object.

Take some time to think about where we should add this helper method. Ideally we should add this as an instance method inside a model class because each active record object is an instance of a model class.

Should we add the helper method inside each of the models? That doesn't seem right as it is not very DRY. We should add it as an instance method inside the ApplicationRecord class because the model classes in our application inherit from ApplicationRecord hence it will be available to all its child classes through inheritance.

Update the application_record.rb file like so:

1class ApplicationRecord < ActiveRecord::Base
2  include ActionView::Helpers::TranslationHelper
3  self.abstract_class = true
4
5  def errors_to_sentence
6    errors.full_messages.to_sentence
7  end
8end

In the above code, we haven't mentioned the instance explicitly which will call the errors method. Ruby adds an implicit self in such a case. So errors will be called on the self object which will refer to the object which invokes errors_to_sentence method.

Now update the application_controller.rb file like so:

1class ApplicationController < ActionController::Base
2  # previous code
3
4  private
5
6    # previous code
7
8    def respond_with_error(message, status = :unprocessable_entity, context = {})
9      is_exception = message.kind_of?(StandardError)
10      error_message = is_exception ? message.record&.errors_to_sentence : message
11      render status: status, json: { error: error_message }.merge(context)
12    end
13end

As you can see the code looks concise and easy to read now.

Now, update the create action of the TasksController with the corresponding api responder, like so:

1def create
2  task = Task.new(task_params)
3  task.save!
4  respond_with_success("Task was successfully created")
5end

Singular vs plural naming conventions

You should be mindful of naming variables correctly. If a variable contains multiple entities like an array of objects then it should always be plural even if it contains a single array.

Let us understand in brief with the help of an example. Suppose we have to save the errors encountered during an active record operation on the task object in a variable.

We can obtain errors in a task record using the task.errors_to_sentence helper method. Inside the helper method, the to_sentence method converts an array of errors to a single sentence. Hence we should use a singular variable name like error to store the error returned by errors_to_sentence method.

We should have named the variable errors if we weren't using the to_sentence method. In that case, full_messages method would have returned an array of errors and the variable name should have been plural.

Similarly variables that contain a single entity should be singular. For example, a single task record should be stored as task and an array of task records should be stored as tasks and not task.

Adding a route for creating a new task

Now, open config/routes.rb and make the necessary change:

1Rails.application.routes.draw do
2  resources :tasks, only: %i[index create], param: :slug
3end

If you are wondering what the %i[] notation is, then it's a way of creating an array of symbols where elements are separated by space.

[:Ruby, :Python, :PHP] is same as %i[Ruby Python PHP]. As we can see the latter version is much cleaner to look at and also it's easier to modify elements.

Please note that %i[] is only for array of symbols. If it's an array of strings, then use the %w[] notation. That is ["Ruby", "Python", "PHP"] is same as %w[Ruby Python PHP].

Now we will modify the Task form to send a post request to create a new task.

Sending a POST request to create a task

Now, we will handle the client side logic to create a new task. To do that, we will be abstracting out the API logic and UI Form logic to different components. So few naming and structuring conventions to keep in mind over here. First, we don't need a folder for Form right now because there aren't multiple modules that can be grouped under Form entity. Thus only a Form.jsx is enough. Second, we should not prefix with the keyword Task again nor make the component name something like Tasks/TaskForm.jsx, because it's already within the Tasks namespace. The extra prefixing of Task is redundant. The following looks cleaner: Tasks/Form.jsx. PS: In case when you import if there are conflicts between multiple forms, then import under an alias like TaskForm. That's fine.

Now let's create the form file. To do so, run the following commands:

1touch app/javascript/src/components/Tasks/Form.jsx

In Form.jsx, add the following content:

1import React from "react";
2
3import Input from "components/Input";
4import Button from "components/Button";
5
6const Form = ({ type = "create", title, setTitle, loading, handleSubmit }) => {
7  return (
8    <form className="max-w-lg mx-auto" onSubmit={handleSubmit}>
9      <Input
10        label="Title"
11        placeholder="Todo Title (Max 50 Characters Allowed)"
12        value={title}
13        onChange={e => setTitle(e.target.value.slice(0, 50))}
14      />
15      <Button
16        type="submit"
17        buttonText={type === "create" ? "Create Task" : "Update Task"}
18        loading={loading}
19      />
20    </form>
21  );
22};
23
24export default Form;

Here, we are using the reusable Input and Button component that we had created before. Also, Form is going to be a reusable form component that we will be using not only while creating a task but also updating a task (which comes in a future chapter).

We have backend validation for title to allow a maximum of 50 characters. However, we have added frontend validation in the onChange method to ensure that from the user's perspective things are very clear.

Now, we will be creating our Create component that handles the API logic to create a task. To do so, run the following command:

1touch app/javascript/src/components/Tasks/Create.jsx

In Create.jsx, add the following content:

1import React, { useState } from "react";
2import Container from "components/Container";
3import Form from "./Form";
4import tasksApi from "apis/tasks";
5
6const Create = ({ history }) => {
7  const [title, setTitle] = useState("");
8  const [loading, setLoading] = useState(false);
9
10  const handleSubmit = async event => {
11    event.preventDefault();
12    setLoading(true);
13    try {
14      await tasksApi.create({ task: { title } });
15      setLoading(false);
16      history.push("/dashboard");
17    } catch (error) {
18      logger.error(error);
19      setLoading(false);
20    }
21  };
22
23  return (
24    <Container>
25      <Form setTitle={setTitle} loading={loading} handleSubmit={handleSubmit} />
26    </Container>
27  );
28};
29
30export default Create;

We have used history.push to redirect our application to dashboard if a task is successfully created.

The history object is provided by the react-router-dom package and it is passed as a prop into each component rendered by the Router.

It contains the browser session history inside a stack. It has various other properties. One such property is the location property. It always contains the last entry in the history stack which is also the current location.

The history object has various methods as well which can be used to manually control the browser history. Like the push method we have used.

The push method accepts a path and pushes this path into the history stack thus updating the current location.

You can more about the react-router-dom package and the functionalities it provides, from its official documentation.

Handle parent key in API connector functions

We will now add an API route to create a task using POST request in app/javascript/src/apis/tasks.js. In tasks.js, add the following code:

1import axios from "axios";
2
3const list = () => axios.get("/tasks");
4
5const create = payload => axios.post("/tasks", payload);
6
7const tasksApi = {
8  list,
9  create,
10};
11
12export default tasksApi;

This function to create new task will work fine but there is one problem in this approach. Every time we call create, we will have to explicitly pass in the parent key(in this case, task) as we did in the above mentioned handleSubmit function.

1taskApi.create({ task: { text } });

With multiple invocation of these API connector functions, it will be very difficult to keep track of all these parent keys and it becomes a nightmare to maintain them. So, to avoid this we will add the parent key to API connector functions itself, such that the developer only will have to worry about passing in the payload. Rest will be automatically handled by the connector itself.

So the updated code in apis/tasks.js will be:

1import axios from "axios";
2
3const list = () => axios.get("/tasks");
4
5const create = payload =>
6  axios.post("/tasks", {
7    task: payload,
8  });
9
10const tasksApi = {
11  list,
12  create,
13};
14
15export default tasksApi;

So here the parent key task is handled in the create function itself. And this simplifies the handleSubmit function in Create.jsx like so:

1const handleSubmit = async event => {
2  event.preventDefault();
3  setLoading(true);
4  try {
5    await tasksApi.create({ title });
6    setLoading(false);
7    history.push("/dashboard");
8  } catch (error) {
9    logger.error(error);
10    setLoading(false);
11  }
12};

Let's create a route to render the Create component from Tasks folder in App.jsx.

1// previous imports if any
2import CreateTask from "components/Tasks/Create";
3import Dashboard from "components/Dashboard";
4
5const App = () => {
6  // previous code if any
7  return (
8    <Router>
9      <Switch>
10        <Route exact path="/tasks/create" component={CreateTask} />
11        <Route exact path="/dashboard" component={Dashboard} />
12      </Switch>
13    </Router>
14  );
15};
16// previous code if any

Visit http://localhost:3000 and click the Create button in NavBar and this time enter a new task and hit "Submit" button. We should see our new task in the tasks list.

Application flow for creating the task

The flow of all the operations are as following:

  1. Click the link Create on NavBar. The react-router will then render the CreateTask form. Once the form is submitted, the data is send using axios POST request to the create action in tasks controller, where the task_params method will get the strong parameters we had passed in.

  2. Controller will then try to create a new record in database using Task.new(task_params).

  3. If the task creation is successful, the controller will return a status ok with a notice message, and if the creation fails then an appropriate error response will be sent from the backend with response status unprocessable_entity.

  4. If the task record creation is successful, then, the user will be redirected to /dashboard which is the task listing page.

Moving response messages to i18n en.locales

Let's move the response messages to en.yml:

1en:
2  successfully_created: "Task was successfully created!"
3  task:
4    slug:
5      immutable: "is immutable!"

Let's use that to show a response. Update the create action of TasksController with the translation, like so:

1def create
2  task = Task.new(task_params)
3  task.save!
4  respond_with_success(t("successfully_created"))
5end

Let's commit the changes:

1git add -A
2git commit -m "Implemented create action"