Project DoList: "Project" CRUD / Hotwire

Steven WanderskiSteven Wanderski
10 min read

This article will describe how to create the Project CRUD pages (Create, Read, Update, Delete) and then wire it all up with Hotwire Turbo.

Create the Project Model

Let's first create the Project model:

  • bin/rails g model Project user_id:integer name weight:integer color

  • bin/rails db:migrate

This creates a DB table named projects with the following columns:

  • User ID

  • Name

  • Weight (used for custom ordering)

  • Color

Our User model will own Project records, so let's add a "has many" relationship to the app/models/user.rb file so that it looks like this:

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  has_many :projects
end

Setup the CRUD Pages

Routes

So that our URL matches the URL structure of the Todoist app, let's change the pathname of our protected routes from dashboard to app. Also let's add all the necessary routes for the project CRUD pages using the resources helper. Make config/routes.rb match the following:

Rails.application.routes.draw do
  namespace :users, path: 'app' do
    resources :projects, except: [:destroy] do
      member do
        get '/delete', action: :destroy
      end
    end
  end

  devise_for :users
  root 'pages#home'
end

Note that we are defining our own GET /delete route. The resources helper creates RESTful routes for each CRUD action; therefore it creates the /delete route using the DELETE route method. Since our Rails 7 app has no way (yet) of easily making DELETE requests from a basic link, we add our own GET /delete route so that a link can make this request.

Controllers

Next let's create the controller for the CRUD pages at app/controllers/users/projects_controller.rb:

class Users::ProjectsController < Users::ApplicationController
  def index
  end

  def new
    @project = Project.new
  end

  def edit
    @project = current_user.projects.find(params[:id])
  end

  def create
    @project = current_user.projects.new(project_params)

    if @project.save
      redirect_to users_projects_path
    else
      render 'new'
    end
  end

  def update
    @project = current_user.projects.find(params[:id])

    if @project.update(project_params)
      redirect_to users_projects_path
    else
      render 'new'
    end
  end

  def show
    @project = current_user.projects.find(params[:id])
  end

  def destroy
    @project = current_user.projects.find(params[:id])
    @project.destroy
    redirect_to users_projects_path
  end

  private

  def project_params
    params.require(:project).permit(:name)
  end
end

Things to note:

  • This class must inherit from Users::ApplicationController so that we get the correct layout template and forced user authentication.

  • We use current_user.projects.find(params[:id]) to prevent one user accessing another user's project records. Event though this is a rudimentary form of authorization, it works well for a basic app.

We will be rendering the projects list in the base layout template's sidebar so we need to assign the user's projects in the base controller app/controllers/users/application_controller.rb:

class Users::ApplicationController < ApplicationController
  layout 'users'
  before_action :authenticate_user!
  before_action :set_projects

  private

  def set_projects
    @projects = current_user.projects
  end
end

Views

Next we need to create the template files for each of CRUD pages:

  • app/views/users/projects/index.html.erb

  • app/views/users/projects/new.html.erb

  • app/views/users/projects/edit.html.erb

  • app/views/users/projects/show.html.erb

Match the file contents with the following:

index.html.erb

<div class="flex flex-col items-center">
  <div class="w-full max-w-3xl mt-10">
    <h1 class="font-semibold text-2xl max-w-xl mb-10">
      My Projects
    </h1>

    <% if @projects.any? %>
      <div class="border-zinc-800 border-b text-sm py-2 my-4 font-semibold">
        <%= pluralize(@projects.count, 'Project') %>
      </div>

      <% @projects.each do |project| %>
        <div class="flex items-center justify-between hover:bg-zinc-800 rounded-lg pr-4">
          <%= link_to project.name,
              users_project_path(project),
              class: 'py-4 px-4 text-sm block grow'
          %>

          <div>
            <%= link_to 'Edit',
                edit_users_project_path(project),
                class: 'text-sm'
            %>

            <%= link_to 'Delete',
                delete_users_project_path(project),
                onclick: 'return confirm("Are you sure?")',
                class: 'text-sm'
            %>
          </div>
        </div>
      <% end %>
    <% else %>
      <div>
        No projects.
      </div>
    <% end %>
  </div>
</div>

new.html.erb:

<div class="font-semibold text-lg mb-5">
  Add Project
</div>

<div>
  <%= form_for(@project, url: users_projects_path) do |f| %>
    <div class="mb-5">
      <%= f.label :name, class: 'block text-sm mb-1' %>
      <%= f.text_field :name, class: 'bg-transparent border-zinc-700 p-1 text-sm rounded' %>
    </div>

    <div class="flex gap-3">
      <%= link_to 'Cancel', users_projects_path, class: 'bg-zinc-800 py-1.5 px-4 rounded text-sm' %>
      <%= f.button 'Add', class: 'bg-red-500 py-1.5 px-4 rounded text-sm' %>
    </div>
  <% end %>
</div>

edit.html.erb:

<div class="font-semibold text-lg mb-5">
  Edit
</div>

