Ruby on Rails To Do App with Turbo
In Ruby on Rails Mar 8, 2023
Updated on May 13, 2023
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.