Ruby on Rails To Do App with Turbo

In this post, we'll build a simple todo app with Ruby on Rails and Turbo. We'll implement the ability to add, edit, delete and mark as completed tasks. Our app will have one page where we'll display a list of tasks, and we'll update them without refreshing the page or writing any JavaScript code. We'll use Ruby on Rails 7.0.4.2 and Ruby 3.2.1.

SET UP THE APPLICATION

Let's get started by creating a new application. In the terminal, type the following command:

rails new todo_app

Once the command finished running, we can change directories to this folder:

cd todo_app

Next, we will generate a Task model:

bin/rails g model Task name:string

Then we will apply this migration:

bin/rails db:migrate

Let's add a validation for the name attribute in  the app/models/task.rb file:

validates_presence_of :name

The next step is to generate a Tasks controller with an index action:

bin/rails g controller Tasks index

This will allow us to display a list of tasks in our application.

Next, we'll set the root route to the index action. Let's open the config/routes.rb and replace this line:

get 'tasks/index'

with:

root 'tasks#index'

DISPLAY A LIST OF TASKS IN THE INDEX VIEW

Let's edit the index method in the controller to add a @tasks instance variable:

def index
  @tasks = Task.all
end

Next, in the app/views/tasks/ folder, we'll create a _task.html.erb partial. In the terminal type:

touch app/views/tasks/_task.html.erb

We wrap each task in a Turbo Frame. Let's edit this partial as follows:

<%= turbo_frame_tag dom_id task do %>
  <p><%= task.name %></p>
<% end %>

Now, we'll create another partial _tasks.html.erb, where we'll display a list of tasks. In the terminal we type:

touch app/views/tasks/_tasks.html.erb

Let's open this partial and add this to it:

<ul id='tasks-list'>
  <% @tasks.each do |task| %>
    <li><%= render partial: 'task', locals: {task: task} %></li>
  <% end %>
</ul>

Now, we can render it in our index view:

<main>
  <h1>Tasks</h1>
  <%= render 'tasks' %>
</main>

IMPLEMENT THE ABILITY TO ADD A NEW TASK

Let's start by making a @task instance variable in the index view, and assigning a new object of the Task class to it. Inside the index method add this:

@task = Task.new

To be able to add a new task, we'll need to define a create action in our controller. But first, let's write the private method task_params at the bottom of our controller:

private
def task_params
  params.require(:task).permit(:name)
end

Then, we add the create method:

def create
  @tasks = Task.all
  @task = Task.new(task_params)
   if @task.save
     render turbo_stream: turbo_stream.replace('tasks-list', partial: 'tasks', locals: { tasks: @tasks })
   else
     render turbo_stream: turbo_stream.replace('task-form', partial: 'tasks/form', locals: { task: @task }) 
   end 
end

We use the turbo_stream method to generate a Turbo Stream response.  We don't need to reload the page or use JavaScript code.

Since we retrieve all tasks from the database for every action, we can write a private method that we'll call load_tasks, and then use the before_action callback to call this method:

def load_tasks
  @tasks = Task.all
end

We can delete this line:

@tasks = Task.all

inside the index and create methods, and put this at the top of our controller:

before_action :load_tasks

We need a route to the create action. Let's add it in the config/routes.rb file:

resources :tasks, only: :create

Next, we'll create a _form.html.erb partial. In the terminal type:

touch app/views/tasks/_form.html.erb

Let's edit this partial to add a form:

<%= form_with(model: task, id: dom_id(task, 'form')) do |form| %>
  <%= form.text_field :name, id: dom_id(task, 'name') %>
  <%= form.submit data: { 'turbo-permanent': true } %>
<% end %>

We will display this partial in our index view. Let's put it before the tasks partial:

<%= render 'form', task: @task %>

We can now add tasks to our list. We need to add a little JavaScript code though because we want to clear the form input once the task is submitted.  In the app/javascript/application.js file let's put this code:

document.addEventListener('turbo:submit-end', function (event) {
  if (event.target.matches('#form_task')) {
event.target.reset();
  }
})

With this, we reset the form. The turbo:submit-end event fires when a form submission is complete.

Implement the ability to mark tasks as completed

Next, we'll implement the ability to mark tasks as completed.

But first, we'll define a private method set_task in our Tasks controller:

def set_task
  @task = Task.find(params[:id])
end

And we'll use the before_action callback to call this method except for the index and create actions. Let's put this line at the top of our controller:

before_action :set_task, except: [:index, :create]

We'll add a status attribute to the Task model, and will use an enum for this status. Let's generate a migration file:

bin/rails g migration AddStatusToTasks status:integer

Let's edit the migration file, to set a default:

class AddStatusToTasks < ActiveRecord::Migration[7.0]
  def change
    add_column :tasks, :status, :integer, default: 0
  end