<div>
  <%= form_for(@project, url: users_project_path(@project)) do |f| %>
    <div class="mb-5">
      <%= f.label :name, class: 'block text-sm mb-1' %>
      <%= f.text_field :name, class: 'bg-transparent border-zinc-700 p-1 text-sm rounded' %>
    </div>

    <div class="flex gap-3">
      <%= link_to 'Cancel', users_projects_path, 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>
  <% end %>
</div>

show.html.erb:

<div class="flex flex-col items-center">
  <div class="w-full max-w-3xl mt-10">
    <h1 class="font-semibold text-2xl max-w-xl mb-10">
      <%= @project.name %>
    </h1>
  </div>
</div>

Lastly let's update app/views/layouts/users.html.erb to add the sidebar layout styles:

<!DOCTYPE html>
<html class="h-full">
  <head>
    <title>DoList: A free and open source alternative to Todoist</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
    <%= stylesheet_link_tag "application" %>
  </head>

  <body class="h-full">
    <div class="flex h-full">
      <div class="bg-zinc-800 text-white text-sm p-5 min-w-[300px]">
        <div class="mb-10">
          <div><%= current_user.email %></div>
          <div><%= link_to 'Logout', destroy_user_session_path %></div>
        </div>

        <div class="flex justify-between mb-4">
          <div>
            <%= link_to 'My Projects', users_projects_path %>
          </div>

          <div>
            <%= link_to new_users_project_path do %>
              <%= heroicon 'plus', options: { class: 'text-white w-5 h-5' } %>
            <% end %>
          </div>
        </div>

        <div>
          <% @projects.each do |project| %>
            <div class="mb-1">
              <%= active_link_to project.name,
                  users_project_path(project),
                  class: 'block p-2 rounded-lg',
                  class_active: 'bg-red-900/25 text-red-300',
                  class_inactive: 'hover:bg-zinc-700'
              %>
            </div>
          <% end %>
        </div>
      </div>

      <div class="bg-zinc-900 text-white grow p-5">
        <%= yield %>
      </div>
    </div>
  </body>
</html>

This now adds a standard CRUD flow with full page refreshes (no Turbo yet). The flow looks like this:

Install Turbo

Its now time to add Turbo. It is often easiest to build out the UI first using the standard method of server-side page refreshes. Once this is in place it is fairly easy to add Turbo and make the flow use modals and no page refreshes.

Since we don't want to use a JS bundler (ex. Webpack) we can install Turbo by simply including a script tag in the head of our layout file (app/views/layouts/users.html.erb):

<script src="https://unpkg.com/@hotwired/turbo@8.0.5/dist/turbo.es2017-umd.js"></script>

By including this script tag we get the following for free:

  • All links are now handled by making an AJAX request to the href value and then replacing the body of the document with the response.

  • When links are hovered (just before they are clicked), the href is prefetched so that when the link is actually clicked the document body replacement happens instantly.

"New Project" Modal

Our next goal will be to make the "New" and "Edit" forms appear in a modal and to update the display without a page refresh on form submit. We will make this happen by performing the following:

  • Add a <turbo-frame id="modal"> tag to our base layout

  • Add a data-turbo-frame="modal" attribute tag to the "+" link (new project)

  • Wrap the "New" form in a <turbo-frame id="modal"> tag

Adding the above will load the "New" form via AJAX into the <turbo-frame> tag. Once we have that working we can add styling to the "New" form to make it appear as a modal. Let's go through these steps in more detail.

In app/views/layouts/users.html.erb add the following tag on line 53:

<turbo-frame id="modal"></turbo-frame>

Next in the same file add the data-turbo-frame="modal" attribute to our "New Project" link, making it look like this:

<div>
  <%= link_to new_users_project_path, data: { turbo_frame: 'modal' } do %>
    <%= heroicon 'plus', options: { class: 'text-white w-5 h-5' } %>
  <% end %>
</div>

Finally in app/views/users/projects/new.html.erb add a <turbo-frame id="modal"> tag around the entire contents like this:

<turbo-frame id="modal">
  <div class="font-semibold text-lg mb-5">
    Add Project
  </div>

  <div>
    <%= form_for(@project, url: users_projects_path) do |f| %>
      <div class="mb-5">
        <%= f.label :name, class: 'block text-sm mb-1' %>
        <%= f.text_field :name, class: 'bg-transparent border-zinc-700 p-1 text-sm rounded' %>
      </div>

      <div class="flex gap-3">
        <%= link_to 'Cancel', users_projects_path, class: 'bg-zinc-800 py-1.5 px-4 rounded text-sm' %>
        <%= f.button 'Add', class: 'bg-red-500 py-1.5 px-4 rounded text-sm' %>
      </div>
    <% end %>
  </div>
</turbo-frame>

We should now see the following:

Interesting, but not useful. While the form is appearing and submitting via AJAX (without a page refresh) we need the display to be updated once the form has been submitted. We can do this by adding an attribute to the form: data-turbo-frame="_top". This instructs Turbo to follow the successful redirect by replacing the entire body ("_top") with the new response.

In app/views/users/projects/new.html.erb add the new attribute to the form_for tag like this:

<%= form_for(@project, url: users_projects_path, data: { turbo_frame: '_top'}) do |f| %>

