In this article we will add the ability to manage tasks per project and then mark them as completed.
Create the Task Model
Run the following command to generate the Task
model and DB migration:
bin/rails g model Task user_id:integer project_id:integer name description:text is_completed:boolean weight:integer
Run bin/rails db:migrate
to create the tasks
DB table.
Next, the Project
and User
models will each have ownership over a Task
so let's add has_many :tasks
to each model.
Add the Routes and Controller
Let's add all the routes that we'll need for managing tasks. In config/routes.rb
add resources :tasks, except: [:index]
nested under projects
like so:
Rails.application.routes.draw do
namespace :users, path: 'app' do
resources :projects do
resources :tasks, except: [:index] do
member do
post :complete, action: :complete
end
end
end
end
devise_for :users
root 'pages#home'
end
We want tasks
nested under projects
so that every route we access for managing tasks includes the parent project ID. A task cannot exist without a parent project; this route structure enforces this relationship. An example nested route looks like this:
/app/projects/:project_id/tasks/new
The line resources :tasks, except: [:index]
gives us the following routes:
New
Create
Edit
Update
Show
Destroy
We use except: [:index]
since we will not be needing a list page for tasks - that will be handled by the project's "show" route.
Also note the post :complete, action: :complete
line inside the member do
block. This creates a route at POST /app/projects/:project_id/tasks/:id/complete
which will be used to mark a task as "completed".
Next add the tasks controller. Add the file app/controllers/users/tasks_controller.rb
with the following contents:
class Users::TasksController < Users::ApplicationController
def new
@project = current_user.projects.find(params[:project_id])
@task = Task.new
end
def edit
@project = current_user.projects.find(params[:project_id])
@task = current_user.tasks.find(params[:id])
end
def create
@project = current_user.projects.find(params[:project_id])
@task = current_user.tasks.new(task_params)
@task.project_id = @project.id
if @task.save
@tasks = @project.active_tasks
@new_task = Task.new
else
render 'new', status: :unprocessable_entity
end
end
def update
@project = current_user.projects.find(params[:project_id])
@task = current_user.tasks.find(params[:id])
if @task.update(task_params)
@tasks = @project.active_tasks
@new_task = Task.new
else
render 'edit', status: :unprocessable_entity
end
end
def destroy
@project = current_user.projects.find(params[:project_id])
@tasks = @project.active_tasks
@task = current_user.tasks.find(params[:id])
@task.destroy
end
def complete
@project = current_user.projects.find(params[:project_id])
@tasks = @project.active_tasks
@task = current_user.tasks.find(params[:id])
@task.update!(is_completed: true)
end
private
def task_params
params.require(:task).permit(:name, :description)
end
end
Note: we'll go over each action in detail as we implement the view code.
Install the turbo-rails
gem
So far we've only used the Turbo "Drive" features of Turbo which provides refresh-less page transitions for simple links. We will now want to be using Turbo frames and streams for submitting forms and updating parts of the page after successful submissions. The easiest way to use these features in a Rails app is to install the turbo-rails
gem (https://github.com/hotwired/turbo-rails).
The gem gives us the following features:
Adds useful view helpers:
turbo_frame_tag
,turbo_stream
anddom_id
.Automatically adds the request type of
TURBO_STREAM
to any form or link that submits from within a turbo frame.Adds a response format type
.turbo_stream
that allows us to update parts of the DOM in response to aTURBO_STREAM
request.
List Tasks with a Partial
Let's first add the code that will display the list of tasks that belong to the current project. We will use a partial since we'll be re-rendering this list from inside the create
task controller action (details below).
In the file app/views/users/projects/_tasks.html.erb
add the following code:
<div id="tasks">
<% if tasks.any? %>
<div class="mb-3">
<% tasks.each do |task| %>
<%= turbo_frame_tag "#{dom_id(task)}_edit" do %>
<div class="flex justify-between items-center py-3 border-b border-b-slate-700">
<div class="flex items-center gap-2">
<%= link_to complete_users_project_task_path(@project, task), data: { turbo_method: :post }, class: 'group w-[18px] h-[18px] rounded-full border border-slate-400' do %>
<%= heroicon 'check', options: { class: 'group-hover:opacity-100 opacity-0 transition-opacity duration-200 text-slate-400 w-3 h-3 mt-[2px] ml-[2px]' } %>
<% end %>
<div class="text-sm text-slate-200">
<%= task.name %>
</div>
</div>
<div class="flex gap-2">
<div>
<%= link_to 'Edit', edit_users_project_task_path(@project, task), class: 'text-sm' %>
</div>
<div>
<%= link_to 'Delete', users_project_task_path(@project, task), class: 'text-sm', data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } %>
</div>
</div>
</div>
<% end %>
<% end %>
</div>
<% end %>
</div>
In the file app/views/users/projects/show.html.erb
render the partial just below the project title like this:
<h1 class="font-semibold text-2xl max-w-xl mb-4">
<%= @project.name %>
</h1>
<%= render 'tasks', tasks: @tasks %>
The _tasks
partial contains all the code for editing, deleting, and completing a task. We'll go into detail about each one coming up.
Create New Task
Now let's add a way to create new tasks. Let's start by having a simple link that reads "Add Task". When clicked we'll want the link to turn into a form for adding a task. We can create a "new" route and file (that displays the form) and then use Turbo to swap out the link when clicked. And because the link will be prefetched on hover the swap should be near instant.
First let's create the "new" template. Add the file app/views/users/tasks/new.html.erb
with the following contents:
<%= turbo_frame_tag 'new_task_frame' do %>
<%= render 'users/tasks/new/form', task: @task, project: @project %>
<% end %>
Next add the partial file app/views/users/tasks/new/_form.html.erb
with the following contents:
<%= form_for(task, url: users_project_tasks_path, data: { turbo_frame: '_top' }) do |f| %>
<div class="rounded-xl border border-slate-600">
<div class="p-2 border-b border-b-slate-700">
<%= f.label :name, class: 'sr-only' %>
<%= f.text_field :name,
placeholder: 'Task name',
autofocus: true,
required: true,
class: 'w-full bg-transparent border-none p-1 text-sm focus:outline-none focus:ring-0'
%>
<%= f.text_area :description,
placeholder: 'Description',
class: 'w-full bg-transparent border-none p-1 text-sm focus:outline-none focus:ring-0'
%>
</div>
<div class="flex justify-end gap-3 p-2">
<%= link_to 'Cancel',
users_project_path(project),
class: 'bg-zinc-800 py-1.5 px-4 rounded text-sm'
%>
<%= f.button 'Add Task', class: 'bg-red-500 py-1.5 px-4 rounded text-sm' %>
</div>
</div>
<% end %>
Now let's add the "Add Task" code that will perform the swapping. In app/views/users/projects/show.html.erb
add the following code at the bottom:
<%= turbo_frame_tag 'new_task_frame' do %>
<%= link_to new_users_project_task_path(project_id: @project.id), class: 'group' do %>
<div class="flex gap-2">
<%= heroicon 'plus', options: { class: 'text-red-500 w-5 h-5 rounded-full group-hover:text-white group-hover:bg-red-500' } %>
<div class="text-sm text-gray-500 group-hover:text-red-500">
Add Task
</div>
</div>
<% end %>
<% end %>
Because the "Add Task" link is inside of a <turbo-frame>
any clicked link will make an AJAX request to its destination. The link will then replace its own <turbo-frame>
with the matching <turbo-frame>
from the response. Therefore since the "new" route is wrapped in a matching #new_task_frame
, it will be injected into the existing #new_task_frame
that previously contained the link.
Next let's look at the "create" route. After a task is successfully created we want to perform two updates to our page (without a refresh):
Update the task list to show the new task
Reset the new task form to contain empty fields
We can make this happen by responding to the form submission using a "turbo stream" response. Create the file app/views/users/tasks/create.turbo_stream.erb
and add the following code:
<%= turbo_stream.replace "tasks" do %>
<%= render 'users/projects/tasks', tasks: @tasks %>
<% end %>
<%= turbo_stream.update "new_task_frame" do %>
<%= render 'users/tasks/new/form', task: @new_task, project: @project %>
<% end %>
The first statement turbo_stream.replace "tasks"
will find the DOM element with ID "tasks" and replace the entire element with the provided block (the task list).
The second statement turbo_stream.update "new_task_frame"
will find the DOM element with ID "new_task_frame" and update the inner HTML with the provided block (an empty new task form).
Edit a Task
Let's implement the ability to edit a task. Add the file app/views/users/tasks/edit.html.erb
with the following code:
<%= turbo_frame_tag "#{dom_id(@task)}_edit" do %>
<%= form_for(@task, url: users_project_task_path(@project, @task)) do |f| %>
<div class="rounded-xl border border-slate-600">
<div class="p-2 border-b border-b-slate-700">
<%= f.label :name, class: 'sr-only' %>
<%= f.text_field :name,
placeholder: 'Task name',
autofocus: true,
required: true,
class: 'w-full bg-transparent border-none p-1 text-sm focus:outline-none focus:ring-0'
%>
<%= f.text_area :description,
placeholder: 'Description',
class: 'w-full bg-transparent border-none p-1 text-sm focus:outline-none focus:ring-0'
%>
</div>
<div class="flex justify-end gap-3 p-2">
<%= link_to 'Cancel',
users_project_path(@project),
class: 'bg-zinc-800 py-1.5 px-4 rounded text-sm'
%>
<%= f.button 'Save', class: 'bg-red-500 py-1.5 px-4 rounded text-sm' %>
</div>
</div>
<% end %>
<% end %>
Notice that this template wraps the form in the following turbo frame tag:
<%= turbo_frame_tag "#{dom_id(@task)}_edit" do %>
The dom_id
helper generates a string from the passed model's name and ID that can be used as a unique DOM element ID (ex. task_12
). Remember that in our task list partial we wrapped each task element in the same turbo frame ID so that when we click the "Edit" link the entire task element will be replaced with the edit form.
Next create the file app/views/users/tasks/update.turbo_stream.erb
and add the code:
<%= turbo_stream.replace "tasks" do %>
<%= render 'users/projects/tasks', tasks: @tasks %>
<% end %>
On successful task update the above code will replace the task list with an updated list.
Delete a Task
Notice in the task list partial we use the following code to display the "Delete" link:
<%= link_to 'Delete', users_project_task_path(@project, task), class: 'text-sm', data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } %>
Two interesting attributes are present:
data-turbo-method="delete"
instructs Turbo to make aDELETE
request instead of the defaultGET
request.data-turbo-confirm="Are you sure?"
shows a JS confirm dialog before executing the link (if "cancel" is clicked the link does not follow through).
Next create the file app/views/users/tasks/destroy.turbo_stream.erb
with the following code:
<%= turbo_stream.replace "tasks" do %>
<%= render 'users/projects/tasks', tasks: @tasks %>
<% end %>
The above code works identically to the edit / update code: on success the task list is replaced with an updated list of tasks.
Complete a Task
Finally let's add the ability to mark a task as "completed". The following code exists in the task list partial:
<%= link_to complete_users_project_task_path(@project, task), data: { turbo_method: :post }, class: 'group w-[18px] h-[18px] rounded-full border border-slate-400' do %>
<%= heroicon 'check', options: { class: 'group-hover:opacity-100 opacity-0 transition-opacity duration-200 text-slate-400 w-3 h-3 mt-[2px] ml-[2px]' } %>
<% end %>
Note that it is simply a link with a POST
request type. Create the file app/views/users/tasks/complete.turbo_stream.erb
with the following code:
<%= turbo_stream.replace "tasks" do %>
<%= render 'users/projects/tasks', tasks: @tasks %>
<% end %>
Again, this code works identically to the update and delete routes: on successful request the task list is replaced with an updated list.
Here is what our app currently looks like:
Demo: https://dolistapp.org
Summary
In this article we did the following things:
Created the
Task
modelAdded the ability to create, edit, delete and complete a task
Installed the
turbo-rails
gemUsed turbo frames to match the Todoist UX (without page refreshes)