end

Now, we apply this migration:

bin/rails db:migrate 

In the Task model, we define this enum like this:

enum status: { incomplete: 0, completed: 1 }

In the Tasks controller, we'll define a toggle_status action to switch between statuses:

def toggle_status
  if @task.incomplete?
    @task.update(status: :completed)
  else
    @task.update(status: :incomplete)
  end
    render_tasks_partial
end

Since we are repeating the same Turbo Streams response in both create and toggle_status methods, and we'll use it in other methods too, we can create a private method that we'll call render_tasks_partial:

def render_tasks_partial
  render turbo_stream: turbo_stream.replace('tasks-list', partial: 'tasks', locals: { tasks: @tasks })
end

And then update our create and toggle_status methods to replace the Turbo Streams response code with a call to this method:

def create
  @task = Task.new(task_params)
  if @task.save
    render_tasks_partial
  else
    render turbo_stream: turbo_stream.replace('task-form', partial: 'tasks/form', locals: { task: @task }) 
  end 
end
def toggle_status
  if @task.incomplete?
    @task.update(status: :completed)
  else
    @task.update(status: :incomplete)
  end
    render_tasks_partial
end

Let's add a route to this toggle_status action. Let's modify our routes like this:

resources :tasks, only: :create do
  member do
    put :toggle_status
  end
end

And in our partial, we'll add a button to toggle the task status, after the task name:

<%= turbo_frame_tag dom_id task do %>
  <p><%= task.name %></p>
  <div class='actions'>
    <%= button_to task.incomplete? ? 'Incomplete' : 'Completed', toggle_status_task_path(task), method: :put, class: 'actions-button' %>
  </div>
<% end %>

IMPLEMENT THE ABILITY TO EDIT A TASK

Now, we'll implement the ability to edit the tasks. First, let's add an edit method to our controller:

def edit
  render turbo_stream: turbo_stream.update("task_#{params[:id]}", partial: 'tasks/form', locals: { task: @task }) 
end

Also, we'll add an update action to the controller:

def update
  if @task.update(task_params)
    render turbo_stream: turbo_stream.replace("task_#{params[:id]}", partial: 'tasks/task', locals: { task: @task }) 
  else
    render turbo_stream: turbo_stream.update("task_#{params[:id]}_form", partial: 'tasks/form', locals: { task: @task }) 
  end
end

Let's add routes to these two actions in the config/routes.rb file:

resources :tasks, only: [:create, :update] do
  member do
    put :toggle_status
    get :edit
  end
end

And in the _task.html.erb partial, we add a button to edit the task:

<%= turbo_frame_tag dom_id task do %>
  <p><%= task.name %></p>
  <div class="actions">
    <%= button_to task.incomplete? ? 'Incomplete' : 'Completed', toggle_status_task_path(task), method: :put, class: 'actions-button' %>
    <%= link_to 'Edit', edit_task_path(task), class: 'actions-button edit' %>
  </div>
<% end %>

IMPLEMENT THE ABILITY TO DELETE TASKS

Finally, we will implement the ability to delete the task. First, let's define a destroy method in the controller:

def destroy
  @task.destroy
  render_tasks_partial
end

Let's add a route to it in config/routes.rb:

resources :tasks, only: [ :create, :destroy, :update] do
  member do
    get :edit
    put :toggle_status
  end
end

And in the _task.html.erb partial, we'll add a button to this action:

<%= turbo_frame_tag dom_id task do %>
  <p><%= task.name %></p>
    <div class="actions">
      <%= button_to task.incomplete? ? 'Incomplete' : 'Completed', toggle_status_task_path(task), method: :put, class:'actions-button' %>
    <%= link_to "Edit", edit_task_path(task), class: 'actions-button edit' %>
    <%= button_to 'Delete', task, method: :delete, class: 'actions-button' %>
  </div>
<% end %>

Last, let's add some basic styles in the app/assets/stylesheets/application.css file:

 main {
    margin: 30px;
  }
  
  .actions {
    display: flex;
    flex-direction: row;
  }
  
  .actions-button {
    cursor: pointer;
    margin-right: 15px;
  }
  
  .edit {
    background: #ffeb7a;
    border-radius: 5px;
    padding: 3px;
    text-decoration: none;
  }
  
  #tasks-list {
    margin-top: 60px;
    padding: 0;
  }
  
  li {
    list-style-type: none;
    box-shadow: 0px 1px 5px rgba(29, 29, 29, 0.2);
    padding-bottom: 20px;
  }

CONCLUSION

We implemented our app easily, with the help of the turbo_stream method, which allows us to create dynamic web apps without page reloads or complex JavaScript code.

Post last updated on May 13, 2023