Our app should now function like so:

Now let's style the form so that it looks like a modal. We will create a partial so that we can reuse the modal code for other forms. Create the file app/views/shared/_modal.html.erb with the following contents:

<div class="relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true">
  <div class="fixed inset-0 bg-black bg-opacity-75 transition-opacity" aria-hidden="true"></div>

  <div class="fixed inset-0 z-10 w-screen overflow-y-auto">
    <div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
      <div class="relative transform overflow-hidden rounded-lg bg-zinc-900 border border-zinc-700 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
        <%= yield %>
      </div>
    </div>
  </div>
</div>

Now wrap the "New Project" form useing the partial like so:

<turbo-frame id="modal">
  <%= render 'shared/modal' do %>
    <div class="font-semibold text-lg mb-5">
      Add Project
    </div>

    <div>
      <%= form_for(@project, url: users_projects_path, data: { turbo_frame: '_top'}) do |f| %>
        <div class="mb-5">
          <%= f.label :name, class: 'block text-sm mb-1' %>

          <%= f.text_field :name,
              autofocus: true,
              class: 'w-full bg-transparent border-zinc-700 p-1 text-sm rounded'
          %>
        </div>

        <div class="flex gap-3">
          <%= button_tag 'Cancel',
              type: 'button',
              class: 'bg-zinc-800 py-1.5 px-4 rounded text-sm',
              'x-on:click' => '$("#modal").html("").removeAttr("src")'
          %>

          <%= f.button 'Add', class: 'bg-red-500 py-1.5 px-4 rounded text-sm' %>
        </div>
      <% end %>
    </div>
  <% end %>
</turbo-frame>

Notice the new attribute added to the "Cancel" button:

'x-on:click' => '$("#modal").html("").removeAttr("src")'

This is a combination of AlpineJS and jQuery code that will close our modal when clicking the "Cancel" button. We need to include those two libraries in our layout file to make this work.

In app/views/users/projects/new.html.erb add the following to the head:

<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>

Let's explain how this works: the x-on:click attribute assigns an event listener to the respective element. The value of the attribute will be executed on the declared event. The attribute value $("#modal").html("").removeAttr("src") is jQuery code that will clear the contents of <turbo-frame id="modal"> and remove the src attribute. This essentially reverts the action that is performed when clicking the "New Project" (+) link.

"Edit Project" Modal

Let's now make the "Edit" control work by copying everything we've done over to app/views/users/projects/edit.html.erb:

<turbo-frame id="modal">
  <%= render 'shared/modal' do %>
    <div class="font-semibold text-lg mb-5">
      Edit
    </div>

    <div>
      <%= form_for(@project, url: users_project_path(@project), data: { turbo_frame: '_top' }) do |f| %>
        <div class="mb-5">
          <%= f.label :name, class: 'block text-sm mb-1' %>

          <%= f.text_field :name,
              autofocus: true,
              class: 'w-full bg-transparent border-zinc-700 p-1 text-sm rounded'
          %>
        </div>

        <div class="flex gap-3">
          <%= button_tag 'Cancel',
              type: 'button',
              class: 'bg-zinc-800 py-1.5 px-4 rounded text-sm',
              'x-on:click' => '$("#modal").html("").removeAttr("src")'
          %>

          <%= f.button 'Save', class: 'bg-red-500 py-1.5 px-4 rounded text-sm' %>
        </div>
      <% end %>
    </div>
  <% end %>
</turbo-frame>

Note that the above code does the following:

  • Adds a <turbo-frame id="modal"> tag

  • Adds the <%= render 'shared/modal' do %> partial code

  • Adds the data-turbo-frame="_top" attribute to the form

We need to add the data-turbo-frame="modal" attribute to the "Edit" link. In app/views/users/projects/index.html.erb make the "Edit" link look like this:

<%= link_to 'Edit',
    edit_users_project_path(project),
    class: 'text-sm',
    data: { turbo_frame: 'modal' }
%>

Our app should now look like this:

Now that we have Turbo installed we can change the "Delete" links to work according to the default Rails pattern (by using a DELETE request). First, let's revert the change the we made in our routes file that added the custom GET /delete route.

Change config/routes.rb to match the following:

Rails.application.routes.draw do
  namespace :users, path: 'app' do
    resources :projects
  end

  devise_for :users
  root 'pages#home'
end

Next in app/views/users/projects/index.html.erb change the "Delete" links to look like the following:

<%= link_to 'Delete',
    users_project_path(project),
    class: 'text-sm',
    data: {
      turbo_method: :delete,
      turbo_confirm: 'Are you sure?'
    }
%>

This does two things:

  • data-turbo-method is used when we want a link to send a non-GET request (in this case a DELETE request)

  • data-turbo-confirm is a helper method for the native JS alert popup (the click is cancelled if "Cancel" is clicked in the alert).

Our app should now look like this:

Summary

In this article we did the following things:

  • Created a Project model and projects DB table

  • Added server-side rendered CRUD pages for Projects (with page refreshes)

  • Installed Turbo and converted the CRUD pages to modals with no page refreshes

  • Styled the app to match the Todoist theme

0
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