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.