Project DoList: Tasks

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 and dom_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 a TURBO_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 a DELETE request instead of the default GET 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 model

  • Added the ability to create, edit, delete and complete a task

  • Installed the turbo-rails gem

  • Used turbo frames to match the Todoist UX (without page refreshes)

1
Subscribe to my newsletter

Read articles from Steven Wanderski directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Steven Wanderski
Steven Wanderski