Project DoList: "Project" CRUD / Hotwire
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 thebody
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 documentbody
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 layoutAdd 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">
tagAdds the
<%= render 'shared/modal' do %>
partial codeAdds 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:
"Delete" Links
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 aDELETE
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 andprojects
DB tableAdded 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
Subscribe to my newsletter
Read articles from Steven Wanderski directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by